From 9b3e2bfb4588a9ed66572f1864821e31af82a257 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 25 Sep 2025 16:43:26 +0200 Subject: [PATCH] 1571 extensibility twenty cli add command to create base entities like object agent or serverless (#14722) Remove default agent when init new app add command to create a new entity (object or agent) https://github.com/user-attachments/assets/b8f28281-2b40-4eaa-a958-55d2dd5183c8 --- .../src/commands/app-add.command.ts | 147 ++++++++++++++++++ .../src/commands/app-init.command.ts | 45 +++--- .../twenty-cli/src/commands/app.command.ts | 13 ++ .../src/utils/__tests__/app-template.test.ts | 9 +- packages/twenty-cli/src/utils/app-template.ts | 7 +- 5 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 packages/twenty-cli/src/commands/app-add.command.ts diff --git a/packages/twenty-cli/src/commands/app-add.command.ts b/packages/twenty-cli/src/commands/app-add.command.ts new file mode 100644 index 0000000000..979d2b814c --- /dev/null +++ b/packages/twenty-cli/src/commands/app-add.command.ts @@ -0,0 +1,147 @@ +import chalk from 'chalk'; +import { resolveAppPath } from '../utils/app-path-resolver'; +import * as fs from 'fs-extra'; +import inquirer from 'inquirer'; +import { v4 } from 'uuid'; +import path from 'path'; +import { getSchemaUrls } from '../utils/schema-validator'; +import { writeJsoncFile } from '../utils/jsonc-parser'; + +type SyncableEntity = 'agent' | 'object'; + +const getFolderName = (entity: SyncableEntity) => { + switch (entity) { + case 'agent': + return 'agents'; + case 'object': + return 'objects'; + default: + throw new Error(`Unknown entity type: ${entity}`); + } +}; + +export class AppAddCommand { + async execute(options: { path?: string }): Promise { + const entity = await this.getEntity(); + + try { + const appPath = await resolveAppPath(options.path); + + const appExists = await fs.pathExists(appPath); + + if (!appExists) { + console.error(chalk.red('App does not exist')); + process.exit(1); + } + + const entityName = await this.getEntityName(entity); + + const entityData = await this.getEntityToCreateData(entity, entityName); + + const folderName = getFolderName(entity); + + const entitiesDir = path.join(appPath, folderName); + + await fs.ensureDir(entitiesDir); + + const entityPath = path.join(entitiesDir, `${entityName}.jsonc`); + + await writeJsoncFile(entityPath, entityData); + } catch (error) { + console.error( + chalk.red(`Add new ${entity} failed:`), + error instanceof Error ? error.message : error, + ); + process.exit(1); + } + } + + private async getEntity() { + const { entity } = await inquirer.prompt([ + { + type: 'select', + name: 'entity', + message: `What entity do you want to create?`, + default: '', + choices: ['agent', 'object'], + }, + ]); + + return entity as SyncableEntity; + } + + private async getEntityName(entity: SyncableEntity) { + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: `Enter a name for your new ${entity}:`, + default: '', + validate: (input) => { + if (input.length === 0) { + return `${entity} name is required`; + } + + if (!/^[a-z0-9-]+$/.test(input)) { + return 'Name must contain only lowercase letters, numbers, and hyphens'; + } + + return true; + }, + }, + ]); + + return name; + } + + private async getEntityToCreateData( + entity: SyncableEntity, + entityName: string, + ) { + const schemas = getSchemaUrls(); + + const entityToCreateData: Record = { + $schema: schemas[entity], + standardId: v4(), + }; + + const schemasDir = path.join(__dirname, '../../schemas'); + + const schemaPath = path.join(schemasDir, `${entity}.schema.json`); + + const schema = await fs.readJson(schemaPath); + + const requiredFields = schema.required; + + for (const requiredField of requiredFields) { + if (requiredField === 'name') { + entityToCreateData.name = entityName; + continue; + } + + if (Object.keys(entityToCreateData).includes(requiredField)) { + continue; + } + + const answer = await inquirer.prompt([ + { + type: 'input', + name: requiredField, + message: `Enter a ${requiredField} for your new ${entity}:`, + default: '', + validate: (input) => { + try { + return input.length > 0; + } catch { + return 'Please enter non empty string'; + } + }, + }, + ]); + + entityToCreateData[requiredField] = answer[requiredField]; + } + + return entityToCreateData; + } +} diff --git a/packages/twenty-cli/src/commands/app-init.command.ts b/packages/twenty-cli/src/commands/app-init.command.ts index be4a85f61f..9a9c1baa5f 100644 --- a/packages/twenty-cli/src/commands/app-init.command.ts +++ b/packages/twenty-cli/src/commands/app-init.command.ts @@ -3,7 +3,6 @@ import * as fs from 'fs-extra'; import inquirer from 'inquirer'; import * as path from 'path'; import { - createAgentManifest, createBasePackageJson, createGitignoreContent, createReadmeContent, @@ -13,14 +12,15 @@ import { writeJsoncFile } from '../utils/jsonc-parser'; export class AppInitCommand { async execute(options: { path?: string; name?: string }): Promise { try { - const appName = await this.getAppName(options.name); - const appDir = this.determineAppDirectory(options.path, appName); + const { name, description } = await this.getAppInfos(options.name); + + const appDir = this.determineAppDirectory(name, options.path); await this.validateDirectory(appDir); - this.logCreationInfo(appDir, appName); + this.logCreationInfo(appDir, name); - await this.createAppStructure(appDir, appName); + await this.createAppStructure(appDir, name, description); this.logSuccess(appDir); } catch (error) { @@ -32,15 +32,17 @@ export class AppInitCommand { } } - private async getAppName(providedName?: string): Promise { + private async getAppInfos( + providedName?: string, + ): Promise<{ name: string; description: string }> { if (providedName) { - return providedName; + return { name: providedName, description: '' }; } - const nameAnswer = await inquirer.prompt([ + return inquirer.prompt([ { type: 'input', - name: 'appName', + name: 'name', message: 'Application name:', validate: (input) => { if (input.length === 0) return 'Application name is required'; @@ -49,17 +51,21 @@ export class AppInitCommand { return true; }, }, + { + type: 'input', + name: 'description', + message: 'Application description (optional):', + default: '', + }, ]); - - return nameAnswer.appName; } private determineAppDirectory( + appName: string, providedPath?: string, - appName?: string, ): string { if (providedPath) { - return path.resolve(providedPath); + return path.resolve(providedPath, appName); } return path.join(process.cwd(), appName!); @@ -86,24 +92,15 @@ export class AppInitCommand { private async createAppStructure( appDir: string, appName: string, + description: string, ): Promise { await fs.ensureDir(appDir); - // Create agents directory - const agentsDir = path.join(appDir, 'agents'); - await fs.ensureDir(agentsDir); - // Create main basePackageJson with agent references - const basePackageJson = createBasePackageJson(appName); + const basePackageJson = createBasePackageJson(appName, description); const basePackageJsonPath = path.join(appDir, 'package.json'); await writeJsoncFile(basePackageJsonPath, basePackageJson); - // Create agent basePackageJson file - const agentManifest = createAgentManifest(appName); - const agentFileName = `${appName}-agent`; - const agentPath = path.join(agentsDir, `${agentFileName}.jsonc`); - await writeJsoncFile(agentPath, agentManifest); - // Create README const readmeContent = createReadmeContent(appName, appDir); await fs.writeFile(path.join(appDir, 'README.md'), readmeContent); diff --git a/packages/twenty-cli/src/commands/app.command.ts b/packages/twenty-cli/src/commands/app.command.ts index 9bb511d2bc..8d20f6e391 100644 --- a/packages/twenty-cli/src/commands/app.command.ts +++ b/packages/twenty-cli/src/commands/app.command.ts @@ -2,11 +2,13 @@ import { Command } from 'commander'; import { AppSyncCommand } from './app-sync.command'; import { AppDevCommand } from './app-dev.command'; import { AppInitCommand } from './app-init.command'; +import { AppAddCommand } from './app-add.command'; export class AppCommand { private devCommand = new AppDevCommand(); private syncCommand = new AppSyncCommand(); private initCommand = new AppInitCommand(); + private addCommand = new AppAddCommand(); getCommand(): Command { const appCommand = new Command('app'); @@ -44,6 +46,17 @@ export class AppCommand { await this.initCommand.execute(options); }); + appCommand + .command('add') + .description('Add a new entity to your application') + .option( + '-p, --path ', + 'Application directory path (auto-detected if not specified)', + ) + .action(async (options) => { + await this.addCommand.execute(options); + }); + return appCommand; } } diff --git a/packages/twenty-cli/src/utils/__tests__/app-template.test.ts b/packages/twenty-cli/src/utils/__tests__/app-template.test.ts index 1c3cc6330c..933321619e 100644 --- a/packages/twenty-cli/src/utils/__tests__/app-template.test.ts +++ b/packages/twenty-cli/src/utils/__tests__/app-template.test.ts @@ -13,7 +13,8 @@ describe('app-template', () => { describe('createBasePackageJson', () => { it('should create a valid app package.json with correct structure', () => { const appName = 'my-test-app'; - const basePackageJson = createBasePackageJson(appName); + const description = 'A Twenty application for my-test-app'; + const basePackageJson = createBasePackageJson(appName, description); expect(basePackageJson).toEqual({ $schema: @@ -27,7 +28,7 @@ describe('app-template', () => { it('should handle single word app names', () => { const appName = 'calculator'; - const basePackageJson = createBasePackageJson(appName); + const basePackageJson = createBasePackageJson(appName, ''); expect(basePackageJson.label).toBe('Calculator'); expect(basePackageJson.standardId).toBe('mocked-uuid-12345'); @@ -35,14 +36,14 @@ describe('app-template', () => { it('should handle kebab-case app names correctly', () => { const appName = 'user-management-system'; - const basePackageJson = createBasePackageJson(appName); + const basePackageJson = createBasePackageJson(appName, ''); expect(basePackageJson.label).toBe('User Management System'); expect(basePackageJson.standardId).toBe('mocked-uuid-12345'); }); it('should generate unique standardIds', () => { - const basePackageJson = createBasePackageJson('test-app'); + const basePackageJson = createBasePackageJson('test-app', ''); expect(basePackageJson.standardId).toBeDefined(); expect(typeof basePackageJson.standardId).toBe('string'); diff --git a/packages/twenty-cli/src/utils/app-template.ts b/packages/twenty-cli/src/utils/app-template.ts index a595e8b144..411c36c19b 100644 --- a/packages/twenty-cli/src/utils/app-template.ts +++ b/packages/twenty-cli/src/utils/app-template.ts @@ -2,7 +2,10 @@ import { randomUUID } from 'crypto'; import { AgentManifest, PackageJson } from '../types/config.types'; import { getSchemaUrls } from './schema-validator'; -export const createBasePackageJson = (appName: string): PackageJson => { +export const createBasePackageJson = ( + appName: string, + description: string, +): PackageJson => { const schemas = getSchemaUrls(); return { @@ -12,7 +15,7 @@ export const createBasePackageJson = (appName: string): PackageJson => { .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '), - description: `A Twenty application for ${appName}`, + description, version: '0.0.1', }; };