From aa5d30a91129128c2d6439ebc0b867928bd2e7ef Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 21 Nov 2025 19:21:30 +0100 Subject: [PATCH] Fix twenty cli (#15997) As title fixes "app add" and "app init" commands adds tests --- packages/twenty-cli/package.json | 4 +- packages/twenty-cli/project.json | 3 +- .../src/commands/app-add.command.ts | 165 ++++++++---------- .../src/commands/app-init.command.ts | 16 +- .../twenty-cli/src/commands/app.command.ts | 8 +- .../src/constants/constants-path.ts | 2 - packages/twenty-cli/src/constants/schemas.ts | 8 - .../src/constants/schemas/agent.schema.json | 109 ------------ .../constants/schemas/appManifest.schema.json | 129 -------------- .../src/constants/schemas/object.schema.json | 78 --------- .../schemas/serverlessFunction.schema.json | 107 ------------ .../src/constants/schemas/trigger.schema.json | 43 ----- .../utils/__tests__/convert-to-label.spec.ts | 10 ++ .../__tests__/get-decorated-class.spec.ts | 21 --- .../get-object-decorated-class.spec.ts | 30 ++++ .../get-serverless-function-base-file.spec.ts | 34 ++++ packages/twenty-cli/src/utils/app-template.ts | 7 +- .../twenty-cli/src/utils/convert-to-label.ts | 6 + ...class.ts => get-object-decorated-class.ts} | 6 +- .../get-serverless-function-base-file.ts | 10 +- .../twenty-cli/src/utils/schema-validator.ts | 83 --------- yarn.lock | 18 ++ 22 files changed, 201 insertions(+), 696 deletions(-) delete mode 100644 packages/twenty-cli/src/constants/schemas.ts delete mode 100644 packages/twenty-cli/src/constants/schemas/agent.schema.json delete mode 100644 packages/twenty-cli/src/constants/schemas/appManifest.schema.json delete mode 100644 packages/twenty-cli/src/constants/schemas/object.schema.json delete mode 100644 packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json delete mode 100644 packages/twenty-cli/src/constants/schemas/trigger.schema.json create mode 100644 packages/twenty-cli/src/utils/__tests__/convert-to-label.spec.ts delete mode 100644 packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts create mode 100644 packages/twenty-cli/src/utils/__tests__/get-object-decorated-class.spec.ts create mode 100644 packages/twenty-cli/src/utils/__tests__/get-serverless-function-base-file.spec.ts create mode 100644 packages/twenty-cli/src/utils/convert-to-label.ts rename packages/twenty-cli/src/utils/{get-object-metadata-decorated-class.ts => get-object-decorated-class.ts} (74%) delete mode 100644 packages/twenty-cli/src/utils/schema-validator.ts diff --git a/packages/twenty-cli/package.json b/packages/twenty-cli/package.json index 63e457db7f1..b34af9d2c56 100644 --- a/packages/twenty-cli/package.json +++ b/packages/twenty-cli/package.json @@ -1,6 +1,6 @@ { "name": "twenty-cli", - "version": "0.2.3", + "version": "0.2.4", "description": "Command-line interface for Twenty application development", "main": "dist/cli.js", "bin": { @@ -40,6 +40,7 @@ "lodash.camelcase": "^4.3.0", "lodash.capitalize": "^4.2.1", "lodash.kebabcase": "^4.1.1", + "lodash.startcase": "^4.4.0", "typescript": "^5.9.2", "uuid": "^13.0.0" }, @@ -50,6 +51,7 @@ "@types/lodash.camelcase": "^4.3.7", "@types/lodash.capitalize": "^4", "@types/lodash.kebabcase": "^4.1.7", + "@types/lodash.startcase": "^4", "@types/node": "^20.0.0", "jest": "^29.5.0", "tsx": "^4.7.0", diff --git a/packages/twenty-cli/project.json b/packages/twenty-cli/project.json index 392c97eca1a..a9f20d188a8 100644 --- a/packages/twenty-cli/project.json +++ b/packages/twenty-cli/project.json @@ -19,8 +19,7 @@ "options": { "cwd": "packages/twenty-cli", "commands": [ - "cp -R src/constants/base-application-project dist/constants", - "cp -R src/constants/schemas dist/constants" + "cp -R src/constants/base-application-project dist/constants" ] }, "dependsOn": ["before-build"] diff --git a/packages/twenty-cli/src/commands/app-add.command.ts b/packages/twenty-cli/src/commands/app-add.command.ts index 57adf66ddeb..b35d8c361a4 100644 --- a/packages/twenty-cli/src/commands/app-add.command.ts +++ b/packages/twenty-cli/src/commands/app-add.command.ts @@ -1,16 +1,12 @@ import chalk from 'chalk'; -import { randomUUID } from 'crypto'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; -import path from 'path'; +import { join } from 'path'; import camelcase from 'lodash.camelcase'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; -import { getSchemaUrls } from '../utils/schema-validator'; -import { BASE_SCHEMAS_PATH } from '../constants/constants-path'; -import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class'; +import { getObjectDecoratedClass } from '../utils/get-object-decorated-class'; import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file'; - -const ROOT_FOLDER = 'src'; +import { convertToLabel } from '../utils/convert-to-label'; export enum SyncableEntity { AGENT = 'agent', @@ -23,35 +19,34 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => { }; export class AppAddCommand { - async execute(entityType?: SyncableEntity): Promise { + async execute(entityType?: SyncableEntity, path?: string): Promise { try { - const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER); + const appPath = join(CURRENT_EXECUTION_DIRECTORY, path ?? ''); await fs.ensureDir(appPath); const entity = entityType ?? (await this.getEntity()); - const entityName = await this.getEntityName(entity); - - const entityData = await this.getEntityToCreateData(entity, entityName); - if (entity === SyncableEntity.OBJECT) { - delete entityData['standardId']; - delete entityData['$schema']; + const entityData = await this.getObjectData(); - const objectFileName = `${camelcase(entityName)}.ts`; + const name = entityData.nameSingular; - const decoratedObject = getObjectMetadataDecoratedClass({ + const objectFileName = `${camelcase(name)}.ts`; + + const decoratedObject = getObjectDecoratedClass({ data: entityData, - name: entityName, + name, }); - await fs.writeFile(path.join(appPath, objectFileName), decoratedObject); + await fs.writeFile(join(appPath, objectFileName), decoratedObject); return; } if (entity === SyncableEntity.SERVERLESS_FUNCTION) { + const entityName = await this.getEntityName(entity); + const objectFileName = `${camelcase(entityName)}.ts`; const decoratedServerlessFunction = getServerlessFunctionBaseFile({ @@ -59,7 +54,7 @@ export class AppAddCommand { }); await fs.writeFile( - path.join(appPath, objectFileName), + join(appPath, objectFileName), decoratedServerlessFunction, ); @@ -74,27 +69,6 @@ export class AppAddCommand { } } - private async addEntityInitFiles(entity: SyncableEntity, entityPath: string) { - switch (entity) { - case SyncableEntity.SERVERLESS_FUNCTION: { - const srcPath = path.join(entityPath, 'src'); - await fs.ensureDir(srcPath); - - await fs.writeFile( - path.join(srcPath, 'index.ts'), - 'export const main = async (params: {\n a: string;\n b: number;\n}): Promise => {\n const { a, b } = params;\n\n // Rename the parameters and code below with your own logic\n // This is just an example\n const message = `Hello, input: ${a} and ${b}`;\n\n\n\n return { message };\n};', - ); - - return; - } - case SyncableEntity.AGENT: - case SyncableEntity.OBJECT: - return; - default: - throw new Error(`Unknown entity type: ${entity}`); - } - } - private async getEntity() { const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([ { @@ -133,58 +107,63 @@ export class AppAddCommand { return name; } - private async getEntityToCreateData( - entity: SyncableEntity, - entityName: string, - ) { - const schemas = getSchemaUrls(); - - const uuid = randomUUID(); - - const entityToCreateData: Record = { - $schema: schemas[entity], - universalIdentifier: uuid, - }; - - if (entity === SyncableEntity.OBJECT || entity === SyncableEntity.AGENT) { - entityToCreateData.standardId = uuid; - } - - const schemaPath = path.join(BASE_SCHEMAS_PATH, `${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<{ [key: string]: string }>([ - { - 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'; - } - }, + private async getObjectData() { + return inquirer.prompt([ + { + 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; }, - ]); - - entityToCreateData[requiredField] = answer[requiredField]; - } - - return entityToCreateData; + }, + { + 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; + }, + }, + ]); } } diff --git a/packages/twenty-cli/src/commands/app-init.command.ts b/packages/twenty-cli/src/commands/app-init.command.ts index 733b8272fe9..a773d10c724 100644 --- a/packages/twenty-cli/src/commands/app-init.command.ts +++ b/packages/twenty-cli/src/commands/app-init.command.ts @@ -4,6 +4,7 @@ import inquirer from 'inquirer'; import * as path from 'path'; import { copyBaseApplicationProject } from '../utils/app-template'; import kebabCase from 'lodash.kebabcase'; +import { convertToLabel } from '../utils/convert-to-label'; export class AppInitCommand { async execute(directory?: string): Promise { @@ -44,7 +45,9 @@ export class AppInitCommand { { type: 'input', name: 'name', - message: 'Application name (eg: my-awesome-app):', + message: 'Application name:', + when: () => !directory, + default: 'my-awesome-app', validate: (input) => { if (input.length === 0) return 'Application name is required'; return true; @@ -53,12 +56,9 @@ export class AppInitCommand { { type: 'input', name: 'displayName', - message: 'Display name (eg: My awesome app):', + message: 'Application display name:', default: (answers: any) => { - return answers.name - .split('-') - .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + return convertToLabel(answers?.name ?? directory); }, }, { @@ -69,7 +69,9 @@ export class AppInitCommand { }, ]); - const appName = name.trim(); + const computedName = name ?? directory; + + const appName = computedName.trim(); const appDisplayName = displayName.trim(); diff --git a/packages/twenty-cli/src/commands/app.command.ts b/packages/twenty-cli/src/commands/app.command.ts index 3c1030fb878..397365b8371 100644 --- a/packages/twenty-cli/src/commands/app.command.ts +++ b/packages/twenty-cli/src/commands/app.command.ts @@ -101,10 +101,11 @@ export class AppCommand { appCommand .command('add [entityType]') + .option('--path ', 'Path in which the entity should be created.') .description( `Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`, ) - .action(async (entityType?: string) => { + .action(async (entityType?: string, options?: { path?: string }) => { if (entityType && !isSyncableEntity(entityType)) { console.error( chalk.red( @@ -113,7 +114,10 @@ export class AppCommand { ); process.exit(1); } - await this.addCommand.execute(entityType as SyncableEntity); + await this.addCommand.execute( + entityType as SyncableEntity, + options?.path, + ); }); appCommand diff --git a/packages/twenty-cli/src/constants/constants-path.ts b/packages/twenty-cli/src/constants/constants-path.ts index 82d7a5a6ace..2857516d90e 100644 --- a/packages/twenty-cli/src/constants/constants-path.ts +++ b/packages/twenty-cli/src/constants/constants-path.ts @@ -6,5 +6,3 @@ export const BASE_APPLICATION_PROJECT_PATH = join( BASE_PATH, 'base-application-project', ); - -export const BASE_SCHEMAS_PATH = join(BASE_PATH, 'schemas'); diff --git a/packages/twenty-cli/src/constants/schemas.ts b/packages/twenty-cli/src/constants/schemas.ts deleted file mode 100644 index 988bbcae5ee..00000000000 --- a/packages/twenty-cli/src/constants/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -const SCHEMA_BASE_URL = - 'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas'; - -export const APP_MANIFEST_SCHEMA_URL = `${SCHEMA_BASE_URL}/appManifest.schema.json`; -export const AGENT_SCHEMA_URL = `${SCHEMA_BASE_URL}/agent.schema.json`; -export const OBJECT_SCHEMA_URL = `${SCHEMA_BASE_URL}/object.schema.json`; -export const TRIGGER_SCHEMA_URL = `${SCHEMA_BASE_URL}/trigger.schema.json`; -export const SERVERLESS_FUNCTION_SCHEMA_URL = `${SCHEMA_BASE_URL}/serverlessFunction.schema.json`; diff --git a/packages/twenty-cli/src/constants/schemas/agent.schema.json b/packages/twenty-cli/src/constants/schemas/agent.schema.json deleted file mode 100644 index d173dc28c8b..00000000000 --- a/packages/twenty-cli/src/constants/schemas/agent.schema.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/agent.schema.json", - "title": "Twenty Agent Manifest", - "description": "Schema for Twenty AI agent configuration files", - "type": "object", - "required": ["standardId", "universalIdentifier", "name", "label", "prompt", "modelId"], - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation and IDE support" - }, - "universalIdentifier": { - "type": "string", - "description": "Unique identifier (UUID format recommended)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "standardId": { - "const": { "$data": "1/universalIdentifier" }, - "description": "Should be the same as universalIdentifier" - }, - "name": { - "type": "string", - "description": "Internal name for the agent (camelCase, used in code)", - "pattern": "^[a-zA-Z][a-zA-Z0-9]*$", - "minLength": 1, - "maxLength": 100 - }, - "label": { - "type": "string", - "description": "Human-readable display name for the agent", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "description": "Brief description of what the agent does", - "maxLength": 500 - }, - "icon": { - "type": "string", - "description": "Icon for the agent (emoji or icon name)", - "maxLength": 50 - }, - "prompt": { - "type": "string", - "description": "System prompt that defines the agent's behavior and personality", - "minLength": 10, - "maxLength": 10000 - }, - "modelId": { - "type": "string", - "description": "AI model to use for this agent", - "default": "auto", - "enum": [ - "auto", - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - "claude-opus-4-20250514", - "claude-sonnet-4-20250514", - "claude-3-5-haiku-20241022", - "grok-3", - "grok-3-mini", - "grok-4" - ] - }, - "responseFormat": { - "type": "object", - "description": "Format specification for agent responses", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "description": "Response format type", - "enum": ["text", "json"] - }, - "schema": { - "type": "object", - "description": "JSON schema for structured responses (required when type is 'json')", - "additionalProperties": true - } - }, - "if": { - "properties": { - "type": { "const": "json" } - } - }, - "then": { - "required": ["schema"] - } - } - }, - "additionalProperties": false, - "examples": [ - { - "standardId": "550e8400-e29b-41d4-a716-446655440001", - "name": "customerSupportAgent", - "label": "Customer Support Assistant", - "description": "Helps customers with their inquiries and issues", - "icon": "🎧", - "prompt": "You are a helpful customer support agent. Always be polite, professional, and solution-oriented.", - "modelId": "auto", - "responseFormat": { - "type": "text" - } - } - ] -} diff --git a/packages/twenty-cli/src/constants/schemas/appManifest.schema.json b/packages/twenty-cli/src/constants/schemas/appManifest.schema.json deleted file mode 100644 index 573d923090c..00000000000 --- a/packages/twenty-cli/src/constants/schemas/appManifest.schema.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json", - "title": "Twenty App Manifest", - "description": "Schema for Twenty application manifest files", - "type": "object", - "required": ["universalIdentifier", "name", "version", "license", "engines", "packageManager"], - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation and IDE support" - }, - "universalIdentifier": { - "type": "string", - "description": "Unique identifier (UUID format recommended)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "name": { - "type": "string", - "description": "Human-readable display name for the application", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "description": "Brief description of what the application does", - "maxLength": 1000 - }, - "icon": { - "type": "string", - "description": "Icon for the application (emoji or icon name)", - "maxLength": 50 - }, - "license": { - "const": "MIT", - "title": "The application's license", - "description": "Currently only MIT is accepted, although more licenses will probably be available in the future." - }, - "env": { - "type": "object", - "title": "Environment Variables", - "description": "Key-value pairs defining environment variables available to all serverless functions.", - "patternProperties": { - "^[A-Z_][A-Z0-9_]*$": { - "type": "object", - "title": "Environment Variable Definition", - "properties": { - "universalIdentifier": { - "type": "string", - "description": "Unique identifier (UUID format recommended)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "description": { - "type": "string", - "description": "Description for this environment variable." - }, - "value": { - "type": "string", - "description": "Default value for this environment variable" - }, - "isSecret": { - "type": "boolean", - "description": "If true, the value will be treated as sensitive and hidden from logs or UI." - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "engines": { - "type": "object", - "title": "The application's engines", - "description": "Define engines here" - }, - "packageManager": { - "const": "yarn@4.9.2", - "title": "Package manager of the application" - }, - "version": { - "type": "string", - "description": "Semantic version of the application", - "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9-]+)?$" - }, - "dependencies": { - "type": "object", - "title": "The extension's source dependencies", - "description": "Source dependencies following the npm package.json dependency format." - }, - "devDependencies": { - "type": "object", - "title": "The extension's source devDependencies", - "description": "Dev dependencies following the npm package.json dependency format." - }, - "agents": { - "type": "array", - "description": "Optional inline agent definitions (agents are typically discovered from the agents/ folder)", - "items": { - "type": "object", - "description": "Inline agent definition", - "$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/agent.schema.json" - } - }, - "objects": { - "type": "array", - "description": "Optional inline object definitions (objects are typically discovered from the objects/ folder)", - "items": { - "type": "object", - "description": "Inline object definition", - "$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json" - } - } - }, - "examples": [ - { - "standardId": "550e8400-e29b-41d4-a716-446655440000", - "name": "Customer Support App", - "description": "Comprehensive customer support application with AI agents", - "icon": "🎧", - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": "^24.5.0", - "npm": "please-use-yarn", - "yarn": ">=4.9.2" - } - } - ] -} diff --git a/packages/twenty-cli/src/constants/schemas/object.schema.json b/packages/twenty-cli/src/constants/schemas/object.schema.json deleted file mode 100644 index d11839ad509..00000000000 --- a/packages/twenty-cli/src/constants/schemas/object.schema.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json", - "title": "Twenty Object Manifest", - "description": "Schema for Twenty AI object configuration files", - "type": "object", - "required": [ - "standardId", - "universalIdentifier", - "nameSingular", - "namePlural", - "labelSingular", - "labelPlural" - ], - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation and IDE support" - }, - "universalIdentifier": { - "type": "string", - "description": "Unique identifier (UUID format recommended)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "standardId": { - "const": { "$data": "1/universalIdentifier" }, - "description": "Should be the same as universalIdentifier" - }, - "nameSingular": { - "type": "string", - "description": "Name singular for the object", - "pattern": "^[a-zA-Z][a-zA-Z0-9]*$", - "minLength": 1, - "maxLength": 100 - }, - "namePlural": { - "type": "string", - "description": "Name plural for the object", - "pattern": "^[a-zA-Z][a-zA-Z0-9]*$", - "minLength": 1, - "maxLength": 100 - }, - "labelSingular": { - "type": "string", - "description": "Human-readable display name singular for the object", - "minLength": 1, - "maxLength": 200 - }, - "labelPlural": { - "type": "string", - "description": "Human-readable display name singular for the object", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "description": "Brief description of the object", - "maxLength": 500 - }, - "icon": { - "type": "string", - "description": "Icon for the object (emoji or icon name)", - "maxLength": 50 - } - }, - "additionalProperties": false, - "examples": [ - { - "standardId": "550e8400-e29b-41d4-a716-446655440001", - "nameSingular": "object", - "namePlural": "objects", - "labelSingular": "Object", - "labelPlural": "Objects", - "description": "Object description", - "icon": "🎧" - } - ] -} diff --git a/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json b/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json deleted file mode 100644 index 92df2055e46..00000000000 --- a/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json", - "title": "Twenty Serverless Function Manifest", - "description": "Schema for Twenty AI serverless function configuration files", - "type": "object", - "required": ["universalIdentifier"], - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation and IDE support" - }, - "universalIdentifier": { - "type": "string", - "description": "Unique identifier (UUID format recommended)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "name": { - "type": "string", - "description": "Name singular for the serverless function (eg: my-serverless-function)", - "pattern": "^[a-z0-9-]+$", - "minLength": 1, - "maxLength": 100 - }, - "description": { - "type": "string", - "description": "Brief description of the serverless function", - "maxLength": 500 - }, - "timeoutSeconds": { - "type": "number", - "description": "Serverless function timeout in seconds, between 1 and 900", - "min": 1, - "max": 900 - }, - "triggers": { - "type": "array", - "description": "Serverless function's triggers", - "items": { - "anyOf": [ - { "$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/trigger.schema.json" }, - { - "type": "object", - "required": ["$ref"], - "properties": { "$ref": { "type": "string" } }, - "additionalProperties": false - } - ] - } - }, - "code": { - "type": "object", - "description": "Serverless function's code", - "required": ["src"], - "properties": { - "src": { - "type": "object", - "description":"Serverless function source folder", - "required": ["index.ts"], - "properties": { - "index.ts": { - "type": "string", - "description":"Serverless function index.ts file" - }, - "additionalProperties": true - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "examples": [ - { - "standardId": "550e8400-e29b-41d4-a716-446655440001", - "universalIdentifier": "550e8400-e29b-41d4-a716-446655440001", - "name": "My serverless function", - "triggers": [ - { - "standardId": "550e8400-e29b-41d4-a716-446655440002", - "universalIdentifier": "550e8400-e29b-41d4-a716-446655440002", - "type": "cron", - "schedule": "0 9 * * *" - }, - { - "standardId": "550e8400-e29b-41d4-a716-446655440003", - "universalIdentifier": "550e8400-e29b-41d4-a716-446655440003", - "type": "databaseEvent", - "eventName": "company.created" - }, - { - "standardId": "550e8400-e29b-41d4-a716-446655440004", - "universalIdentifier": "550e8400-e29b-41d4-a716-446655440004", - "type": "route", - "path": "test-route", - "httpMethod": "GET", - "isAuthRequired": false - } - ], - "code": { - "src": { - "index.ts": "{\n \"code\": \"import axios from 'axios';\\n\\nexport const main = async (params) => {\\n const { a, b } = params;\\n const message = \\\"toto\\\";\\n return { message };\\n};\",\n \"params\": { \"a\": \"1\", \"b\": 2 }\n}\n" - } - } - } - ] -} diff --git a/packages/twenty-cli/src/constants/schemas/trigger.schema.json b/packages/twenty-cli/src/constants/schemas/trigger.schema.json deleted file mode 100644 index 38766fc72a5..00000000000 --- a/packages/twenty-cli/src/constants/schemas/trigger.schema.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/trigger.schema.json", - "type": "object", - "required": ["universalIdentifier", "type"], - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation and IDE support" - }, - - "universalIdentifier": { - "type": "string", - "description": "Unique identifier (UUID format recommended)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - - "type": { "enum": ["cron", "databaseEvent", "route"] }, - - "pattern": { "type": "string" }, - - "eventName": { "type": "string" }, - - "path": { "type": "string" }, - - "httpMethod": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }, - - "isAuthRequired": { "type": "boolean" } - }, - "allOf": [ - { - "if": { "properties": { "type": { "const": "cron" } } }, - "then": { "required": ["pattern"] } - }, - { - "if": { "properties": { "type": { "const": "databaseEvent" } } }, - "then": { "required": ["eventName"] } - }, - { - "if": { "properties": { "type": { "const": "route" } } }, - "then": { "required": ["path", "httpMethod", "isAuthRequired"] } - } - ] -} diff --git a/packages/twenty-cli/src/utils/__tests__/convert-to-label.spec.ts b/packages/twenty-cli/src/utils/__tests__/convert-to-label.spec.ts new file mode 100644 index 00000000000..d565478c2a8 --- /dev/null +++ b/packages/twenty-cli/src/utils/__tests__/convert-to-label.spec.ts @@ -0,0 +1,10 @@ +import { convertToLabel } from '../convert-to-label'; + +describe('convertToLabel', () => { + it('should convert to label', () => { + expect(convertToLabel('toto')).toBe('Toto'); + expect(convertToLabel('totoTata')).toBe('Toto tata'); + expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi'); + expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi'); + }); +}); diff --git a/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts b/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts deleted file mode 100644 index 0f14ac778d0..00000000000 --- a/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getObjectMetadataDecoratedClass } from '../../utils/get-object-metadata-decorated-class'; - -describe('getDecoratedClass', () => { - it('should return properly formatted class', () => { - const result = getObjectMetadataDecoratedClass({ - data: { nameSingular: 'Name', namePlural: 'Names' }, - name: 'MyNewObject', - }); - - const expectedResult = `import { ObjectMetadata } from 'twenty-sdk/application'; - -@ObjectMetadata({ - nameSingular: 'Name', - namePlural: 'Names', -}) -export class MyNewObject {} -`; - - expect(result).toEqual(expectedResult); - }); -}); diff --git a/packages/twenty-cli/src/utils/__tests__/get-object-decorated-class.spec.ts b/packages/twenty-cli/src/utils/__tests__/get-object-decorated-class.spec.ts new file mode 100644 index 00000000000..302d50a06c1 --- /dev/null +++ b/packages/twenty-cli/src/utils/__tests__/get-object-decorated-class.spec.ts @@ -0,0 +1,30 @@ +import { getObjectDecoratedClass } from '../get-object-decorated-class'; + +describe('getObjectDecoratedClass', () => { + it('should return proper object file', () => { + expect( + getObjectDecoratedClass({ + data: { + universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf', + nameSingular: 'name', + namePlural: 'names', + labelSingular: 'Name', + labelPlural: 'Names', + }, + name: 'MyNewObject', + }), + ).toBe( + `import { Object } from 'twenty-sdk/application'; + +@Object({ + universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf', + nameSingular: 'name', + namePlural: 'names', + labelSingular: 'Name', + labelPlural: 'Names', +}) +export class MyNewObject {} +`, + ); + }); +}); diff --git a/packages/twenty-cli/src/utils/__tests__/get-serverless-function-base-file.spec.ts b/packages/twenty-cli/src/utils/__tests__/get-serverless-function-base-file.spec.ts new file mode 100644 index 00000000000..638863ad2fe --- /dev/null +++ b/packages/twenty-cli/src/utils/__tests__/get-serverless-function-base-file.spec.ts @@ -0,0 +1,34 @@ +import { getServerlessFunctionBaseFile } from '../get-serverless-function-base-file'; + +describe('getServerlessFunctionBaseFile', () => { + it('should render proper file', () => { + expect( + getServerlessFunctionBaseFile({ + name: 'serverless-function-name', + universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', + }), + ) + .toBe(`import { type ServerlessFunctionConfig } from 'twenty-sdk/application'; + +export const main = async (params: { + a: string; + b: number; +}): Promise<{ message: string }> => { + const { a, b } = params; + + // Rename the parameters and code below with your own logic + // This is just an example + const message = \`Hello, input: \${a} and \${b}\`; + + return { message }; +}; + +export const config: ServerlessFunctionConfig = { + universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', + name: 'serverless-function-name', + timeoutSeconds: 5, +}; + +`); + }); +}); diff --git a/packages/twenty-cli/src/utils/app-template.ts b/packages/twenty-cli/src/utils/app-template.ts index f4c7061ce65..d9c22b25147 100644 --- a/packages/twenty-cli/src/utils/app-template.ts +++ b/packages/twenty-cli/src/utils/app-template.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'crypto'; -import { getSchemaUrls } from './schema-validator'; import * as fs from 'fs-extra'; import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path'; import { writeJsoncFile } from '../utils/jsonc-parser'; @@ -77,10 +75,7 @@ const createBasePackageJson = async ({ }) => { const base = JSON.parse(await readBaseApplicationProjectFile('package.json')); - const schemas = getSchemaUrls(); - - base['$schema'] = schemas.appManifest; - base['universalIdentifier'] = randomUUID(); + base['universalIdentifier'] = v4(); base['name'] = appName; await writeJsoncFile(join(appDirectory, 'package.json'), base); diff --git a/packages/twenty-cli/src/utils/convert-to-label.ts b/packages/twenty-cli/src/utils/convert-to-label.ts new file mode 100644 index 00000000000..e71d9db12ca --- /dev/null +++ b/packages/twenty-cli/src/utils/convert-to-label.ts @@ -0,0 +1,6 @@ +import { startCase } from 'lodash'; + +export const convertToLabel = (str: string) => { + const s = startCase(str).toLowerCase(); + return s.charAt(0).toUpperCase() + s.slice(1); +}; diff --git a/packages/twenty-cli/src/utils/get-object-metadata-decorated-class.ts b/packages/twenty-cli/src/utils/get-object-decorated-class.ts similarity index 74% rename from packages/twenty-cli/src/utils/get-object-metadata-decorated-class.ts rename to packages/twenty-cli/src/utils/get-object-decorated-class.ts index 710a8babf86..b9b2594854b 100644 --- a/packages/twenty-cli/src/utils/get-object-metadata-decorated-class.ts +++ b/packages/twenty-cli/src/utils/get-object-decorated-class.ts @@ -1,6 +1,6 @@ import camelcase from 'lodash.camelcase'; -export const getObjectMetadataDecoratedClass = ({ +export const getObjectDecoratedClass = ({ data, name, }: { @@ -15,9 +15,9 @@ export const getObjectMetadataDecoratedClass = ({ const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1); - return `import { ObjectMetadata } from 'twenty-sdk/application'; + return `import { Object } from 'twenty-sdk/application'; -@ObjectMetadata({ +@Object({ ${decoratorOptions} }) export class ${className} {} diff --git a/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts b/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts index 35b5252e25c..2d1216b23df 100644 --- a/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts +++ b/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts @@ -1,7 +1,13 @@ import kebabCase from 'lodash.kebabcase'; import { v4 } from 'uuid'; -export const getServerlessFunctionBaseFile = ({ name }: { name: string }) => { +export const getServerlessFunctionBaseFile = ({ + name, + universalIdentifier = v4(), +}: { + name: string; + universalIdentifier?: string; +}) => { const kebabCaseName = kebabCase(name); return `import { type ServerlessFunctionConfig } from 'twenty-sdk/application'; @@ -20,7 +26,7 @@ export const main = async (params: { }; export const config: ServerlessFunctionConfig = { - universalIdentifier: '${v4()}', + universalIdentifier: '${universalIdentifier}', name: '${kebabCaseName}', timeoutSeconds: 5, }; diff --git a/packages/twenty-cli/src/utils/schema-validator.ts b/packages/twenty-cli/src/utils/schema-validator.ts deleted file mode 100644 index 1dbc82ad325..00000000000 --- a/packages/twenty-cli/src/utils/schema-validator.ts +++ /dev/null @@ -1,83 +0,0 @@ -import Ajv from 'ajv'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { - AGENT_SCHEMA_URL, - APP_MANIFEST_SCHEMA_URL, - OBJECT_SCHEMA_URL, - SERVERLESS_FUNCTION_SCHEMA_URL, - TRIGGER_SCHEMA_URL, -} from '../constants/schemas'; -import { BASE_SCHEMAS_PATH } from '../constants/constants-path'; - -export class SchemaValidationError extends Error { - constructor( - message: string, - public readonly errors: any[], - public readonly filePath?: string, - ) { - super(message); - this.name = 'SchemaValidationError'; - } -} - -const formatErrors = (errors: any[]): string => { - return errors - .map((error) => { - const path = error.instancePath || 'root'; - const message = error.message; - const value = - error.data !== undefined ? ` (got: ${JSON.stringify(error.data)})` : ''; - return ` • ${path}: ${message}${value}`; - }) - .join('\n'); -}; - -export const validateSchema = async ( - schemaName: 'appManifest' | 'agent' | 'object' | 'serverlessFunction', - manifest: any, - filePath?: string, -): Promise => { - const ajv = new Ajv({ - allErrors: true, - verbose: true, - strict: false, - $data: true, - }); - - const schemaUrls = getSchemaUrls(); - - let schema; - - for (const name of Object.keys(schemaUrls) as (keyof typeof schemaUrls)[]) { - const schemaPath = path.join(BASE_SCHEMAS_PATH, `${name}.schema.json`); - ajv.addSchema(await fs.readJson(schemaPath)); - - if (name === schemaName) { - schema = ajv.getSchema(schemaUrls[name])?.schema; - } - } - - if (!schema) throw new Error(`Schema ${schemaName} not found.`); - - const valid = ajv.validate(schema, manifest); - - if (!valid) { - const errorMessages = formatErrors(ajv.errors || []); - throw new SchemaValidationError( - `${schemaName} validation failed:\n${errorMessages}`, - ajv.errors || [], - filePath, - ); - } -}; - -export const getSchemaUrls = () => { - return { - trigger: TRIGGER_SCHEMA_URL, - agent: AGENT_SCHEMA_URL, - object: OBJECT_SCHEMA_URL, - serverlessFunction: SERVERLESS_FUNCTION_SCHEMA_URL, - appManifest: APP_MANIFEST_SCHEMA_URL, - }; -}; diff --git a/yarn.lock b/yarn.lock index 4e15e7b36ec..6f735b5b675 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24709,6 +24709,15 @@ __metadata: languageName: node linkType: hard +"@types/lodash.startcase@npm:^4": + version: 4.4.9 + resolution: "@types/lodash.startcase@npm:4.4.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10c0/9d31900b2a7096f307f30b765fcd50fe83c5270c9f730c8f5006c4560628f979fefb7a5d04bced817863707bc0587f2d8b743a9318707983ebc42d944e0030cb + languageName: node + linkType: hard + "@types/lodash.uniq@npm:^4.5.9": version: 4.5.9 resolution: "@types/lodash.uniq@npm:4.5.9" @@ -42880,6 +42889,13 @@ __metadata: languageName: node linkType: hard +"lodash.startcase@npm:^4.4.0": + version: 4.4.0 + resolution: "lodash.startcase@npm:4.4.0" + checksum: 10c0/bd82aa87a45de8080e1c5ee61128c7aee77bf7f1d86f4ff94f4a6d7438fc9e15e5f03374b947be577a93804c8ad6241f0251beaf1452bf716064eeb657b3a9f0 + languageName: node + linkType: hard + "lodash.topath@npm:^4.5.2": version: 4.5.2 resolution: "lodash.topath@npm:4.5.2" @@ -56062,6 +56078,7 @@ __metadata: "@types/lodash.camelcase": "npm:^4.3.7" "@types/lodash.capitalize": "npm:^4" "@types/lodash.kebabcase": "npm:^4.1.7" + "@types/lodash.startcase": "npm:^4" "@types/node": "npm:^20.0.0" ajv: "npm:^8.12.0" ajv-formats: "npm:^2.1.1" @@ -56078,6 +56095,7 @@ __metadata: lodash.camelcase: "npm:^4.3.0" lodash.capitalize: "npm:^4.2.1" lodash.kebabcase: "npm:^4.1.1" + lodash.startcase: "npm:^4.4.0" tsx: "npm:^4.7.0" typescript: "npm:^5.9.2" uuid: "npm:^13.0.0"