Files
twenty/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts
T
Paul Rastoin 57d8954973 [SDK] Pure ESM (#18427)
# Introduction
While testing the sdk and overall apps in
https://github.com/prastoin/twenty-app-hello-world
Faced a lot of pure `CJS` external dependencies import issue

Replaced all the cjs deps to either esm equivalent or node native
replacement
2026-03-05 17:19:01 +01:00

490 lines
13 KiB
TypeScript

import chalk from 'chalk';
import inquirer from 'inquirer';
import { writeFile } from 'node:fs/promises';
import { join, relative } from 'path';
import { SyncableEntity } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template';
import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-component-template';
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template';
import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template';
import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils';
import { kebabCase } from '@/cli/utilities/string/kebab-case';
const APP_FOLDER = 'src';
export class EntityAddCommand {
private lastObjectUniversalIdentifier: string | undefined;
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
const entity = entityType ?? (await this.getEntity());
const entityName = this.getFolderName(entity);
const appPath = path
? join(CURRENT_EXECUTION_DIRECTORY, path)
: join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER, entityName);
await ensureDir(appPath);
const { name, file } = await this.getEntityData(entity);
const filePath = join(appPath, this.getFileName(name, entity));
if (await pathExists(filePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(filePath, file);
console.log(
chalk.green(`✓ Created ${entityName}:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, filePath)),
);
if (entity === SyncableEntity.Object) {
await this.promptAndCreateViewAndNavigationMenuItem(name, path);
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getEntityData(entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.Object: {
const entityData = await this.getObjectData();
const name = entityData.nameSingular;
const objectUniversalIdentifier = v4();
this.lastObjectUniversalIdentifier = objectUniversalIdentifier;
const file = getObjectBaseFile({
data: entityData,
name,
universalIdentifier: objectUniversalIdentifier,
});
return { name, file };
}
case SyncableEntity.Field: {
const entityData = await this.getFieldData();
const name = entityData.name;
const file = getFieldBaseFile({
data: entityData,
name,
});
return { name, file };
}
case SyncableEntity.LogicFunction: {
const name = await this.getEntityName(entity);
const file = getLogicFunctionBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.FrontComponent: {
const name = await this.getEntityName(entity);
const file = getFrontComponentBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Role: {
const name = await this.getEntityName(entity);
const file = getRoleBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Skill: {
const name = await this.getEntityName(entity);
const file = getSkillBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.View: {
const entityData = await this.getViewData();
const name = entityData.name;
const file = getViewBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.NavigationMenuItem: {
const name = await this.getEntityName(entity);
const file = getNavigationMenuItemBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.PageLayout: {
const name = await this.getEntityName(entity);
const file = getPageLayoutBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
}
private async promptAndCreateViewAndNavigationMenuItem(
objectName: string,
customPath?: string,
): Promise<void> {
const { createViewAndNavItem } = await inquirer.prompt<{
createViewAndNavItem: boolean;
}>([
{
type: 'confirm',
name: 'createViewAndNavItem',
message:
'Also create a view and navigation menu item for this object? (recommended)',
default: true,
},
]);
if (!createViewAndNavItem || !this.lastObjectUniversalIdentifier) {
return;
}
const viewUniversalIdentifier = v4();
const viewFile = getViewBaseFile({
name: `all-${kebabCase(objectName)}`,
universalIdentifier: viewUniversalIdentifier,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
});
const viewFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.View),
);
await ensureDir(viewFolderPath);
const viewFileName = `all-${kebabCase(objectName)}.ts`;
const viewFilePath = join(viewFolderPath, viewFileName);
if (await pathExists(viewFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(viewFilePath, viewFile);
console.log(
chalk.green(`✓ Created view:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, viewFilePath)),
);
const navFile = getNavigationMenuItemBaseFile({
name: objectName,
viewUniversalIdentifier,
});
const navFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.NavigationMenuItem),
);
await ensureDir(navFolderPath);
const navFileName = `${kebabCase(objectName)}.ts`;
const navFilePath = join(navFolderPath, navFileName);
if (await pathExists(navFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(navFilePath, navFile);
console.log(
chalk.green(`✓ Created navigation menu item:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, navFilePath)),
);
}
private async getEntity() {
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: Object.values(SyncableEntity),
},
]);
return entity;
}
private async handleFileExist() {
return await inquirer.prompt<{ overwrite: boolean }>([
{
type: 'confirm',
name: 'overwrite',
message: `File already exists. Do you want to overwrite it?`,
default: false,
},
]);
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt<{ name: string }>([
{
type: 'input',
name: 'name',
message: `Enter a name for your new ${entity}:`,
default: '',
validate: (input) => {
if (input.length === 0) {
return `${entity} name is required`;
}
return true;
},
},
]);
return name;
}
private async getFieldData() {
return inquirer.prompt<{
name: string;
label: string;
type: FieldMetadataType;
objectUniversalIdentifier: string;
description: string;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your field:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'label',
message: 'Enter a label for your field:',
default: (answers: any) => {
return convertToLabel(answers.name);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'select',
name: 'type',
message: 'Select the field type:',
choices: Object.values(FieldMetadataType),
default: FieldMetadataType.TEXT,
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this field belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'description',
message: 'Enter a description for your field (optional):',
default: '',
},
]);
}
private async getObjectData() {
return inquirer.prompt<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
}>([
{
type: 'input',
name: 'nameSingular',
message: 'Enter a name singular for your object (eg: company):',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'namePlural',
message: 'Enter a name plural for your object (eg: companies):',
default: '',
validate: (input: string, answers?: any) => {
if (input.trim() === answers?.nameSingular.trim()) {
return 'Name plural must be different from name singular';
}
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelSingular',
message: 'Enter a label singular for your object:',
default: (answers: any) => {
return convertToLabel(answers.nameSingular);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelPlural',
message: 'Enter a label plural for your object:',
default: (answers: any) => {
return convertToLabel(answers.namePlural);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
private async getViewData() {
return inquirer.prompt<{
name: string;
objectUniversalIdentifier: string;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your view:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this view belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
getFolderName(entity: SyncableEntity) {
return `${kebabCase(entity)}s`;
}
getFileName(name: string, entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.FrontComponent: {
return `${kebabCase(name)}.tsx`;
}
default: {
return `${kebabCase(name)}.ts`;
}
}
}
}