diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index 9f037b89730..a8fc3ce7a9f 100644 --- a/packages/create-twenty-app/package.json +++ b/packages/create-twenty-app/package.json @@ -1,6 +1,6 @@ { "name": "create-twenty-app", - "version": "0.2.4", + "version": "0.3.0-alpha", "description": "Command-line interface to create Twenty application", "main": "dist/cli.cjs", "bin": "dist/cli.cjs", diff --git a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts new file mode 100644 index 00000000000..4fb999f9159 --- /dev/null +++ b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts @@ -0,0 +1,310 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { copyBaseApplicationProject } from '@/utils/app-template'; + +// Mock fs-extra's copy function to skip copying base template (not available during tests) +jest.mock('fs-extra', () => { + const actual = jest.requireActual('fs-extra'); + return { + ...actual, + copy: jest.fn().mockResolvedValue(undefined), + }; +}); + +describe('copyBaseApplicationProject', () => { + let testAppDirectory: string; + + beforeEach(async () => { + // Create a unique temp directory for each test + testAppDirectory = join( + tmpdir(), + `test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await fs.ensureDir(testAppDirectory); + jest.clearAllMocks(); + }); + + afterEach(async () => { + // Clean up temp directory after each test + if (testAppDirectory && (await fs.pathExists(testAppDirectory))) { + await fs.remove(testAppDirectory); + } + }); + + it('should create the correct folder structure with src/app/', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + // Verify src/app/ folder exists + const srcAppPath = join(testAppDirectory, 'src', 'app'); + expect(await fs.pathExists(srcAppPath)).toBe(true); + + // Verify application.config.ts exists in src/app/ + const appConfigPath = join(srcAppPath, 'application.config.ts'); + expect(await fs.pathExists(appConfigPath)).toBe(true); + + // Verify default-function.role.ts exists in src/app/ + const roleConfigPath = join(srcAppPath, 'default-function.role.ts'); + expect(await fs.pathExists(roleConfigPath)).toBe(true); + }); + + it('should create package.json with correct content', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + const packageJsonPath = join(testAppDirectory, 'package.json'); + expect(await fs.pathExists(packageJsonPath)).toBe(true); + + const packageJson = await fs.readJson(packageJsonPath); + expect(packageJson.name).toBe('my-test-app'); + expect(packageJson.version).toBe('0.1.0'); + expect(packageJson.dependencies['twenty-sdk']).toBe('0.3.0-alpha'); + expect(packageJson.scripts.sync).toBe('twenty app sync'); + expect(packageJson.scripts.dev).toBe('twenty app dev'); + }); + + it('should create .gitignore file', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + const gitignorePath = join(testAppDirectory, '.gitignore'); + expect(await fs.pathExists(gitignorePath)).toBe(true); + + const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); + expect(gitignoreContent).toContain('/node_modules'); + expect(gitignoreContent).toContain('generated'); + }); + + it('should create yarn.lock file', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + const yarnLockPath = join(testAppDirectory, 'yarn.lock'); + expect(await fs.pathExists(yarnLockPath)).toBe(true); + + const yarnLockContent = await fs.readFile(yarnLockPath, 'utf8'); + expect(yarnLockContent).toContain('yarn lockfile v1'); + }); + + it('should create application.config.ts with defineApp and correct values', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + const appConfigPath = join( + testAppDirectory, + 'src', + 'app', + 'application.config.ts', + ); + const appConfigContent = await fs.readFile(appConfigPath, 'utf8'); + + // Verify it uses defineApp + expect(appConfigContent).toContain( + "import { defineApp } from 'twenty-sdk'", + ); + expect(appConfigContent).toContain('export default defineApp({'); + + // Verify it imports the role identifier + expect(appConfigContent).toContain( + "import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role'", + ); + + // Verify display name and description + expect(appConfigContent).toContain("displayName: 'My Test App'"); + expect(appConfigContent).toContain("description: 'A test application'"); + + // Verify it has a universalIdentifier (UUID format) + expect(appConfigContent).toMatch( + /universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, + ); + + // Verify it references the role + expect(appConfigContent).toContain( + 'functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER', + ); + }); + + it('should create default-function.role.ts with defineRole and correct values', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + const roleConfigPath = join( + testAppDirectory, + 'src', + 'app', + 'default-function.role.ts', + ); + const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8'); + + // Verify it uses defineRole + expect(roleConfigContent).toContain( + "import { defineRole } from 'twenty-sdk'", + ); + expect(roleConfigContent).toContain('export default defineRole({'); + + // Verify it exports the universal identifier constant + expect(roleConfigContent).toContain( + 'export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER', + ); + + // Verify role label includes app name + expect(roleConfigContent).toContain( + "label: 'My Test App default function role'", + ); + + // Verify default permissions + expect(roleConfigContent).toContain('canReadAllObjectRecords: true'); + expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true'); + expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true'); + expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false'); + + // Verify it has a universalIdentifier (UUID format) + expect(roleConfigContent).toMatch( + /universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER/, + ); + }); + + it('should call fs.copy to copy base application template', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + }); + + // Verify fs.copy was called with correct destination + expect(fs.copy).toHaveBeenCalledTimes(1); + expect(fs.copy).toHaveBeenCalledWith( + expect.stringContaining('base-application'), + testAppDirectory, + ); + }); + + it('should handle empty description', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: '', + appDirectory: testAppDirectory, + }); + + const appConfigPath = join( + testAppDirectory, + 'src', + 'app', + 'application.config.ts', + ); + const appConfigContent = await fs.readFile(appConfigPath, 'utf8'); + + expect(appConfigContent).toContain("description: ''"); + }); + + it('should generate unique UUIDs for each application', async () => { + // Create first app + const firstAppDir = join(testAppDirectory, 'app1'); + await fs.ensureDir(firstAppDir); + await copyBaseApplicationProject({ + appName: 'app-one', + appDisplayName: 'App One', + appDescription: 'First app', + appDirectory: firstAppDir, + }); + + // Create second app + const secondAppDir = join(testAppDirectory, 'app2'); + await fs.ensureDir(secondAppDir); + await copyBaseApplicationProject({ + appName: 'app-two', + appDisplayName: 'App Two', + appDescription: 'Second app', + appDirectory: secondAppDir, + }); + + // Read both app configs + const firstAppConfig = await fs.readFile( + join(firstAppDir, 'src', 'app', 'application.config.ts'), + 'utf8', + ); + const secondAppConfig = await fs.readFile( + join(secondAppDir, 'src', 'app', 'application.config.ts'), + 'utf8', + ); + + // Extract UUIDs using regex + const uuidRegex = + /universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/; + const firstUuid = firstAppConfig.match(uuidRegex)?.[1]; + const secondUuid = secondAppConfig.match(uuidRegex)?.[1]; + + expect(firstUuid).toBeDefined(); + expect(secondUuid).toBeDefined(); + expect(firstUuid).not.toBe(secondUuid); + }); + + it('should generate unique role UUIDs for each application', async () => { + // Create first app + const firstAppDir = join(testAppDirectory, 'app1'); + await fs.ensureDir(firstAppDir); + await copyBaseApplicationProject({ + appName: 'app-one', + appDisplayName: 'App One', + appDescription: 'First app', + appDirectory: firstAppDir, + }); + + // Create second app + const secondAppDir = join(testAppDirectory, 'app2'); + await fs.ensureDir(secondAppDir); + await copyBaseApplicationProject({ + appName: 'app-two', + appDisplayName: 'App Two', + appDescription: 'Second app', + appDirectory: secondAppDir, + }); + + // Read both role configs + const firstRoleConfig = await fs.readFile( + join(firstAppDir, 'src', 'app', 'default-function.role.ts'), + 'utf8', + ); + const secondRoleConfig = await fs.readFile( + join(secondAppDir, 'src', 'app', 'default-function.role.ts'), + 'utf8', + ); + + // Extract UUIDs using regex + const uuidRegex = + /DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/; + const firstUuid = firstRoleConfig.match(uuidRegex)?.[1]; + const secondUuid = secondRoleConfig.match(uuidRegex)?.[1]; + + expect(firstUuid).toBeDefined(); + expect(secondUuid).toBeDefined(); + expect(firstUuid).not.toBe(secondUuid); + }); +}); diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index 975302f46f5..b846bca0bbe 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -2,7 +2,7 @@ import * as fs from 'fs-extra'; import { join } from 'path'; import { v4 } from 'uuid'; -const SOURCE_FOLDER = 'src'; +const APP_FOLDER = 'src/app'; export const copyBaseApplicationProject = async ({ appName, @@ -23,23 +23,19 @@ export const copyBaseApplicationProject = async ({ await createYarnLock(appDirectory); - const sourceFolderPath = join(appDirectory, SOURCE_FOLDER); + const appFolderPath = join(appDirectory, APP_FOLDER); - await fs.ensureDir(sourceFolderPath); - - const defaultServerlessFunctionRoleUniversalIdentifier = v4(); + await fs.ensureDir(appFolderPath); await createDefaultServerlessFunctionRoleConfig({ displayName: appDisplayName, - appDirectory: sourceFolderPath, - defaultServerlessFunctionRoleUniversalIdentifier, + appDirectory: appFolderPath, }); await createApplicationConfig({ displayName: appDisplayName, description: appDescription, - appDirectory: sourceFolderPath, - defaultServerlessFunctionRoleUniversalIdentifier, + appDirectory: appFolderPath, }); }; @@ -94,49 +90,49 @@ yarn-error.log* const createDefaultServerlessFunctionRoleConfig = async ({ displayName, appDirectory, - defaultServerlessFunctionRoleUniversalIdentifier, }: { displayName: string; appDirectory: string; - defaultServerlessFunctionRoleUniversalIdentifier: string; }) => { - const content = `import { type RoleConfig } from 'twenty-sdk'; + const universalIdentifier = v4(); -export const functionRole: RoleConfig = { - universalIdentifier: '${defaultServerlessFunctionRoleUniversalIdentifier}', + const content = `import { defineRole } from 'twenty-sdk'; + +export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER = + '${universalIdentifier}'; + +export default defineRole({ + universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER, label: '${displayName} default function role', description: '${displayName} default function role', canReadAllObjectRecords: true, canUpdateAllObjectRecords: true, canSoftDeleteAllObjectRecords: true, canDestroyAllObjectRecords: false, -}; +}); `; - await fs.writeFile(join(appDirectory, 'role.config.ts'), content); + await fs.writeFile(join(appDirectory, 'default-function.role.ts'), content); }; const createApplicationConfig = async ({ displayName, description, appDirectory, - defaultServerlessFunctionRoleUniversalIdentifier, }: { displayName: string; description?: string; appDirectory: string; - defaultServerlessFunctionRoleUniversalIdentifier: string; }) => { - const content = `import { type ApplicationConfig } from 'twenty-sdk'; + const content = `import { defineApp } from 'twenty-sdk'; +import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role'; -const config: ApplicationConfig = { +export default defineApp({ universalIdentifier: '${v4()}', displayName: '${displayName}', description: '${description ?? ''}', - functionRoleUniversalIdentifier: '${defaultServerlessFunctionRoleUniversalIdentifier}', -}; - -export default config; + functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER, +}); `; await fs.writeFile(join(appDirectory, 'application.config.ts'), content); @@ -172,7 +168,7 @@ const createPackageJson = async ({ 'lint-fix': 'eslint --fix', }, dependencies: { - 'twenty-sdk': '0.2.4', + 'twenty-sdk': '0.3.0-alpha', }, devDependencies: { typescript: '^5.9.3', diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 2ebb4ae222f..8b606359340 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-sdk", - "version": "0.2.4", + "version": "0.3.0-alpha", "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", @@ -36,10 +36,12 @@ "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.4.0", + "fast-glob": "^3.3.0", "fs-extra": "^11.2.0", "graphql": "^16.8.1", "graphql-sse": "^2.5.4", "inquirer": "^10.0.0", + "jiti": "^2.0.0", "jsonc-parser": "^3.2.0", "lodash.camelcase": "^4.3.0", "lodash.capitalize": "^4.2.1", diff --git a/packages/twenty-sdk/src/application/__tests__/define-app.spec.ts b/packages/twenty-sdk/src/application/__tests__/define-app.spec.ts new file mode 100644 index 00000000000..fd2fc2e9733 --- /dev/null +++ b/packages/twenty-sdk/src/application/__tests__/define-app.spec.ts @@ -0,0 +1,62 @@ +import { defineApp } from '../define-app'; + +describe('defineApp', () => { + it('should return the config when valid', () => { + const config = { + universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe', + displayName: 'My App', + description: 'My app description', + icon: 'IconWorld', + }; + + const result = defineApp(config); + + expect(result).toEqual(config); + }); + + it('should pass through all optional fields', () => { + const config = { + universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe', + displayName: 'My App', + description: 'My app description', + icon: 'IconWorld', + applicationVariables: { + API_KEY: { + universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6', + description: 'API Key', + isSecret: true, + }, + }, + functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2', + }; + + const result = defineApp(config); + + expect(result).toEqual(config); + expect(result.applicationVariables).toBeDefined(); + expect(result.functionRoleUniversalIdentifier).toBe( + '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2', + ); + }); + + it('should throw error when universalIdentifier is missing', () => { + const config = { + displayName: 'My App', + }; + + expect(() => defineApp(config as any)).toThrow( + 'App must have a universalIdentifier', + ); + }); + + it('should throw error when universalIdentifier is empty string', () => { + const config = { + universalIdentifier: '', + displayName: 'My App', + }; + + expect(() => defineApp(config as any)).toThrow( + 'App must have a universalIdentifier', + ); + }); +}); diff --git a/packages/twenty-sdk/src/application/__tests__/define-function.spec.ts b/packages/twenty-sdk/src/application/__tests__/define-function.spec.ts new file mode 100644 index 00000000000..c9e2bf3322e --- /dev/null +++ b/packages/twenty-sdk/src/application/__tests__/define-function.spec.ts @@ -0,0 +1,299 @@ +import { defineFunction } from '../functions/define-function'; + +// Mock handler for testing +const mockHandler = async () => ({ success: true }); + +describe('defineFunction', () => { + const validRouteConfig = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route' as const, + path: '/postcards/send', + httpMethod: 'POST' as const, + isAuthRequired: true, + }, + ], + }; + + it('should return the config when valid with route trigger', () => { + const result = defineFunction(validRouteConfig); + + expect(result).toEqual(validRouteConfig); + }); + + it('should accept cron trigger', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Daily Report', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', + type: 'cron' as const, + pattern: '0 9 * * *', + }, + ], + }; + + const result = defineFunction(config); + + expect(result.triggers[0].type).toBe('cron'); + }); + + it('should accept databaseEvent trigger', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'On Contact Created', + handler: mockHandler, + triggers: [ + { + universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', + type: 'databaseEvent' as const, + eventName: 'contact.created', + }, + ], + }; + + const result = defineFunction(config); + + expect(result.triggers[0].type).toBe('databaseEvent'); + }); + + it('should accept multiple triggers', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Multi-trigger Function', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route' as const, + path: '/sync', + httpMethod: 'POST' as const, + isAuthRequired: true, + }, + { + universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', + type: 'cron' as const, + pattern: '0 * * * *', + }, + ], + }; + + const result = defineFunction(config); + + expect(result.triggers).toHaveLength(2); + }); + + it('should pass through optional fields', () => { + const config = { + ...validRouteConfig, + description: 'Send a postcard to a contact', + timeoutSeconds: 30, + }; + + const result = defineFunction(config); + + expect(result.description).toBe('Send a postcard to a contact'); + expect(result.timeoutSeconds).toBe(30); + }); + + it('should throw error when universalIdentifier is missing', () => { + const config = { + name: 'Send Postcard', + handler: mockHandler, + triggers: validRouteConfig.triggers, + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Function must have a universalIdentifier', + ); + }); + + it('should throw error when handler is missing', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + triggers: validRouteConfig.triggers, + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Function must have a handler', + ); + }); + + it('should throw error when handler is not a function', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: 'not-a-function', + triggers: validRouteConfig.triggers, + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Function must have a handler', + ); + }); + + it('should throw error when triggers is empty', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + triggers: [], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Function must have at least one trigger', + ); + }); + + it('should throw error when triggers is missing', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Function must have at least one trigger', + ); + }); + + it('should throw error when trigger is missing universalIdentifier', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + triggers: [ + { + type: 'route' as const, + path: '/postcards/send', + httpMethod: 'POST' as const, + isAuthRequired: true, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Each trigger must have a universalIdentifier', + ); + }); + + it('should throw error when trigger is missing type', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + path: '/postcards/send', + httpMethod: 'POST' as const, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Each trigger must have a type', + ); + }); + + it('should throw error when route trigger is missing path', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route' as const, + httpMethod: 'POST' as const, + isAuthRequired: true, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Route trigger must have a path', + ); + }); + + it('should throw error when route trigger is missing httpMethod', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Send Postcard', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route' as const, + path: '/postcards/send', + isAuthRequired: true, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Route trigger must have an httpMethod', + ); + }); + + it('should throw error when cron trigger is missing pattern', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Daily Report', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', + type: 'cron' as const, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Cron trigger must have a pattern', + ); + }); + + it('should throw error when databaseEvent trigger is missing eventName', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'On Contact Created', + handler: mockHandler, + triggers: [ + { + universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', + type: 'databaseEvent' as const, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Database event trigger must have an eventName', + ); + }); + + it('should throw error for unknown trigger type', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Unknown Trigger', + handler: mockHandler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'unknown' as any, + }, + ], + }; + + expect(() => defineFunction(config as any)).toThrow( + 'Unknown trigger type: unknown', + ); + }); +}); diff --git a/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts b/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts new file mode 100644 index 00000000000..4ae386e745f --- /dev/null +++ b/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts @@ -0,0 +1,238 @@ +import { defineObject } from '../objects/define-object'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { type ObjectManifest } from 'twenty-shared/application'; + +describe('defineObject', () => { + const validConfig: ObjectManifest = { + universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', + nameSingular: 'postCard', + namePlural: 'postCards', + labelSingular: 'Post Card', + labelPlural: 'Post Cards', + icon: 'IconMail', + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldMetadataType.TEXT, + name: 'content', + label: 'Content', + }, + ], + }; + + it('should return the config when valid', () => { + const result = defineObject(validConfig); + + expect(result).toEqual(validConfig); + }); + + it('should pass through all optional fields', () => { + const config: ObjectManifest = { + ...validConfig, + description: 'A post card object', + }; + + const result = defineObject(config); + + expect(result.description).toBe('A post card object'); + }); + + it('should throw error when universalIdentifier is missing', () => { + const config = { + ...validConfig, + universalIdentifier: undefined, + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have a universalIdentifier', + ); + }); + + it('should throw error when nameSingular is missing', () => { + const config = { + ...validConfig, + nameSingular: undefined, + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have a nameSingular', + ); + }); + + it('should throw error when namePlural is missing', () => { + const config = { + ...validConfig, + namePlural: undefined, + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have a namePlural', + ); + }); + + it('should throw error when labelSingular is missing', () => { + const config = { + ...validConfig, + labelSingular: undefined, + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have a labelSingular', + ); + }); + + it('should throw error when labelPlural is missing', () => { + const config = { + ...validConfig, + labelPlural: undefined, + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have a labelPlural', + ); + }); + + it('should throw error when fields is empty', () => { + const config = { + ...validConfig, + fields: [], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have at least one field', + ); + }); + + it('should throw error when fields is missing', () => { + const config = { + ...validConfig, + fields: undefined, + }; + + expect(() => defineObject(config as any)).toThrow( + 'Object must have at least one field', + ); + }); + + it('should throw error when field is missing universalIdentifier', () => { + const config = { + ...validConfig, + fields: [ + { + type: FieldMetadataType.TEXT, + label: 'Content', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Field "Content" must have a universalIdentifier', + ); + }); + + it('should throw error when field is missing type', () => { + const config = { + ...validConfig, + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + label: 'Content', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Field "Content" must have a type', + ); + }); + + it('should throw error when field is missing label', () => { + const config = { + ...validConfig, + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldMetadataType.TEXT, + name: 'content', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Field must have a label', + ); + }); + + it('should throw error when field is missing name', () => { + const config = { + ...validConfig, + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldMetadataType.TEXT, + label: 'Content', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow('Field must have a name'); + }); + + it('should throw error when SELECT field has no options', () => { + const config = { + ...validConfig, + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldMetadataType.SELECT, + label: 'Status', + name: 'status', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Field "Status" is a SELECT/MULTI_SELECT type and must have options', + ); + }); + + it('should throw error when MULTI_SELECT field has no options', () => { + const config = { + ...validConfig, + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldMetadataType.MULTI_SELECT, + label: 'Tags', + name: 'tag', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Field "Tags" is a SELECT/MULTI_SELECT type and must have options', + ); + }); + + it('should accept SELECT field with options', () => { + const config = { + ...validConfig, + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldMetadataType.SELECT, + name: 'status', + label: 'Status', + options: [ + { value: 'draft', label: 'Draft', color: 'gray', position: 0 }, + { value: 'sent', label: 'Sent', color: 'green', position: 1 }, + ], + }, + ], + }; + + const result = defineObject(config as any); + + expect(result.fields[0].options).toHaveLength(2); + }); +}); diff --git a/packages/twenty-sdk/src/application/__tests__/define-role.spec.ts b/packages/twenty-sdk/src/application/__tests__/define-role.spec.ts new file mode 100644 index 00000000000..e9f27f03b22 --- /dev/null +++ b/packages/twenty-sdk/src/application/__tests__/define-role.spec.ts @@ -0,0 +1,181 @@ +import { defineRole } from '../roles/define-role'; + +describe('defineRole', () => { + const validConfig = { + universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', + label: 'App User', + description: 'Standard user role', + }; + + it('should return the config when valid', () => { + const result = defineRole(validConfig); + + expect(result).toEqual(validConfig); + }); + + it('should pass through all optional fields', () => { + const config = { + ...validConfig, + icon: 'IconUser', + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + }; + + const result = defineRole(config); + + expect(result.icon).toBe('IconUser'); + expect(result.canReadAllObjectRecords).toBe(true); + }); + + it('should accept objectPermissions with objectNameSingular', () => { + const config = { + ...validConfig, + objectPermissions: [ + { + objectNameSingular: 'postCard', + canReadObjectRecords: true, + canUpdateObjectRecords: true, + }, + ], + }; + + const result = defineRole(config); + + expect(result.objectPermissions).toHaveLength(1); + expect(result.objectPermissions![0].objectNameSingular).toBe('postCard'); + }); + + it('should accept objectPermissions with objectUniversalIdentifier', () => { + const config = { + ...validConfig, + objectPermissions: [ + { + objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', + canReadObjectRecords: true, + }, + ], + }; + + const result = defineRole(config); + + expect(result.objectPermissions![0].objectUniversalIdentifier).toBe( + '54b589ca-eeed-4950-a176-358418b85c05', + ); + }); + + it('should accept fieldPermissions with fieldName', () => { + const config = { + ...validConfig, + fieldPermissions: [ + { + objectNameSingular: 'postCard', + fieldName: 'content', + canReadFieldValue: true, + canUpdateFieldValue: false, + }, + ], + }; + + const result = defineRole(config); + + expect(result.fieldPermissions).toHaveLength(1); + expect(result.fieldPermissions![0].fieldName).toBe('content'); + }); + + it('should accept fieldPermissions with fieldUniversalIdentifier', () => { + const config = { + ...validConfig, + fieldPermissions: [ + { + objectNameSingular: 'postCard', + fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + canReadFieldValue: true, + }, + ], + }; + + const result = defineRole(config); + + expect(result.fieldPermissions![0].fieldUniversalIdentifier).toBe( + '58a0a314-d7ea-4865-9850-7fb84e72f30b', + ); + }); + + it('should accept permissionFlags', () => { + const config = { + ...validConfig, + permissionFlags: ['UPLOAD_FILE', 'DOWNLOAD_FILE'], + }; + + const result = defineRole(config as any); + + expect(result.permissionFlags).toHaveLength(2); + }); + + it('should throw error when universalIdentifier is missing', () => { + const config = { + label: 'App User', + }; + + expect(() => defineRole(config as any)).toThrow( + 'Role must have a universalIdentifier', + ); + }); + + it('should throw error when label is missing', () => { + const config = { + universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', + }; + + expect(() => defineRole(config as any)).toThrow('Role must have a label'); + }); + + it('should throw error when objectPermission has neither objectNameSingular nor objectUniversalIdentifier', () => { + const config = { + ...validConfig, + objectPermissions: [ + { + canReadObjectRecords: true, + }, + ], + }; + + expect(() => defineRole(config as any)).toThrow( + 'Object permission must have either objectNameSingular or objectUniversalIdentifier', + ); + }); + + it('should throw error when fieldPermission has neither objectNameSingular nor objectUniversalIdentifier', () => { + const config = { + ...validConfig, + fieldPermissions: [ + { + fieldName: 'content', + canReadFieldValue: true, + }, + ], + }; + + expect(() => defineRole(config as any)).toThrow( + 'Field permission must have either objectNameSingular or objectUniversalIdentifier', + ); + }); + + it('should throw error when fieldPermission has neither fieldName nor fieldUniversalIdentifier', () => { + const config = { + ...validConfig, + fieldPermissions: [ + { + objectNameSingular: 'postCard', + canReadFieldValue: true, + }, + ], + }; + + expect(() => defineRole(config as any)).toThrow( + 'Field permission must have either fieldName or fieldUniversalIdentifier', + ); + }); +}); diff --git a/packages/twenty-sdk/src/application/define-app.ts b/packages/twenty-sdk/src/application/define-app.ts new file mode 100644 index 00000000000..6e35b4e9747 --- /dev/null +++ b/packages/twenty-sdk/src/application/define-app.ts @@ -0,0 +1,25 @@ +import { type Application } from 'twenty-shared/application'; + +/** + * Define an application configuration with validation. + * + * @example + * ```typescript + * import { defineApp } from 'twenty-sdk'; + * import { APP_ID } from '../src/constants'; + * + * export default defineApp({ + * universalIdentifier: APP_ID, + * displayName: 'My App', + * description: 'My app description', + * icon: 'IconWorld', + * }); + * ``` + */ +export const defineApp = (config: T): T => { + if (!config.universalIdentifier) { + throw new Error('App must have a universalIdentifier'); + } + + return config; +}; diff --git a/packages/twenty-sdk/src/application/functions/define-function.ts b/packages/twenty-sdk/src/application/functions/define-function.ts new file mode 100644 index 00000000000..e0d65c06887 --- /dev/null +++ b/packages/twenty-sdk/src/application/functions/define-function.ts @@ -0,0 +1,80 @@ +import { type FunctionConfig } from './function-config'; + +/** + * Define a serverless function configuration with validation. + * + * @example + * ```typescript + * import { defineFunction } from 'twenty-sdk'; + * import { sendPostcard } from '../src/handlers/send-postcard'; + * + * export const config = defineFunction({ + * universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + * name: 'Send Postcard', + * description: 'Send a postcard to a contact', + * timeoutSeconds: 30, + * handler: sendPostcard, + * triggers: [ + * { + * universalIdentifier: 'c9f84c8d-...', + * type: 'route', + * path: '/postcards/send', + * httpMethod: 'POST', + * isAuthRequired: true, + * }, + * ], + * }); + * ``` + */ +export const defineFunction = (config: T): T => { + if (!config.universalIdentifier) { + throw new Error('Function must have a universalIdentifier'); + } + + if (typeof config.handler !== 'function') { + throw new Error('Function must have a handler'); + } + + if (!config.triggers || config.triggers.length === 0) { + throw new Error('Function must have at least one trigger'); + } + + // Validate each trigger + for (const trigger of config.triggers) { + if (!trigger.universalIdentifier) { + throw new Error('Each trigger must have a universalIdentifier'); + } + + if (!trigger.type) { + throw new Error('Each trigger must have a type'); + } + + switch (trigger.type) { + case 'route': + if (!trigger.path) { + throw new Error('Route trigger must have a path'); + } + if (!trigger.httpMethod) { + throw new Error('Route trigger must have an httpMethod'); + } + break; + + case 'cron': + if (!trigger.pattern) { + throw new Error('Cron trigger must have a pattern'); + } + break; + + case 'databaseEvent': + if (!trigger.eventName) { + throw new Error('Database event trigger must have an eventName'); + } + break; + + default: + throw new Error(`Unknown trigger type: ${(trigger as { type: string }).type}`); + } + } + + return config; +}; diff --git a/packages/twenty-sdk/src/application/functions/function-config.ts b/packages/twenty-sdk/src/application/functions/function-config.ts index d28cd300292..055c23b9159 100644 --- a/packages/twenty-sdk/src/application/functions/function-config.ts +++ b/packages/twenty-sdk/src/application/functions/function-config.ts @@ -3,6 +3,8 @@ import { type ServerlessFunctionTriggerManifest, } from 'twenty-shared/application'; +export type FunctionHandler = (...args: any[]) => any | Promise; + export type FunctionConfig = Omit< ServerlessFunctionManifest, 'handlerPath' | 'handlerName' @@ -10,5 +12,6 @@ export type FunctionConfig = Omit< name?: string; description?: string; timeoutSeconds?: number; + handler: FunctionHandler; triggers?: ServerlessFunctionTriggerManifest[]; }; diff --git a/packages/twenty-sdk/src/application/functions/triggers/cron-payload-type.ts b/packages/twenty-sdk/src/application/functions/triggers/cron-payload-type.ts index 57053fcef24..89ef085ac50 100644 --- a/packages/twenty-sdk/src/application/functions/triggers/cron-payload-type.ts +++ b/packages/twenty-sdk/src/application/functions/triggers/cron-payload-type.ts @@ -1 +1 @@ -export type CronPayload = {}; +export type CronPayload = Record; diff --git a/packages/twenty-sdk/src/application/index.ts b/packages/twenty-sdk/src/application/index.ts index c1e3f6d8cd9..474e3763292 100644 --- a/packages/twenty-sdk/src/application/index.ts +++ b/packages/twenty-sdk/src/application/index.ts @@ -8,6 +8,7 @@ */ export type { ApplicationConfig } from './application-config'; +export { defineApp } from './define-app'; export type { ActorField, AddressField, @@ -23,7 +24,11 @@ export { Field } from './fields/field.decorator'; export { OnDeleteAction } from './fields/on-delete-action'; export { RelationType } from './fields/relation-type'; export { Relation } from './fields/relation.decorator'; -export type { FunctionConfig } from './functions/function-config'; +export { defineFunction } from './functions/define-function'; +export type { + FunctionHandler, + FunctionConfig, +} from './functions/function-config'; export type { CronPayload } from './functions/triggers/cron-payload-type'; export type { DatabaseEventPayload, @@ -36,8 +41,10 @@ export type { ObjectRecordRestoreEvent, ObjectRecordUpsertEvent, } from './functions/triggers/database-event-payload-type'; +export { defineObject } from './objects/define-object'; export { Object } from './objects/object.decorator'; export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids'; export { PermissionFlag } from './permission-flag-type'; export type { RoleConfig } from './role-config'; +export { defineRole } from './roles/define-role'; export type { SyncableEntityOptions } from './syncable-entity-options.type'; diff --git a/packages/twenty-sdk/src/application/objects/define-object.ts b/packages/twenty-sdk/src/application/objects/define-object.ts new file mode 100644 index 00000000000..9ee4ed1df58 --- /dev/null +++ b/packages/twenty-sdk/src/application/objects/define-object.ts @@ -0,0 +1,93 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { type ObjectManifest } from 'twenty-shared/application'; + +/** + * Define an object configuration with validation. + * + * @example + * ```typescript + * import { defineObject, FieldType } from 'twenty-sdk'; + * import { POST_CARD_ID, STATUS_OPTIONS } from '../../src/constants'; + * + * export default defineObject({ + * universalIdentifier: POST_CARD_ID, + * nameSingular: 'postCard', + * namePlural: 'postCards', + * labelSingular: 'Post Card', + * labelPlural: 'Post Cards', + * icon: 'IconMail', + * fields: [ + * { + * universalIdentifier: '...', + * name: 'content', + * type: FieldType.TEXT, + * label: 'Content', + * }, + * { + * universalIdentifier: '...', + * name: 'status', + * type: FieldType.SELECT, + * label: 'Status', + * options: STATUS_OPTIONS, + * }, + * ], + * }); + * ``` + */ +export const defineObject = (config: T): T => { + if (!config.universalIdentifier) { + throw new Error('Object must have a universalIdentifier'); + } + + if (!config.nameSingular) { + throw new Error('Object must have a nameSingular'); + } + + if (!config.namePlural) { + throw new Error('Object must have a namePlural'); + } + + if (!config.labelSingular) { + throw new Error('Object must have a labelSingular'); + } + + if (!config.labelPlural) { + throw new Error('Object must have a labelPlural'); + } + + if (!config.fields || config.fields.length === 0) { + throw new Error('Object must have at least one field'); + } + + // Validate each field + for (const field of config.fields) { + if (!field.universalIdentifier) { + throw new Error(`Field "${field.label}" must have a universalIdentifier`); + } + + if (!field.type) { + throw new Error(`Field "${field.label}" must have a type`); + } + + if (!field.name) { + throw new Error('Field must have a name'); + } + + if (!field.label) { + throw new Error('Field must have a label'); + } + + // Validate SELECT fields have options + if ( + (field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT) && + (!field.options || field.options.length === 0) + ) { + throw new Error( + `Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`, + ); + } + } + + return config; +}; diff --git a/packages/twenty-sdk/src/application/roles/define-role.ts b/packages/twenty-sdk/src/application/roles/define-role.ts new file mode 100644 index 00000000000..4b5a01c84a2 --- /dev/null +++ b/packages/twenty-sdk/src/application/roles/define-role.ts @@ -0,0 +1,64 @@ +import { type RoleConfig } from '../role-config'; + +/** + * Define a role configuration with validation. + * + * @example + * ```typescript + * import { defineRole, PermissionFlag } from 'twenty-sdk'; + * + * export default defineRole({ + * universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', + * label: 'App User', + * description: 'Standard user role for the app', + * icon: 'IconUser', + * canReadAllObjectRecords: false, + * objectPermissions: [ + * { + * objectNameSingular: 'postCard', + * canReadObjectRecords: true, + * canUpdateObjectRecords: true, + * }, + * ], + * permissionFlags: [PermissionFlag.UPLOAD_FILE], + * }); + * ``` + */ +export const defineRole = (config: T): T => { + if (!config.universalIdentifier) { + throw new Error('Role must have a universalIdentifier'); + } + + if (!config.label) { + throw new Error('Role must have a label'); + } + + // Validate object permissions if provided + if (config.objectPermissions) { + for (const permission of config.objectPermissions) { + if (!permission.objectNameSingular && !permission.objectUniversalIdentifier) { + throw new Error( + 'Object permission must have either objectNameSingular or objectUniversalIdentifier', + ); + } + } + } + + // Validate field permissions if provided + if (config.fieldPermissions) { + for (const permission of config.fieldPermissions) { + if (!permission.objectNameSingular && !permission.objectUniversalIdentifier) { + throw new Error( + 'Field permission must have either objectNameSingular or objectUniversalIdentifier', + ); + } + if (!permission.fieldName && !permission.fieldUniversalIdentifier) { + throw new Error( + 'Field permission must have either fieldName or fieldUniversalIdentifier', + ); + } + } + } + + return config; +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts b/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts index 5e5d0e01b3f..238e3fdd6c9 100644 --- a/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts @@ -1,48 +1,45 @@ import { existsSync } from 'fs'; import { AppSyncCommand } from '@/cli/commands/app-sync.command'; import { AppUninstallCommand } from '@/cli/commands/app-uninstall.command'; -import { COVERED_APPLICATION_FOLDERS } from '@/cli/__tests__/e2e/constants/covered-applications-folder.constant'; import { getTestedApplicationPath } from '@/cli/__tests__/e2e/utils/get-tested-application-path.util'; -describe.each(COVERED_APPLICATION_FOLDERS)( - 'Application: "%s" install delete and reinstall test suite', - (applicationName) => { - const syncCommand = new AppSyncCommand(); - const deleteCommand = new AppUninstallCommand(); - const appPath = getTestedApplicationPath(applicationName); +describe('Application: install delete and reinstall test-app', () => { + const applicationName = 'test-app'; + const syncCommand = new AppSyncCommand(); + const deleteCommand = new AppUninstallCommand(); + const appPath = getTestedApplicationPath(applicationName); - beforeAll(async () => { - expect(existsSync(appPath)).toBe(true); + beforeAll(async () => { + expect(existsSync(appPath)).toBe(true); + }); + + afterAll(async () => { + const result = await deleteCommand.execute({ + appPath, + askForConfirmation: false, }); - afterAll(async () => { - const result = await deleteCommand.execute({ - appPath, - askForConfirmation: false, - }); + expect(result.success).toBe(true); + }); - expect(result.success).toBe(true); + it(`should successfully install ${applicationName} application`, async () => { + const result = await syncCommand.execute(appPath); + + expect(result.success).toBe(true); + }); + + it(`should successfully delete ${applicationName} application`, async () => { + const result = await deleteCommand.execute({ + appPath, + askForConfirmation: false, }); - it(`should successfully install ${applicationName} application`, async () => { - const result = await syncCommand.execute(appPath); + expect(result.success).toBe(true); + }); - expect(result.success).toBe(true); - }); + it(`should successfully re-install ${applicationName} application`, async () => { + const result = await syncCommand.execute(appPath); - it(`should successfully delete ${applicationName} application`, async () => { - const result = await deleteCommand.execute({ - appPath, - askForConfirmation: false, - }); - - expect(result.success).toBe(true); - }); - - it(`should successfully re-install ${applicationName} application`, async () => { - const result = await syncCommand.execute(appPath); - - expect(result.success).toBe(true); - }); - }, -); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/e2e/constants/covered-applications-folder.constant.ts b/packages/twenty-sdk/src/cli/__tests__/e2e/constants/covered-applications-folder.constant.ts deleted file mode 100644 index 569ce1bd826..00000000000 --- a/packages/twenty-sdk/src/cli/__tests__/e2e/constants/covered-applications-folder.constant.ts +++ /dev/null @@ -1 +0,0 @@ -export const COVERED_APPLICATION_FOLDERS = ['hello-world'] as const; diff --git a/packages/twenty-sdk/src/cli/__tests__/e2e/utils/get-tested-application-path.util.ts b/packages/twenty-sdk/src/cli/__tests__/e2e/utils/get-tested-application-path.util.ts index e08d56e6e80..aa6f43e6436 100644 --- a/packages/twenty-sdk/src/cli/__tests__/e2e/utils/get-tested-application-path.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/e2e/utils/get-tested-application-path.util.ts @@ -1,10 +1,7 @@ import path from 'path'; export const getTestedApplicationPath = (relativePath: string): string => { - const twentyAppsPath = path.resolve( - __dirname, - '../../../../../../twenty-apps', - ); + const twentyAppsPath = path.resolve(__dirname, '../..'); return path.join(twentyAppsPath, relativePath); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/package.json b/packages/twenty-sdk/src/cli/__tests__/test-app/package.json new file mode 100644 index 00000000000..9046a7b84dc --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "test-app", + "version": "0.0.1", + "license": "MIT", + "engines": { + "node": "^24.5.0", + "npm": "please-use-yarn", + "yarn": ">=4.0.2" + }, + "packageManager": "yarn@4.9.2", + "scripts": { + "create-entity": "twenty app add", + "dev": "twenty app dev", + "generate": "twenty app generate", + "sync": "twenty app sync", + "uninstall": "twenty app uninstall", + "auth": "twenty auth login" + }, + "dependencies": { + "twenty-sdk": "latest" + }, + "devDependencies": { + "@types/node": "^24.7.2" + } +} diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/application.config.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/application.config.ts new file mode 100644 index 00000000000..052fc18b968 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/application.config.ts @@ -0,0 +1,18 @@ +import { defineApp } from '@/application/define-app'; +import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role'; + +export default defineApp({ + universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', + displayName: 'Hello World', + description: 'A simple hello world app', + icon: 'IconWorld', + applicationVariables: { + DEFAULT_RECIPIENT_NAME: { + universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de', + description: 'Default recipient name for postcards', + value: 'Alex Karp', + isSecret: false, + }, + }, + functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/default-function.role.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/default-function.role.ts new file mode 100644 index 00000000000..9596245039f --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/default-function.role.ts @@ -0,0 +1,37 @@ +import { PermissionFlag } from '@/application/permission-flag-type'; +import { defineRole } from '@/application/roles/define-role'; + +export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER = + 'b648f87b-1d26-4961-b974-0908fd991061'; + +export default defineRole({ + universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER, + label: 'Default function role', + description: 'Default role for function Twenty client', + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canBeAssignedToAgents: false, + canBeAssignedToUsers: false, + canBeAssignedToApiKeys: false, + objectPermissions: [ + { + objectNameSingular: 'postCard', + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + fieldPermissions: [ + { + objectNameSingular: 'postCard', + fieldName: 'content', + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + permissionFlags: [PermissionFlag.APPLICATIONS], +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/postCard.object.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/postCard.object.ts new file mode 100644 index 00000000000..23b1374a8d6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/postCard.object.ts @@ -0,0 +1,86 @@ +import { defineObject } from '@/application/objects/define-object'; +import { FieldType } from '@/application/fields/field-type'; + +enum PostCardStatus { + DRAFT = 'DRAFT', + SENT = 'SENT', + DELIVERED = 'DELIVERED', + RETURNED = 'RETURNED', +} + +export default defineObject({ + universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', + nameSingular: 'postCard', + namePlural: 'postCards', + labelSingular: 'Post card', + labelPlural: 'Post cards', + description: 'A post card object', + icon: 'IconMail', + fields: [ + { + universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + type: FieldType.TEXT, + label: 'Content', + description: "Postcard's content", + icon: 'IconAbc', + name: 'content', + }, + { + universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac', + type: FieldType.FULL_NAME, + label: 'Recipient name', + icon: 'IconUser', + name: 'recipientName', + }, + { + universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266', + type: FieldType.ADDRESS, + label: 'Recipient address', + icon: 'IconHome', + name: 'recipientAddress', + }, + { + universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e', + type: FieldType.SELECT, + label: 'Status', + icon: 'IconSend', + defaultValue: `'${PostCardStatus.DRAFT}'`, + options: [ + { + value: PostCardStatus.DRAFT, + label: 'Draft', + position: 0, + color: 'gray', + }, + { + value: PostCardStatus.SENT, + label: 'Sent', + position: 1, + color: 'orange', + }, + { + value: PostCardStatus.DELIVERED, + label: 'Delivered', + position: 2, + color: 'green', + }, + { + value: PostCardStatus.RETURNED, + label: 'Returned', + position: 3, + color: 'orange', + }, + ], + name: 'status', + }, + { + universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433', + type: FieldType.DATE_TIME, + label: 'Delivered at', + icon: 'IconCheck', + isNullable: true, + defaultValue: null, + name: 'deliveredAt', + }, + ], +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/test-function-2.function.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/test-function-2.function.ts new file mode 100644 index 00000000000..5304ee372ac --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/test-function-2.function.ts @@ -0,0 +1,16 @@ +import { defineFunction } from '@/application/functions/define-function'; +import { testFunction2 } from '../utils/test-function-2.util'; + +export const config = defineFunction({ + universalIdentifier: 'eb3ffc98-88ec-45d4-9b4a-56833b219ccb', + name: 'test-function-2', + timeoutSeconds: 2, + handler: testFunction2, + triggers: [ + { + universalIdentifier: '9fd0dda9-4664-4fbc-9656-509f4477b9ff', + type: 'cron', + pattern: '0 0 1 1 *', // Every year 1st of January + }, + ], +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/test-function.function.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/test-function.function.ts new file mode 100644 index 00000000000..585b635b28e --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/test-function.function.ts @@ -0,0 +1,31 @@ +import { defineFunction } from '@/application/functions/define-function'; + +const handler = () => { + return 'test-result'; +}; + +export default defineFunction({ + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'test-function', + timeoutSeconds: 2, + handler, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route', + path: '/post-card/create', + httpMethod: 'GET', + isAuthRequired: false, + }, + { + universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', + type: 'cron', + pattern: '0 0 1 1 *', // Every year 1st of January + }, + { + universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', + type: 'databaseEvent', + eventName: 'person.created', + }, + ], +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/utils/test-function-2.util.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/utils/test-function-2.util.ts new file mode 100644 index 00000000000..3d30602cb86 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/utils/test-function-2.util.ts @@ -0,0 +1 @@ +export const testFunction2 = () => 'testFunction2'; diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/yarn.lock b/packages/twenty-sdk/src/cli/__tests__/test-app/yarn.lock new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/twenty-sdk/src/cli/commands/app-add.command.ts b/packages/twenty-sdk/src/cli/commands/app-add.command.ts index 67c4628683b..3d5ef62b1a1 100644 --- a/packages/twenty-sdk/src/cli/commands/app-add.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-add.command.ts @@ -3,15 +3,20 @@ import * as fs from 'fs-extra'; import inquirer from 'inquirer'; import { join } from 'path'; import camelcase from 'lodash.camelcase'; +import kebabcase from 'lodash.kebabcase'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/constants/current-execution-directory'; -import { getObjectDecoratedClass } from '@/cli/utils/get-object-decorated-class'; +import { getNewObjectFileContent } from '@/cli/utils/get-new-object-file-content'; import { getFunctionBaseFile } from '@/cli/utils/get-function-base-file'; +import { getRoleBaseFile } from '@/cli/utils/get-role-base-file'; import { convertToLabel } from '@/cli/utils/convert-to-label'; +const APP_FOLDER = 'src/app'; + export enum SyncableEntity { AGENT = 'agent', OBJECT = 'object', FUNCTION = 'function', + ROLE = 'role', } export const isSyncableEntity = (value: string): value is SyncableEntity => { @@ -21,7 +26,10 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => { export class AppAddCommand { async execute(entityType?: SyncableEntity, path?: string): Promise { try { - const appPath = join(CURRENT_EXECUTION_DIRECTORY, path ?? ''); + // Default to src/app/ folder, allow override with path parameter + const appPath = path + ? join(CURRENT_EXECUTION_DIRECTORY, path) + : join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER); await fs.ensureDir(appPath); @@ -32,14 +40,22 @@ export class AppAddCommand { const name = entityData.nameSingular; - const objectFileName = `${camelcase(name)}.ts`; + // Use *.object.ts naming convention + const objectFileName = `${camelcase(name)}.object.ts`; - const decoratedObject = getObjectDecoratedClass({ + const decoratedObject = getNewObjectFileContent({ data: entityData, name, }); - await fs.writeFile(join(appPath, objectFileName), decoratedObject); + const filePath = join(appPath, objectFileName); + + await fs.writeFile(filePath, decoratedObject); + + console.log( + chalk.green(`✓ Created object:`), + chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')), + ); return; } @@ -47,15 +63,42 @@ export class AppAddCommand { if (entity === SyncableEntity.FUNCTION) { const entityName = await this.getEntityName(entity); - const objectFileName = `${camelcase(entityName)}.ts`; + // Use *.function.ts naming convention + const functionFileName = `${kebabcase(entityName)}.function.ts`; const decoratedServerlessFunction = getFunctionBaseFile({ name: entityName, }); - await fs.writeFile( - join(appPath, objectFileName), - decoratedServerlessFunction, + const filePath = join(appPath, functionFileName); + + await fs.writeFile(filePath, decoratedServerlessFunction); + + console.log( + chalk.green(`✓ Created function:`), + chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')), + ); + + return; + } + + if (entity === SyncableEntity.ROLE) { + const entityName = await this.getEntityName(entity); + + // Use *.role.ts naming convention + const roleFileName = `${kebabcase(entityName)}.role.ts`; + + const roleFileContent = getRoleBaseFile({ + name: entityName, + }); + + const filePath = join(appPath, roleFileName); + + await fs.writeFile(filePath, roleFileContent); + + console.log( + chalk.green(`✓ Created role:`), + chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')), ); return; @@ -76,7 +119,7 @@ export class AppAddCommand { name: 'entity', message: `What entity do you want to create?`, default: '', - choices: [SyncableEntity.FUNCTION, SyncableEntity.OBJECT], + choices: [SyncableEntity.FUNCTION, SyncableEntity.OBJECT, SyncableEntity.ROLE], }, ]); diff --git a/packages/twenty-sdk/src/cli/commands/app-dev.command.ts b/packages/twenty-sdk/src/cli/commands/app-dev.command.ts index 9c6831e150f..c28ee25079b 100644 --- a/packages/twenty-sdk/src/cli/commands/app-dev.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-dev.command.ts @@ -2,7 +2,11 @@ import chalk from 'chalk'; import * as chokidar from 'chokidar'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/constants/current-execution-directory'; import { ApiService } from '@/cli/services/api.service'; +import { ManifestValidationError } from '@/cli/utils/validate-manifest'; +import { displayEntitySummary } from '@/cli/utils/display-entity-summary'; import { loadManifest } from '@/cli/utils/load-manifest'; +import { displayWarnings } from '@/cli/utils/display-warnings'; +import { displayErrors } from '@/cli/utils/display-errors'; export class AppDevCommand { private apiService = new ApiService(); @@ -33,13 +37,28 @@ export class AppDevCommand { } private async synchronize(appPath: string) { - const { manifest, packageJson, yarnLock } = await loadManifest(appPath); + try { + const { manifest, packageJson, yarnLock, warnings } = + await loadManifest(appPath); - await this.apiService.syncApplication({ - manifest, - packageJson, - yarnLock, - }); + displayEntitySummary(manifest); + + displayWarnings(warnings); + + await this.apiService.syncApplication({ + manifest, + packageJson, + yarnLock, + }); + + console.log(chalk.green(' ✓ Synced with server')); + } catch (error) { + if (error instanceof ManifestValidationError) { + displayErrors(error); + throw error; + } + throw error; + } } private logStartupInfo(appPath: string, debounceMs: number): void { diff --git a/packages/twenty-sdk/src/cli/commands/app-logs.command.ts b/packages/twenty-sdk/src/cli/commands/app-logs.command.ts index b736c625229..2c9a2180717 100644 --- a/packages/twenty-sdk/src/cli/commands/app-logs.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-logs.command.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/constants/current-execution-directory'; -import { loadManifest } from '@/cli/utils/load-manifest'; import { ApiService } from '@/cli/services/api.service'; +import { loadManifest } from '@/cli/utils/load-manifest'; export class AppLogsCommand { private apiService = new ApiService(); diff --git a/packages/twenty-sdk/src/cli/commands/app-sync.command.ts b/packages/twenty-sdk/src/cli/commands/app-sync.command.ts index 27d2ce785cb..d2c96600a88 100644 --- a/packages/twenty-sdk/src/cli/commands/app-sync.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-sync.command.ts @@ -3,7 +3,11 @@ import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/constants/current-execution-d import { ApiService } from '@/cli/services/api.service'; import { GenerateService } from '@/cli/services/generate.service'; import { type ApiResponse } from '@/cli/types/api-response.types'; +import { ManifestValidationError } from '@/cli/utils/validate-manifest'; +import { displayEntitySummary } from '@/cli/utils/display-entity-summary'; import { loadManifest } from '@/cli/utils/load-manifest'; +import { displayWarnings } from '@/cli/utils/display-warnings'; +import { displayErrors } from '@/cli/utils/display-errors'; export class AppSyncCommand { private apiService = new ApiService(); @@ -28,36 +32,47 @@ export class AppSyncCommand { } private async synchronize({ appPath }: { appPath: string }) { - const { manifest, packageJson, yarnLock, shouldGenerate } = - await loadManifest(appPath); + try { + const { manifest, packageJson, yarnLock, shouldGenerate, warnings } = + await loadManifest(appPath); - let serverlessSyncResult = await this.apiService.syncApplication({ - manifest, - packageJson, - yarnLock, - }); + displayEntitySummary(manifest); - if (shouldGenerate) { - await this.generateService.generateClient(appPath); + displayWarnings(warnings); - const { manifest: manifestWithClient } = await loadManifest(appPath); - - serverlessSyncResult = await this.apiService.syncApplication({ - manifest: manifestWithClient, + let serverlessSyncResult = await this.apiService.syncApplication({ + manifest, packageJson, yarnLock, }); - } - if (serverlessSyncResult.success === false) { - console.error( - chalk.red('❌ Serverless functions Sync failed:'), - serverlessSyncResult.error, - ); - } else { - console.log(chalk.green('✅ Serverless functions synced successfully')); - } + if (shouldGenerate) { + await this.generateService.generateClient(appPath); - return serverlessSyncResult; + const { manifest: manifestWithClient } = await loadManifest(appPath); + + serverlessSyncResult = await this.apiService.syncApplication({ + manifest: manifestWithClient, + packageJson, + yarnLock, + }); + } + + if (serverlessSyncResult.success === false) { + console.error( + chalk.red('❌ Serverless functions Sync failed:'), + serverlessSyncResult.error, + ); + } else { + console.log(chalk.green('✅ Serverless functions synced successfully')); + } + + return serverlessSyncResult; + } catch (error) { + if (error instanceof ManifestValidationError) { + displayErrors(error); + } + throw error; + } } } diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/get-function-base-file.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/get-function-base-file.spec.ts index 4a3c637a061..ee489e9cad6 100644 --- a/packages/twenty-sdk/src/cli/utils/__tests__/get-function-base-file.spec.ts +++ b/packages/twenty-sdk/src/cli/utils/__tests__/get-function-base-file.spec.ts @@ -1,34 +1,65 @@ import { getFunctionBaseFile } from '@/cli/utils/get-function-base-file'; describe('getFunctionBaseFile', () => { - it('should render proper file', () => { - expect( - getFunctionBaseFile({ - name: 'serverless-function-name', - universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', - }), - ).toBe(`import { type FunctionConfig } from 'twenty-sdk'; + it('should render proper file using defineFunction', () => { + const result = getFunctionBaseFile({ + name: 'my-function', + universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', + }); -export const main = async (params: { - a: string; - b: number; -}): Promise<{ message: string }> => { - const { a, b } = params; + // Verify it uses defineFunction + expect(result).toContain("import { defineFunction } from 'twenty-sdk'"); + expect(result).toContain('export default defineFunction({'); - // Rename the parameters and code below with your own logic - // This is just an example - const message = \`Hello, input: \${a} and \${b}\`; + // Verify function properties + expect(result).toContain( + "universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4'", + ); + expect(result).toContain("name: 'my-function'"); + expect(result).toContain('timeoutSeconds: 5'); + expect(result).toContain('handler,'); + expect(result).toContain('triggers: ['); - return { message }; -}; + // Verify handler is exported + expect(result).toContain('export const handler = async'); -export const config: FunctionConfig = { - universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', - name: 'serverless-function-name', - timeoutSeconds: 5, - triggers: [], -}; + // Verify description is included + expect(result).toContain( + "description: 'Add a description for your function'", + ); + }); -`); + it('should generate unique UUID when not provided', () => { + const result = getFunctionBaseFile({ + name: 'auto-uuid-function', + }); + + // Verify it has a universalIdentifier (UUID format) + expect(result).toMatch( + /universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, + ); + }); + + it('should use kebab-case for function name', () => { + const result = getFunctionBaseFile({ + name: 'my-awesome-function', + }); + + expect(result).toContain("name: 'my-awesome-function'"); + expect(result).toContain("path: '/my-awesome-function'"); + }); + + it('should include trigger examples as comments', () => { + const result = getFunctionBaseFile({ + name: 'example-function', + }); + + // Verify trigger examples are included as comments + expect(result).toContain("type: 'route'"); + expect(result).toContain("type: 'cron'"); + expect(result).toContain("type: 'databaseEvent'"); + expect(result).toContain("httpMethod: 'POST'"); + expect(result).toContain("pattern: '0 0 * * *'"); + expect(result).toContain("eventName: 'objectName.created'"); }); }); diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/get-new-object-file-content.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/get-new-object-file-content.spec.ts new file mode 100644 index 00000000000..95eac74a7b7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/__tests__/get-new-object-file-content.spec.ts @@ -0,0 +1,64 @@ +import { getNewObjectFileContent } from '@/cli/utils/get-new-object-file-content'; + +describe('getNewObjectFileContent', () => { + it('should return proper object file using defineObject', () => { + const result = getNewObjectFileContent({ + data: { + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + }, + name: 'company', + }); + + // Verify it uses defineObject + expect(result).toContain("import { defineObject } from 'twenty-sdk'"); + expect(result).toContain('export default defineObject({'); + + // Verify object properties + expect(result).toContain("nameSingular: 'company'"); + expect(result).toContain("namePlural: 'companies'"); + expect(result).toContain("labelSingular: 'Company'"); + expect(result).toContain("labelPlural: 'Companies'"); + expect(result).toContain("icon: 'IconBox'"); + expect(result).toContain('fields: ['); + + // Verify it has a universalIdentifier (UUID format) + expect(result).toMatch( + /universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, + ); + }); + + it('should generate unique UUIDs for each object', () => { + const result1 = getNewObjectFileContent({ + data: { + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + }, + name: 'company', + }); + + const result2 = getNewObjectFileContent({ + data: { + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + labelPlural: 'People', + }, + name: 'person', + }); + + // Extract UUIDs + const uuidRegex = + /universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/; + const uuid1 = result1.match(uuidRegex)?.[1]; + const uuid2 = result2.match(uuidRegex)?.[1]; + + expect(uuid1).toBeDefined(); + expect(uuid2).toBeDefined(); + expect(uuid1).not.toBe(uuid2); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/get-object-decorated-class.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/get-object-decorated-class.spec.ts deleted file mode 100644 index 5461fd9c013..00000000000 --- a/packages/twenty-sdk/src/cli/utils/__tests__/get-object-decorated-class.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getObjectDecoratedClass } from '@/cli/utils/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'; - -@Object({ - universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf', - nameSingular: 'name', - namePlural: 'names', - labelSingular: 'Name', - labelPlural: 'Names', -}) -export class MyNewObject {} -`, - ); - }); -}); diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/get-role-base-file.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/get-role-base-file.spec.ts new file mode 100644 index 00000000000..6a021e8cc4e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/__tests__/get-role-base-file.spec.ts @@ -0,0 +1,70 @@ +import { getRoleBaseFile } from '@/cli/utils/get-role-base-file'; + +describe('getRoleBaseFile', () => { + it('should render proper file using defineRole', () => { + const result = getRoleBaseFile({ + name: 'my-role', + universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', + }); + + // Verify it uses defineRole + expect(result).toContain("import { defineRole } from 'twenty-sdk'"); + expect(result).toContain('export default defineRole({'); + + // Verify role properties + expect(result).toContain( + "universalIdentifier: MY_ROLE_ROLE_UNIVERSAL_IDENTIFIER", + ); + expect(result).toContain( + "'71e45a58-41da-4ae4-8b73-a543c0a9d3d4'", + ); + expect(result).toContain("label: 'my-role'"); + expect(result).toContain("description: 'Add a description for your role'"); + + // Verify permission defaults + expect(result).toContain('canReadAllObjectRecords: true'); + expect(result).toContain('canUpdateAllObjectRecords: true'); + expect(result).toContain('canSoftDeleteAllObjectRecords: true'); + expect(result).toContain('canDestroyAllObjectRecords: false'); + }); + + it('should generate unique UUID when not provided', () => { + const result = getRoleBaseFile({ + name: 'auto-uuid-role', + }); + + // Verify it has a universalIdentifier (UUID format) + expect(result).toMatch( + /'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, + ); + }); + + it('should use kebab-case for role name in label', () => { + const result = getRoleBaseFile({ + name: 'my-awesome-role', + }); + + expect(result).toContain("label: 'my-awesome-role'"); + }); + + it('should export universal identifier constant with correct naming', () => { + const result = getRoleBaseFile({ + name: 'admin-access', + }); + + expect(result).toContain('export const ADMIN_ACCESS_ROLE_UNIVERSAL_IDENTIFIER'); + expect(result).toContain( + 'universalIdentifier: ADMIN_ACCESS_ROLE_UNIVERSAL_IDENTIFIER', + ); + }); + + it('should handle names with numbers', () => { + const result = getRoleBaseFile({ + name: 'role-v2', + }); + + // kebab-case separates numbers with underscore when converted to constant + expect(result).toContain('export const ROLE_V_2_ROLE_UNIVERSAL_IDENTIFIER'); + expect(result).toContain("label: 'role-v2'"); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts index 08a1e829c5c..0d0698ec57b 100644 --- a/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts @@ -1,610 +1,184 @@ -import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { loadManifest } from '@/cli/utils/load-manifest'; -import { type ApplicationConfig } from '@/application'; +import { join } from 'path'; +import { + loadManifest, + type LoadManifestResult, +} from '@/cli/utils/load-manifest'; +import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role'; -const write = (root: string, file: string, content: string) => { - const abs = join(root, file); - ensureDirSync(resolve(abs, '..')); - writeFileSync(abs, content, 'utf8'); -}; +const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app'); -const tsLibMock = `declare module 'tslib' { - export const __decorate: any; - export const __metadata: any; - export const __param: any; - export const __awaiter: any; - export const __read: any; - export const __spread: any; - export const __spreadArray: any; - export const __assign: any; - }`; +describe('loadManifest with test-app', () => { + let manifest: LoadManifestResult['manifest']; + let packageJson: LoadManifestResult['packageJson']; + let yarnLock: LoadManifestResult['yarnLock']; + let warnings: LoadManifestResult['warnings']; + let shouldGenerate: LoadManifestResult['shouldGenerate']; -const twentySdkTypesMock = ` -declare module 'twenty-sdk' { - export type SyncableEntityOptions = { universalIdentifier: string }; + beforeAll(async () => { + const result = await loadManifest(TEST_APP_PATH); - type ApplicationVariable = SyncableEntityOptions & { - value?: string; - description?: string; - isSecret?: boolean; - }; - - export type ApplicationConfig = SyncableEntityOptions & { - displayName?: string; - description?: string; - icon?: string; - applicationVariables?: Record; - functionRoleUniversalIdentifier?: string; - }; - - export type RoleConfig = SyncableEntityOptions & { - label: string; - description?: string; - icon?: string; - canReadAllObjectRecords?: boolean; - canUpdateAllObjectRecords?: boolean; - canSoftDeleteAllObjectRecords?: boolean; - canDestroyAllObjectRecords?: boolean; - objectPermissions?: any[]; - fieldPermissions?: any[]; - permissionFlags?: any[]; - }; - - export enum PermissionFlag { - API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS', - WORKSPACE = 'WORKSPACE', - WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS', - ROLES = 'ROLES', - DATA_MODEL = 'DATA_MODEL', - SECURITY = 'SECURITY', - WORKFLOWS = 'WORKFLOWS', - IMPERSONATE = 'IMPERSONATE', - SSO_BYPASS = 'SSO_BYPASS', - APPLICATIONS = 'APPLICATIONS', - LAYOUTS = 'LAYOUTS', - BILLING = 'BILLING', - AI_SETTINGS = 'AI_SETTINGS', - - // Tool permissions - AI = 'AI', - VIEWS = 'VIEWS', - UPLOAD_FILE = 'UPLOAD_FILE', - DOWNLOAD_FILE = 'DOWNLOAD_FILE', - SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', - HTTP_REQUEST_TOOL = 'HTTP_REQUEST_TOOL', - IMPORT_CSV = 'IMPORT_CSV', - EXPORT_CSV = 'EXPORT_CSV', - CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS', - PROFILE_INFORMATION = 'PROFILE_INFORMATION', - } - - - - type RouteTrigger = { - type: 'route'; - path: string; - httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - isAuthRequired: boolean; - }; - - type CronTrigger = { - type: 'cron'; - pattern: string; - }; - - type DatabaseEventTrigger = { - type: 'databaseEvent'; - eventName: string; - }; - - type ServerlessFunctionTrigger = SyncableEntityOptions & - (RouteTrigger | CronTrigger | DatabaseEventTrigger); - - export type FunctionConfig = SyncableEntityOptions & { - name?: string; - description?: string; - timeoutSeconds?: number; - triggers?: ServerlessFunctionTrigger[]; - }; - - type ObjectMetadataOptions = SyncableEntityOptions & { - nameSingular: string; - namePlural: string; - labelSingular: string; - labelPlural: string; - description?: string; - icon?: string; - }; - - export const Object = (_: ObjectMetadataOptions): ClassDecorator => { - return () => {}; - }; - - export class BaseObjectMetadata {} - - export enum FieldType { - TEXT = 'TEXT', - FULL_NAME = 'FULL_NAME', - ADDRESS = 'ADDRESS', - SELECT = 'SELECT', - DATE_TIME = 'DATE_TIME', - } - - export const Field: (_: any) => PropertyDecorator; -} -`; - -const defaultRoleMock = ` -import { PermissionFlag, type RoleConfig } from 'twenty-sdk'; - -export const functionRole: RoleConfig = { - universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', - label: 'hello-world-role', - description: 'A role to define app permissions', - canReadAllObjectRecords: false, - canUpdateAllObjectRecords: false, - canSoftDeleteAllObjectRecords: false, - canDestroyAllObjectRecords: false, - objectPermissions: [ - { - objectNameSingular: 'postCard', - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: false, - canDestroyObjectRecords: false, - }, - ], - fieldPermissions: [ - { - objectNameSingular: 'postCard', - fieldName: 'content', - canReadFieldValue: false, - canUpdateFieldValue: false, - }, - ], - permissionFlags: [PermissionFlag.APPLICATIONS], -} - -`; - -const serverlessFunctionMock = ` -import { type FunctionConfig } from 'twenty-sdk'; - -export const main = async (params: any): Promise => { - return {}; -} - -export const config: FunctionConfig = { - universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', - name: 'hello', - timeoutSeconds: 2, - triggers: [ - { - universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', - type: 'route', - path: '/post-card/create', - httpMethod: 'GET', - isAuthRequired: false - }, - { - universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', - type: 'cron', - pattern: '0 0 1 1 *', // Every year 1st of January - }, - { - universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', - type: 'databaseEvent', - eventName: 'person.created' - } - ] -};`; - -const objectMock = `import { - Object, - Field, - FieldType -} from 'twenty-sdk'; - -enum PostCardStatus { - DRAFT = 'DRAFT', - SENT = 'SENT', - DELIVERED = 'DELIVERED', - RETURNED = 'RETURNED', -} - -@Object({ - universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', - nameSingular: 'postCard', - namePlural: 'postCards', - labelSingular: 'Post card', - labelPlural: 'Post cards', - description: ' A post card object', - icon: 'IconMail', -}) -export class PostCard { - @Field({ - universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', - type: FieldType.TEXT, - label: 'Content', - description: "Postcard's content", - }) - content: string; - - @Field({ - universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac', - type: FieldType.FULL_NAME, - label: 'Recipient name', - }) - recipientName: string; - - @Field({ - universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266', - type: FieldType.ADDRESS, - label: 'Recipient address', - }) - recipientAddress: string; - - @Field({ - universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e', - type: FieldType.SELECT, - label: 'Status', - defaultValue: \`'\${PostCardStatus.DRAFT}'\`, - options: [ - { - value: PostCardStatus.DRAFT, - label: 'Draft', - position: 0, - color: 'gray', - }, - { - value: PostCardStatus.SENT, - label: 'Sent', - position: 1, - color: 'orange', - }, - { - value: PostCardStatus.DELIVERED, - label: 'Delivered', - position: 2, - color: 'green', - }, - { - value: PostCardStatus.RETURNED, - label: 'Returned', - position: 3, - color: 'orange', - }, - ], - }) - status: 'draft' | 'sent' | 'delivered' | 'returned'; - - @Field({ - universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433', - type: FieldType.DATE_TIME, - label: 'Delivered at', - isNullable: true, - defaultValue: null, - }) - deliveredAt?: Date; -} -`; - -const packageJsonMock = { - name: 'my-app', - version: '0.0.1', - license: 'MIT', - engines: { - node: '^24.5.0', - npm: 'please-use-yarn', - yarn: '>=4.0.2', - }, - packageManager: 'yarn@4.9.2', - scripts: { - 'create-entity': 'twenty app add', - dev: 'twenty app dev', - generate: 'twenty app generate', - sync: 'twenty app sync', - uninstall: 'twenty app uninstall', - auth: 'twenty auth login', - }, - dependencies: { - 'twenty-sdk': '0.1.0', - }, - devDependencies: { - '@types/node': '^24.7.2', - typescript: '^5.9.3', - }, -}; - -const tsConfigJsonMock = { - compileOnSave: false, - compilerOptions: { - sourceMap: true, - declaration: true, - outDir: './dist', - rootDir: '.', - moduleResolution: 'node', - allowSyntheticDefaultImports: true, - emitDecoratorMetadata: true, - experimentalDecorators: true, - importHelpers: true, - allowUnreachableCode: false, - strictNullChecks: true, - alwaysStrict: true, - noImplicitAny: true, - strictBindCallApply: false, - target: 'es2018', - module: 'esnext', - lib: ['es2020', 'dom'], - skipLibCheck: true, - skipDefaultLibCheck: true, - resolveJsonModule: true, - }, - - exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts'], -}; - -const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 -`; - -const applicationMockConfig: ApplicationConfig = { - universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe', - displayName: 'My App', - description: 'My app description', - icon: 'IconWorld', - applicationVariables: { - TWENTY_API_KEY: { - universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6', - description: 'Twenty API Key', - isSecret: true, - }, - TWENTY_API_URL: { - universalIdentifier: 'aa7210a6-75b0-46ca-bcbe-09a5b42a76ec', - description: 'Twenty API Url', - isSecret: false, - }, - }, - functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2', -}; - -const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk'; - -const config: ApplicationConfig = ${JSON.stringify(applicationMockConfig)}; - -export default config; -`; - -describe('loadManifest (integration)', () => { - const appDirectory = join(tmpdir(), 'test-app'); - - beforeEach(async () => { - await ensureDirSync(appDirectory); - - write(appDirectory, 'yarn.lock', yarnLockMock); - - write(appDirectory, 'application.config.ts', applicationConfigMock); - - write( - appDirectory, - 'tsconfig.json', - JSON.stringify(tsConfigJsonMock, null, 2), - ); - - write( - appDirectory, - 'package.json', - JSON.stringify(packageJsonMock, null, 2), - ); - - write(appDirectory, 'src/Account.ts', objectMock); - - write(appDirectory, 'src/hello.ts', serverlessFunctionMock); - - write(appDirectory, 'src/defaultRole.ts', defaultRoleMock); - - write( - appDirectory, - 'src/types/twenty-sdk-application.d.ts', - twentySdkTypesMock, - ); - - write( - appDirectory, - 'src/types/tslib.d.ts', - // minimal + future-proof - tsLibMock, - ); + manifest = result.manifest; + packageJson = result.packageJson; + yarnLock = result.yarnLock; + warnings = result.warnings; + shouldGenerate = result.shouldGenerate; }); - afterEach(() => { - removeSync(appDirectory); - }); - - it('builds a full manifest for a valid workspace', async () => { - const { packageJson, yarnLock, manifest } = - await loadManifest(appDirectory); - - expect(packageJson.name).toBe('my-app'); + it('should load manifest from test-app directory', async () => { + // Check package.json loaded correctly + expect(packageJson.name).toBe('test-app'); expect(packageJson.version).toBe('0.0.1'); - expect(packageJson.license).toBe('MIT'); - expect(yarnLock).toContain( - '# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.', + + // Check yarn.lock exists (can be empty) + expect(yarnLock).toBeDefined(); + + // Check no warnings + expect(warnings).toEqual([]); + + // Check application config + expect(manifest.application).toBeDefined(); + expect(manifest.application.universalIdentifier).toBe( + '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', ); + expect(manifest.application.displayName).toBe('Hello World'); + expect(manifest.application.description).toBe('A simple hello world app'); + expect(manifest.application.icon).toBe('IconWorld'); - // application - expect(manifest.application).toEqual(applicationMockConfig); + expect(manifest.objects).toHaveLength(1); - expect(manifest.objects.length).toBe(1); + const postCard = manifest.objects[0]; + expect(postCard.universalIdentifier).toBe( + '54b589ca-eeed-4950-a176-358418b85c05', + ); + expect(postCard.nameSingular).toBe('postCard'); + expect(postCard.namePlural).toBe('postCards'); + expect(postCard.labelSingular).toBe('Post card'); + expect(postCard.labelPlural).toBe('Post cards'); + expect(postCard.icon).toBe('IconMail'); - for (const object of manifest.objects) { - const { universalIdentifier: _, fields, ...otherInfo } = object; - expect(otherInfo).toEqual({ - description: ' A post card object', - icon: 'IconMail', - labelPlural: 'Post cards', - labelSingular: 'Post card', - namePlural: 'postCards', - nameSingular: 'postCard', - }); + // Check fields + expect(postCard.fields).toHaveLength(5); - expect(Array.isArray(fields)).toBe(true); + const contentField = postCard.fields?.find((f) => f.name === 'content'); + expect(contentField).toBeDefined(); + expect(contentField?.universalIdentifier).toBe( + '58a0a314-d7ea-4865-9850-7fb84e72f30b', + ); + expect(contentField?.type).toBe('TEXT'); + expect(contentField?.label).toBe('Content'); - expect(fields).toEqual([ - { - universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', - type: 'TEXT', - label: 'Content', - description: "Postcard's content", - name: 'content', - }, - { - universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac', - type: 'FULL_NAME', - label: 'Recipient name', - name: 'recipientName', - }, - { - universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266', - type: 'ADDRESS', - label: 'Recipient address', - name: 'recipientAddress', - }, - { - universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e', - type: 'SELECT', - label: 'Status', - defaultValue: "'DRAFT'", - options: [ - { value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' }, - { value: 'SENT', label: 'Sent', position: 1, color: 'orange' }, - { - value: 'DELIVERED', - label: 'Delivered', - position: 2, - color: 'green', - }, - { - value: 'RETURNED', - label: 'Returned', - position: 3, - color: 'orange', - }, - ], - name: 'status', - }, - { - universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433', - type: 'DATE_TIME', - label: 'Delivered at', - isNullable: true, - defaultValue: null, - name: 'deliveredAt', - }, - ]); - } + const statusField = postCard.fields?.find((f) => f.name === 'status'); + expect(statusField).toBeDefined(); + expect(statusField?.type).toBe('SELECT'); + expect(statusField?.options).toHaveLength(4); - // serverless functions - for (const serverlessFunction of manifest.serverlessFunctions) { - const { - universalIdentifier: _, - handlerPath: __, - triggers, - ...otherInfo - } = serverlessFunction; + expect(manifest.serverlessFunctions).toHaveLength(2); - expect(otherInfo).toEqual({ - handlerName: 'main', - name: 'hello', - timeoutSeconds: 2, - }); + const testFunction = manifest.serverlessFunctions[1]; + expect(testFunction.universalIdentifier).toBe( + 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + ); + expect(testFunction.name).toBe('test-function'); + expect(testFunction.timeoutSeconds).toBe(2); + expect(testFunction.handlerName).toBe('handler'); + expect(testFunction.handlerPath).toBe('src/app/test-function.function.ts'); - for (const trigger of triggers) { - const { universalIdentifier: _, ...otherInfo } = trigger; - switch (trigger.type) { - case 'route': - expect(otherInfo).toEqual({ - isAuthRequired: false, - httpMethod: 'GET', - path: '/post-card/create', - type: 'route', - }); - break; - case 'cron': - expect(otherInfo).toEqual({ - pattern: '0 0 1 1 *', - type: 'cron', - }); - break; - case 'databaseEvent': - expect(otherInfo).toEqual({ - eventName: 'person.created', - type: 'databaseEvent', - }); - break; - } - } - } + // Check triggers + expect(testFunction.triggers).toHaveLength(3); + + const routeTrigger = testFunction.triggers.find((t) => t.type === 'route'); + expect(routeTrigger).toBeDefined(); + expect(routeTrigger?.universalIdentifier).toBe( + 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + ); + expect(routeTrigger?.path).toBe('/post-card/create'); + expect(routeTrigger?.httpMethod).toBe('GET'); + + const cronTrigger = testFunction.triggers.find((t) => t.type === 'cron'); + expect(cronTrigger).toBeDefined(); + expect(cronTrigger?.pattern).toBe('0 0 1 1 *'); + + const dbEventTrigger = testFunction.triggers.find( + (t) => t.type === 'databaseEvent', + ); + expect(dbEventTrigger).toBeDefined(); + expect(dbEventTrigger?.eventName).toBe('person.created'); + + // Second function + const testFunction2 = manifest.serverlessFunctions[0]; + expect(testFunction2.universalIdentifier).toBe( + 'eb3ffc98-88ec-45d4-9b4a-56833b219ccb', + ); + expect(testFunction2.name).toBe('test-function-2'); + expect(testFunction2.timeoutSeconds).toBe(2); + expect(testFunction2.handlerName).toBe('testFunction2'); + expect(testFunction2.handlerPath).toBe('src/utils/test-function-2.util.ts'); - //Role expect(manifest.roles).toHaveLength(1); - for (const role of manifest.roles ?? []) { - const { - universalIdentifier: _, - objectPermissions, - fieldPermissions, - permissionFlags, - ...otherInfo - } = role; - expect(otherInfo).toEqual({ - label: 'hello-world-role', - description: 'A role to define app permissions', - canReadAllObjectRecords: false, - canUpdateAllObjectRecords: false, - canSoftDeleteAllObjectRecords: false, - canDestroyAllObjectRecords: false, - }); + const role = manifest.roles![0]; + expect(role.universalIdentifier).toBe( + 'b648f87b-1d26-4961-b974-0908fd991061', + ); + expect(role.label).toBe('Default function role'); + expect(role.description).toBe('Default role for function Twenty client'); + expect(role.canReadAllObjectRecords).toBe(false); + expect(role.canUpdateAllObjectRecords).toBe(false); - expect(Array.isArray(objectPermissions)).toBe(true); - expect(Array.isArray(fieldPermissions)).toBe(true); - expect(Array.isArray(permissionFlags)).toBe(true); - } - }); + // Check object permissions + expect(role.objectPermissions).toHaveLength(1); + expect(role.objectPermissions![0].objectNameSingular).toBe('postCard'); + expect(role.objectPermissions![0].canReadObjectRecords).toBe(true); + expect(role.objectPermissions![0].canUpdateObjectRecords).toBe(true); - it('should not define serverless for util file', async () => { - write( - appDirectory, - 'src/utils/format.ts', - ` -export const format = async (params: any): Promise => { - return {}; -} -`, + // Check field permissions + expect(role.fieldPermissions).toHaveLength(1); + expect(role.fieldPermissions![0].objectNameSingular).toBe('postCard'); + expect(role.fieldPermissions![0].fieldName).toBe('content'); + expect(role.fieldPermissions![0].canReadFieldValue).toBe(false); + + expect(manifest.sources).toBeDefined(); + expect(manifest.sources['src']).toBeDefined(); + + // Check that the source files are loaded + const srcSources = manifest.sources['src'] as Record; + const appSources = srcSources['app'] as Record; + expect(appSources['application.config.ts']).toBeDefined(); + expect(appSources['postCard.object.ts']).toBeDefined(); + expect(appSources['test-function.function.ts']).toBeDefined(); + expect(appSources['default-function.role.ts']).toBeDefined(); + + // Verify source content contains expected code + expect(appSources['application.config.ts']).toContain('defineApp'); + expect(appSources['postCard.object.ts']).toContain('defineObject'); + expect(appSources['test-function.function.ts']).toContain('defineFunction'); + expect(appSources['default-function.role.ts']).toContain('defineRole'); + + expect(shouldGenerate).toBe(false); + + const expectedRoleId = DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER; + + expect(manifest.application.functionRoleUniversalIdentifier).toBe( + expectedRoleId, ); - const { manifest } = await loadManifest(appDirectory); - expect(manifest.serverlessFunctions.length).toBe(1); - }); + const linkedRole = manifest.roles?.find( + (r) => r.universalIdentifier === expectedRoleId, + ); + expect(linkedRole).toBeDefined(); - it('manifest should contains typescript sources', async () => { - const { manifest } = await loadManifest(appDirectory); - // the method is already exercised in loadManifest; just assert again: - expect(Object.keys(manifest.sources)).toEqual([ - 'application.config.ts', - 'src', - ]); - expect(Object.keys(manifest.sources['src'])).toEqual([ - 'Account.ts', - 'defaultRole.ts', - 'hello.ts', - ]); - }); + expect(manifest.application.applicationVariables).toBeDefined(); - it('manifest should contains typescript sources', async () => { - const { shouldGenerate } = await loadManifest(appDirectory); - expect(shouldGenerate).toBe(false); + const defaultRecipient = + manifest.application.applicationVariables?.DEFAULT_RECIPIENT_NAME; + expect(defaultRecipient).toBeDefined(); + expect(defaultRecipient?.universalIdentifier).toBe( + '19e94e59-d4fe-4251-8981-b96d0a9f74de', + ); + expect(defaultRecipient?.description).toBe( + 'Default recipient name for postcards', + ); + expect(defaultRecipient?.value).toBe('Alex Karp'); + expect(defaultRecipient?.isSecret).toBe(false); }); }); diff --git a/packages/twenty-sdk/src/cli/utils/config-loader.ts b/packages/twenty-sdk/src/cli/utils/config-loader.ts new file mode 100644 index 00000000000..73d0c7ef2f8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/config-loader.ts @@ -0,0 +1,234 @@ +import { createJiti } from 'jiti'; +import * as fs from 'fs-extra'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Create a jiti instance for loading TypeScript config files +const createConfigLoader = () => { + return createJiti(fileURLToPath(import.meta.url), { + moduleCache: false, // Don't cache during dev for hot reload + fsCache: false, + interopDefault: true, + }); +}; + +/** + * Find the first valid config export from a module. + * Priority: + * 1. default export + * 2. first named export that is a plain object (not a function, class, or primitive) + */ +const findConfigExport = ( + mod: Record, + validator?: (value: unknown) => boolean, +): T | undefined => { + // Priority 1: default export + if (mod.default !== undefined) { + if (!validator || validator(mod.default)) { + return mod.default as T; + } + } + + // Priority 2: first named export that passes validation (or is a plain object) + for (const [key, value] of Object.entries(mod)) { + if (key === 'default') continue; + if (value === undefined || value === null) continue; + + // Skip functions, classes, and primitives - we want config objects + if (typeof value !== 'object') continue; + + // Skip arrays + if (Array.isArray(value)) continue; + + if (!validator || validator(value)) { + return value as T; + } + } + + return undefined; +}; + +/** + * Load a TypeScript config file using jiti runtime evaluation. + * This allows importing constants and other modules in config files. + * + * Supports multiple export patterns: + * - `export default { ... }` + * - `export const anyName = { ... }` (any named export that is a plain object) + * + * @example + * ```typescript + * const config = await loadConfig('/path/to/src/app/application.config.ts'); + * ``` + */ +export const loadConfig = async (filepath: string): Promise => { + const jiti = createConfigLoader(); + + try { + const mod = (await jiti.import(filepath)) as Record; + + const config = findConfigExport(mod); + + if (!config) { + throw new Error( + `Config file ${filepath} must export a config object (default export or any named object export)`, + ); + } + + return config; + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to load config from ${filepath}: ${error.message}`, + ); + } + throw error; + } +}; + +/** + * Escape special regex characters in a string. + */ +const escapeRegExp = (string: string): string => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + +/** + * Extract the import path for a given identifier from source code. + * Returns null if the identifier is not imported (i.e., defined locally). + */ +const extractImportPath = ( + source: string, + identifier: string, + filepath: string, + appPath: string, +): string | null => { + // Escape special regex characters in the identifier (e.g., $ in function names like $handler) + const escapedIdentifier = escapeRegExp(identifier); + + // Match: import { identifier } from 'path' + // Match: import { original as identifier } from 'path' + // Match: import identifier from 'path' (default import) + const patterns = [ + // Named import: import { foo } from 'path' or import { foo, bar } from 'path' + new RegExp( + `import\\s*\\{[^}]*\\b${escapedIdentifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, + ), + // Aliased import: import { original as foo } from 'path' + new RegExp( + `import\\s*\\{[^}]*\\w+\\s+as\\s+${escapedIdentifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, + ), + // Default import: import foo from 'path' + new RegExp(`import\\s+${escapedIdentifier}\\s+from\\s*['"]([^'"]+)['"]`), + ]; + + for (const pattern of patterns) { + const match = source.match(pattern); + + if (match) { + const importPath = match[1]; + // Resolve relative to the function file, then make relative to appPath + const fileDir = path.dirname(filepath); + const absolutePath = path.resolve(fileDir, importPath); + const relativePath = path.relative(appPath, absolutePath); + + // Add .ts extension if not present + const resultPath = relativePath.endsWith('.ts') + ? relativePath + : `${relativePath}.ts`; + + return resultPath.replace(/\\/g, '/'); + } + } + + // Handler is defined locally, not imported + return null; +}; + +/** + * Load a function module and extract handler info from config.handler property. + * + * The handler can be either: + * 1. Imported from another file: + * ```typescript + * import { myHandler } from '../src/handlers/my-handler'; + * export const config = { handler: myHandler, ... }; + * ``` + * + * 2. Defined locally in the same file: + * ```typescript + * export const myHandler = async () => { ... }; + * export const config = { handler: myHandler, ... }; + * ``` + * + * @example + * ```typescript + * const { config, handlerName, handlerPath } = await loadFunctionModule( + * '/path/to/src/app/functions/my-function.function.ts', + * '/path/to/app' + * ); + * ``` + */ +export const loadFunctionModule = async ( + filepath: string, + appPath: string, +): Promise<{ + config: unknown; + handlerName: string; + handlerPath: string; +}> => { + const jiti = createConfigLoader(); + + try { + const mod = (await jiti.import(filepath)) as Record; + + // Find config with a handler property + const hasHandler = (value: unknown): boolean => { + return ( + typeof value === 'object' && + value !== null && + 'handler' in value && + typeof (value as { handler: unknown }).handler === 'function' + ); + }; + + const config = findConfigExport<{ handler: Function }>(mod, hasHandler); + + if (!config) { + throw new Error( + `Function file ${filepath} must export a config object with a "handler" property`, + ); + } + + // Get handler name from the function's name property + const handlerName = config.handler.name; + + if (!handlerName) { + throw new Error( + `Handler function in ${filepath} must be a named function`, + ); + } + + // Parse source to find where the handler is imported from + const source = await fs.readFile(filepath, 'utf8'); + const importPath = extractImportPath( + source, + handlerName, + filepath, + appPath, + ); + + // If handler is imported, use the import path; otherwise use the function file itself + const handlerPath = + importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/'); + + return { config, handlerName, handlerPath }; + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to load function module from ${filepath}: ${error.message}`, + ); + } + throw error; + } +}; diff --git a/packages/twenty-sdk/src/cli/utils/display-entity-summary.ts b/packages/twenty-sdk/src/cli/utils/display-entity-summary.ts new file mode 100644 index 00000000000..7c133675591 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/display-entity-summary.ts @@ -0,0 +1,14 @@ +import chalk from 'chalk'; +import { type ApplicationManifest } from 'twenty-shared/application'; + +export const displayEntitySummary = (manifest: ApplicationManifest): void => { + const appName = manifest.application.displayName ?? 'Application'; + console.log(chalk.green(` ✓ Loaded "${appName}"`)); + console.log(chalk.green(` ✓ Found ${manifest.objects.length} object(s)`)); + console.log( + chalk.green(` ✓ Found ${manifest.serverlessFunctions.length} function(s)`), + ); + console.log( + chalk.green(` ✓ Found ${manifest.roles?.length ?? 'no'} role(s)`), + ); +}; diff --git a/packages/twenty-sdk/src/cli/utils/display-errors.ts b/packages/twenty-sdk/src/cli/utils/display-errors.ts new file mode 100644 index 00000000000..dae72a22abe --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/display-errors.ts @@ -0,0 +1,10 @@ +import { type ManifestValidationError } from '@/cli/utils/validate-manifest'; +import chalk from 'chalk'; + +export const displayErrors = (error: ManifestValidationError): void => { + console.log(chalk.red('\n ✗ Manifest validation failed:\n')); + for (const err of error.errors) { + console.log(chalk.red(` • ${err.path}: ${err.message}`)); + } + console.log(''); +}; diff --git a/packages/twenty-sdk/src/cli/utils/display-warnings.ts b/packages/twenty-sdk/src/cli/utils/display-warnings.ts new file mode 100644 index 00000000000..f601ae88e48 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/display-warnings.ts @@ -0,0 +1,14 @@ +import type { ValidationWarning } from '@/cli/utils/validate-manifest'; +import chalk from 'chalk'; + +export const displayWarnings = (warnings?: ValidationWarning[]): void => { + if (!warnings || warnings.length === 0) { + return; + } + + console.log(''); + for (const warning of warnings) { + const path = warning.path ? `${warning.path}: ` : ''; + console.log(chalk.yellow(` ⚠ ${path}${warning.message}`)); + } +}; diff --git a/packages/twenty-sdk/src/cli/utils/get-function-base-file.ts b/packages/twenty-sdk/src/cli/utils/get-function-base-file.ts index 3e54e13a5eb..53a00b2778e 100644 --- a/packages/twenty-sdk/src/cli/utils/get-function-base-file.ts +++ b/packages/twenty-sdk/src/cli/utils/get-function-base-file.ts @@ -9,28 +9,51 @@ export const getFunctionBaseFile = ({ universalIdentifier?: string; }) => { const kebabCaseName = kebabCase(name); + const triggerUniversalIdentifier = v4(); - return `import { type FunctionConfig } from 'twenty-sdk'; + return `import { defineFunction } from 'twenty-sdk'; -export const main = async (params: { +// Handler function - rename and implement your logic +export const handler = 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}\`; + // Replace with your own logic + const message = \`Hello, input: \${a} and \${b}\`; return { message }; }; -export const config: FunctionConfig = { +export default defineFunction({ universalIdentifier: '${universalIdentifier}', name: '${kebabCaseName}', + description: 'Add a description for your function', timeoutSeconds: 5, - triggers: [], -}; - + handler, + triggers: [ + // Add your triggers here + // Route trigger example: + // { + // universalIdentifier: '${triggerUniversalIdentifier}', + // type: 'route', + // path: '/${kebabCaseName}', + // httpMethod: 'POST', + // }, + // Cron trigger example: + // { + // universalIdentifier: '...', + // type: 'cron', + // pattern: '0 0 * * *', // Daily at midnight + // }, + // Database event trigger example: + // { + // universalIdentifier: '...', + // type: 'databaseEvent', + // eventName: 'objectName.created', + // }, + ], +}); `; }; diff --git a/packages/twenty-sdk/src/cli/utils/get-new-object-file-content.ts b/packages/twenty-sdk/src/cli/utils/get-new-object-file-content.ts new file mode 100644 index 00000000000..915592b3f35 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/get-new-object-file-content.ts @@ -0,0 +1,37 @@ +import { v4 } from 'uuid'; + +export const getNewObjectFileContent = ({ + data, +}: { + data: { + nameSingular: string; + namePlural: string; + labelSingular: string; + labelPlural: string; + }; + name: string; +}) => { + const universalIdentifier = v4(); + + return `import { defineObject } from 'twenty-sdk'; + +export default defineObject({ + universalIdentifier: '${universalIdentifier}', + nameSingular: '${data.nameSingular}', + namePlural: '${data.namePlural}', + labelSingular: '${data.labelSingular}', + labelPlural: '${data.labelPlural}', + icon: 'IconBox', + fields: [ + // Add your fields here using defineField helper + // Example: + // { + // universalIdentifier: '...', + // type: FieldMetadataType.TEXT, + // name: 'description', + // label: 'Description', + // }, + ], +}); +`; +}; diff --git a/packages/twenty-sdk/src/cli/utils/get-object-decorated-class.ts b/packages/twenty-sdk/src/cli/utils/get-object-decorated-class.ts deleted file mode 100644 index a24c9a0cf05..00000000000 --- a/packages/twenty-sdk/src/cli/utils/get-object-decorated-class.ts +++ /dev/null @@ -1,25 +0,0 @@ -import camelcase from 'lodash.camelcase'; - -export const getObjectDecoratedClass = ({ - data, - name, -}: { - data: object; - name: string; -}) => { - const decoratorOptions = Object.entries(data) - .map(([key, value]) => ` ${key}: '${value}',`) - .join('\n'); - - const camelCaseName = camelcase(name); - - const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1); - - return `import { Object } from 'twenty-sdk'; - -@Object({ -${decoratorOptions} -}) -export class ${className} {} -`; -}; diff --git a/packages/twenty-sdk/src/cli/utils/get-role-base-file.ts b/packages/twenty-sdk/src/cli/utils/get-role-base-file.ts new file mode 100644 index 00000000000..ecce28f44a0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/get-role-base-file.ts @@ -0,0 +1,28 @@ +import kebabCase from 'lodash.kebabcase'; +import { v4 } from 'uuid'; + +export const getRoleBaseFile = ({ + name, + universalIdentifier = v4(), +}: { + name: string; + universalIdentifier?: string; +}) => { + const kebabCaseName = kebabCase(name); + + return `import { defineRole } from 'twenty-sdk'; + +export const ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_ROLE_UNIVERSAL_IDENTIFIER = + '${universalIdentifier}'; + +export default defineRole({ + universalIdentifier: ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_ROLE_UNIVERSAL_IDENTIFIER, + label: '${name}', + description: 'Add a description for your role', + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canSoftDeleteAllObjectRecords: true, + canDestroyAllObjectRecords: false, +}); +`; +}; diff --git a/packages/twenty-sdk/src/cli/utils/load-manifest.ts b/packages/twenty-sdk/src/cli/utils/load-manifest.ts index 15cd1805ce8..bfb39c02c91 100644 --- a/packages/twenty-sdk/src/cli/utils/load-manifest.ts +++ b/packages/twenty-sdk/src/cli/utils/load-manifest.ts @@ -1,533 +1,249 @@ import * as fs from 'fs-extra'; -import { posix, relative, sep } from 'path'; -import { - type Decorator, - type Expression, - type FunctionDeclaration, - type Modifier, - type Node, - type Program, - type SourceFile, - SyntaxKind, - type VariableDeclaration, - forEachChild, - getDecorators, - isArrayLiteralExpression, - isArrowFunction, - isCallExpression, - isClassDeclaration, - isComputedPropertyName, - isExportAssignment, - isFunctionExpression, - isIdentifier, - isImportDeclaration, - isNoSubstitutionTemplateLiteral, - isNumericLiteral, - isObjectLiteralExpression, - isPropertyAccessExpression, - isPropertyAssignment, - isPropertyDeclaration, - isShorthandPropertyAssignment, - isStringLiteralLike, - isTemplateExpression, - isVariableStatement, -} from 'typescript'; -import { GENERATED_FOLDER_NAME } from '@/cli/services/generate.service'; -import { type Sources } from 'twenty-shared/types'; +import { glob } from 'fast-glob'; +import path, { posix, relative, sep } from 'path'; import { type Application, - type PackageJson, type ApplicationManifest, - type ServerlessFunctionManifest, type ObjectManifest, + type PackageJson, type RoleManifest, + type ServerlessFunctionManifest, } from 'twenty-shared/application'; -import { findPathFile } from '@/cli/utils/find-path-file'; -import { getTsProgramAndDiagnostics } from '@/cli/utils/get-ts-program-and-diagnostics'; -import { parseJsoncFile, parseTextFile } from '@/cli/utils/jsonc-parser'; -import { formatAndWarnTsDiagnostics } from '@/cli/utils/format-and-warn-ts-diagnostics'; - -type JSONValue = - | string - | number - | boolean - | null - | JSONValue[] - | { [k: string]: JSONValue }; - -const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => { - const expr = node.expression; - if (isCallExpression(expr)) { - if (isIdentifier(expr.expression)) return expr.expression.text === name; - if (isPropertyAccessExpression(expr.expression)) - return expr.expression.name.text === name; - } - return false; -}; - -const exprToValue = (expr: Expression): JSONValue => { - if (isStringLiteralLike(expr)) return expr.text; - if (isNumericLiteral(expr)) return Number(expr.text); - if (expr.kind === SyntaxKind.TrueKeyword) return true; - if (expr.kind === SyntaxKind.FalseKeyword) return false; - if (expr.kind === SyntaxKind.NullKeyword) return null; - - if (isPropertyAccessExpression(expr)) { - if (isIdentifier(expr.expression) && isIdentifier(expr.name)) { - return expr.name.text; - } - return String(expr.getText()); - } - - if (isNoSubstitutionTemplateLiteral(expr)) { - return expr.text; - } - if (isTemplateExpression(expr)) { - let out = expr.head.text; - for (const span of expr.templateSpans) { - const v = exprToValue(span.expression); - out += String(v) + span.literal.text; - } - return out; - } - - if (isArrayLiteralExpression(expr)) { - return expr.elements.map((e) => - e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e), - ); - } - - if (isObjectLiteralExpression(expr)) { - const obj: Record = {}; - for (const prop of expr.properties) { - if (isPropertyAssignment(prop)) { - const key = - isIdentifier(prop.name) || isStringLiteralLike(prop.name) - ? prop.name.text - : isComputedPropertyName(prop.name) && - isStringLiteralLike(prop.name.expression) - ? prop.name.expression.text - : undefined; - if (key) obj[key] = exprToValue(prop.initializer); - } else if (isShorthandPropertyAssignment(prop)) { - // Unsupported without a checker; skip to keep it "light". - // Could resolve via typechecker if needed. - } - // getters/setters/methods are ignored intentionally - } - return obj; - } - - // Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback. - // You can throw instead if you prefer to fail fast. - return isIdentifier(expr) - ? expr.text - : String((expr as any).getText?.() ?? ''); -}; - -const getFirstArgObject = (dec: Decorator) => { - if (!isCallExpression(dec.expression)) return undefined; - const [firstArg] = dec.expression.arguments; - return firstArg && isObjectLiteralExpression(firstArg) - ? (exprToValue(firstArg) as Record) - : undefined; -}; - -const collectObjects = (program: Program) => { - const manifest: ObjectManifest[] = []; - - for (const sf of program.getSourceFiles()) { - if (sf.isDeclarationFile) { - continue; - } - - const visit = (node: Node) => { - if (isClassDeclaration(node) && getDecorators(node)?.length) { - const decorators = getDecorators(node); - const objectDec = decorators?.find( - (d) => - isDecoratorNamed(d, 'ObjectMetadata') || - isDecoratorNamed(d, 'Object'), - ); - if (objectDec) { - const cfg = getFirstArgObject(objectDec); - if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) { - const fields: Array> = []; - - for (const member of node.members) { - if (!isPropertyDeclaration(member)) { - continue; - } - - const fieldDec = getDecorators(member)?.find( - (d) => - isDecoratorNamed(d, 'FieldManifest') || - isDecoratorNamed(d, 'Field'), - ); - - if (!fieldDec) { - continue; - } - - const fieldCfg = getFirstArgObject(fieldDec); - - if (!fieldCfg) { - continue; - } - - // Try to attach the TypeScript property name as "name" - let name: string | undefined; - if (member.name && isIdentifier(member.name)) { - name = member.name.text; - } else { - // fallback to AST text if not a simple identifier - name = member.name?.getText?.() ?? undefined; - } - - fields.push({ - ...(fieldCfg as any), - ...(name ? { name } : {}), - }); - } - manifest.push({ ...(cfg as any), fields } as ObjectManifest); - } - } - } - forEachChild(node, visit); - }; - - visit(sf); - } - - return manifest; -}; - -// Add if you want a small guard for "export" presence on statements -const hasExportModifier = (st: any) => - st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ?? - false; +import { type Sources } from 'twenty-shared/types'; +import { type FunctionConfig } from '@/application/functions/function-config'; +import { type RoleConfig } from '@/application/role-config'; +import { loadConfig, loadFunctionModule } from './config-loader'; +import { findPathFile } from './find-path-file'; +import { parseJsoncFile, parseTextFile } from './jsonc-parser'; +import { + validateManifest, + ManifestValidationError, + type ValidationWarning, +} from './validate-manifest'; /** - * Finds (and validates) the new serverless file shape: - * - exactly 2 exported bindings - * - one must be `config` (typed FunctionConfig) - * - the other must be a function (exported function declaration, or const initialized with arrow/function expression) + * Validate that the required folder structure exists. */ -const findHandlerAndConfig = ( - sf: SourceFile, -): { - handlerName: ServerlessFunctionManifest['handlerName']; - configObject: Pick< - ServerlessFunctionManifest, - | 'universalIdentifier' - | 'name' - | 'description' - | 'timeoutSeconds' - | 'triggers' - >; -} => { - type Exported = { - name: string; - kind: 'function' | 'const'; - init?: Expression; - declNode: Node; - }; +const validateFolderStructure = async (appPath: string): Promise => { + const appFolder = path.join(appPath, 'src', 'app'); - const exported: Exported[] = []; - - // 1) export const X = - for (const st of sf.statements) { - if (!isVariableStatement(st) || !hasExportModifier(st)) continue; - - for (const decl of st.declarationList.declarations) { - if (!isIdentifier(decl.name)) continue; - - const name = decl.name.text; - const init = decl.initializer ?? undefined; - - exported.push({ - name, - kind: 'const', - init, - declNode: decl, - }); - } - } - - // 2) export function X() { ... } - for (const st of sf.statements) { - if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) { - const fd = st as FunctionDeclaration; - if (fd.name && isIdentifier(fd.name)) { - exported.push({ - name: fd.name.text, - kind: 'function', - init: undefined, - declNode: fd, - }); - } - } - } - - // Enforce exactly two exports - const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values()); - if (unique.length !== 2) { + if (!(await fs.pathExists(appFolder))) { throw new Error( - `Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`, + `Missing src/app/ folder in ${appPath}.\n` + + 'Create it with: mkdir -p src/app', ); } - // Find config - const configExport = unique.find((e) => e.name === 'config'); - if (!configExport) { - throw new Error( - `Serverless file ${sf.fileName} must export a binding named "config".`, - ); + const configFile = path.join(appPath, 'src', 'app', 'application.config.ts'); + if (!(await fs.pathExists(configFile))) { + throw new Error('Missing src/app/application.config.ts'); } - // Must be initialized to an object literal - if (!configExport.init || !isObjectLiteralExpression(configExport.init)) { - throw new Error( - `"config" in ${sf.fileName} must be initialized to an object literal.`, - ); - } - // (Light) type guard: ensure declared type mentions FunctionConfig if present - const maybeVarDecl = configExport.declNode as VariableDeclaration; - if ('type' in maybeVarDecl && maybeVarDecl.type) { - const typeText = maybeVarDecl.type.getText(sf); - if (!/\bFunctionConfig\b/.test(typeText)) { - throw new Error( - `"config" in ${sf.fileName} must be typed as FunctionConfig (got: ${typeText}).`, - ); - } - } - - const configObject = exprToValue(configExport.init) as Pick< - ServerlessFunctionManifest, - | 'universalIdentifier' - | 'name' - | 'description' - | 'timeoutSeconds' - | 'triggers' - >; - - // Identify the handler: the other export - const handlerExport = unique.find((e) => e.name !== 'config'); - if (!handlerExport) { - throw new Error(`Could not find the handler export in ${sf.fileName}.`); - } - - // If it's a const, make sure it’s a function-ish initializer - if (handlerExport.kind === 'const') { - const init = handlerExport.init; - const isFuncLike = - !!init && (isArrowFunction(init) || isFunctionExpression(init)); - if (!isFuncLike) { - throw new Error( - `Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`, - ); - } - } - - return { - handlerName: handlerExport.name, - configObject, - }; }; -const posixRelativeFromCwd = (fileName: string, appPath: string) => { - const rel = relative(appPath, fileName); - // normalize to posix separators for portability / manifest stability +/** + * Convert a file path to posix format relative to appPath. + */ +const toPosixRelative = (filepath: string, appPath: string): string => { + const rel = relative(appPath, filepath); return rel.split(sep).join(posix.sep); }; -const collectServerlessFunctions = (program: Program, appPath: string) => { - const serverlessFunctions: ServerlessFunctionManifest[] = []; +const loadFiles = async ( + patterns: string[], + cwd: string, +): Promise => { + return glob(patterns, { + cwd, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); +}; - for (const sf of program.getSourceFiles()) { - if (sf.isDeclarationFile) continue; +/** + * Load all object definitions from src/app/ (any *.object.ts file). + */ +const loadObjects = async (appPath: string): Promise => { + const objectFiles = await loadFiles(['src/app/**/*.object.ts'], appPath); + const objects: ObjectManifest[] = []; + + for (const filepath of objectFiles) { try { - const { handlerName, configObject } = findHandlerAndConfig(sf); + const manifest = await loadConfig(filepath); - const handlerPath = posixRelativeFromCwd(sf.fileName, appPath); + objects.push(manifest); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } - serverlessFunctions.push({ - ...configObject, + return objects; +}; + +/** + * Load all function definitions from src/app/ (any *.function.ts file). + */ +const loadFunctions = async ( + appPath: string, +): Promise => { + const functionFiles = await loadFiles(['src/app/**/*.function.ts'], appPath); + + const functions: ServerlessFunctionManifest[] = []; + + for (const filepath of functionFiles) { + try { + const { config, handlerName, handlerPath } = await loadFunctionModule( + filepath, + appPath, + ); + const fnConfig = config as FunctionConfig; + + const manifest: ServerlessFunctionManifest = { + universalIdentifier: fnConfig.universalIdentifier, + name: fnConfig.name, + description: fnConfig.description, + timeoutSeconds: fnConfig.timeoutSeconds, + triggers: fnConfig.triggers ?? [], handlerPath, handlerName, - }); - } catch { - // Not a serverless file under the new format — ignore and continue scanning. - continue; + }; + + functions.push(manifest); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); } } - return serverlessFunctions; + return functions; }; -const setNested = (root: Sources, parts: string[], value: string) => { - let cur: Sources = root; - for (let i = 0; i < parts.length; i++) { - const key = parts[i]; - if (i === parts.length - 1) { - cur[key] = value; - } else { - cur[key] = (cur[key] ?? {}) as Sources; - cur = cur[key] as Sources; - } - } -}; +/** + * Load all role definitions from src/app/ (any *.role.ts file). + */ +const loadRoles = async (appPath: string): Promise => { + const roleFiles = await loadFiles(['src/app/**/*.role.ts'], appPath); -const loadFolderContentIntoJson = async ( - program: Program, - appPath: string, -): Promise => { - const sources: Sources = {}; + const roles: RoleManifest[] = []; - // Iterate only files the TS program knows about. - for (const sf of program.getSourceFiles()) { - const abs = sf.fileName; - - // Skip .d.ts and anything outside sourcePath - if (sf.isDeclarationFile) continue; - if (!abs.startsWith(appPath + sep) && abs !== appPath) continue; - - // Keep only TS/TSX files - if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue; - - // Optional extra guard (usually unnecessary if tsconfig excludes node_modules) - if (abs.includes(`${sep}node_modules${sep}`)) continue; - - const relFromRoot = relative(appPath, abs); - const parts = relFromRoot.split(sep); - - const content = await fs.readFile(abs, 'utf8'); - setNested(sources, parts, content); - } - - return sources; -}; - -export const extractTwentyAppConfig = (program: Program): Application => { - for (const sf of program.getSourceFiles()) { - if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts')) - continue; - - let found: Application | undefined; - - const visit = (node: any): void => { - // Look for "export default twentyAppConfig" - if (isExportAssignment(node) && isIdentifier(node.expression)) { - const varName = node.expression.text; - - // find the corresponding variable declaration - for (const stmt of sf.statements) { - if (isVariableStatement(stmt)) { - for (const decl of stmt.declarationList.declarations) { - if (isIdentifier(decl.name) && decl.name.text === varName) { - if ( - decl.initializer && - isObjectLiteralExpression(decl.initializer) - ) { - found = exprToValue( - decl.initializer, - ) as unknown as Application; - } - } - } - } - } - } - if (!found) forEachChild(node, visit); - }; - - visit(sf); - - if (found) return found; - } - - throw new Error('Could not find default exported ApplicationConfig'); -}; - -const isGeneratedModuleUsedInProgram = (program: Program): boolean => { - for (const sf of program.getSourceFiles()) { - if (sf.isDeclarationFile) continue; - - let found = false; - - const visit = (node: Node): void => { - if (found) return; - - if (isImportDeclaration(node)) { - const moduleSpecifier = node.moduleSpecifier; - - if (isStringLiteralLike(moduleSpecifier)) { - const moduleText = moduleSpecifier.text; - - // Match ../../generated, ../generated, ./foo/generated, etc. - const isGeneratedModule = - moduleText === GENERATED_FOLDER_NAME || - moduleText.endsWith(`/${GENERATED_FOLDER_NAME}`); - - if (isGeneratedModule && node.importClause) { - found = true; - return; - } - } - } - - forEachChild(node, visit); - }; - - visit(sf); - - if (found) return true; - } - - return false; -}; - -export const collectRoles = (program: Program): Array => { - const roles: Array = []; - - for (const sf of program.getSourceFiles()) { - if (sf.isDeclarationFile) continue; - - for (const st of sf.statements) { - if (!isVariableStatement(st)) continue; - - // must be "export const ..." - const isExported = - st.modifiers?.some((m) => m.kind === SyntaxKind.ExportKeyword) ?? false; - if (!isExported) continue; - - for (const decl of st.declarationList.declarations) { - if (!isIdentifier(decl.name)) continue; - - // must be typed RoleConfig (matches: RoleConfig, foo.RoleConfig, import type RoleConfig, etc.) - const typeText = decl.type?.getText(sf) ?? ''; - if (!typeText.includes('RoleConfig')) continue; - - // must be "= { ... }" - const init = decl.initializer; - if (!init || !isObjectLiteralExpression(init)) continue; - - roles.push(exprToValue(init) as unknown as RoleManifest); - } + for (const filepath of roleFiles) { + try { + const config = await loadConfig(filepath); + roles.push(config); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); } } return roles; }; -export const loadManifest = async ( - appPath: string, -): Promise<{ +/** + * Build a nested object structure from all TypeScript source files. + */ +const loadSources = async (appPath: string): Promise => { + const sources: Sources = {}; + + // Get all TypeScript files in src/ folder + const tsFiles = await loadFiles(['src/**/*.ts'], appPath); + + for (const filepath of tsFiles) { + const relPath = relative(appPath, filepath); + const parts = relPath.split(sep); + const content = await fs.readFile(filepath, 'utf8'); + + // Build nested structure + let current: Sources = sources; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + current[part] = content; + } else { + current[part] = (current[part] ?? {}) as Sources; + current = current[part] as Sources; + } + } + } + + return sources; +}; + +/** + * Check if the app imports from the generated folder. + * Detects any `import ... from '...generated'` or `import ... from '...generated/...'` pattern. + */ +const checkShouldGenerate = async (appPath: string): Promise => { + const tsFiles = await loadFiles(['src/**/*.ts'], appPath); + + // Matches: import ... from 'generated' or from '.../generated' or from '.../generated/...' + const generatedImportPattern = + /from\s+['"][^'"]*\/generated(?:\/[^'"]*)?['"]|from\s+['"]generated['"]/; + + for (const filepath of tsFiles) { + const content = await fs.readFile(filepath, 'utf8'); + + if (generatedImportPattern.test(content)) { + return true; + } + } + + return false; +}; + +export type LoadManifestResult = { packageJson: PackageJson; yarnLock: string; manifest: ApplicationManifest; shouldGenerate: boolean; -}> => { + warnings: ValidationWarning[]; +}; + +/** + * Load an application manifest using the folder structure with jiti runtime evaluation. + * + * Files are detected by their suffix (*.object.ts, *.function.ts, *.role.ts) + * and can be placed anywhere within src/app/. + * + * Example structures: + * ``` + * # Traditional (by type) + * my-app/ + * ├── src/ + * │ └── app/ + * │ ├── application.config.ts + * │ ├── objects/ + * │ │ └── postCard.object.ts + * │ ├── functions/ + * │ │ └── createPostCard.function.ts + * │ └── roles/ + * │ └── admin.role.ts + * + * # Feature-based + * my-app/ + * ├── src/ + * │ └── app/ + * │ ├── application.config.ts + * │ └── post-card/ + * │ ├── postCard.object.ts + * │ ├── createPostCard.function.ts + * │ └── postCardAdmin.role.ts + * ``` + */ +export const loadManifest = async ( + appPath: string, +): Promise => { + // Validate folder structure + await validateFolderStructure(appPath); + + // Load package.json and yarn.lock const packageJson = await parseJsoncFile( await findPathFile(appPath, 'package.json'), ); @@ -536,34 +252,51 @@ export const loadManifest = async ( await findPathFile(appPath, 'yarn.lock'), ); - const { diagnostics, program } = await getTsProgramAndDiagnostics({ + // Load application config + const applicationConfigPath = path.join( appPath, + 'src', + 'app', + 'application.config.ts', + ); + const application = await loadConfig(applicationConfigPath); + + // Load all entities in parallel + const [objects, serverlessFunctions, roles, sources, shouldGenerate] = + await Promise.all([ + loadObjects(appPath), + loadFunctions(appPath), + loadRoles(appPath), + loadSources(appPath), + checkShouldGenerate(appPath), + ]); + + // Build manifest + const manifest: ApplicationManifest = { + application, + objects, + serverlessFunctions, + roles, + sources, + }; + + // Validate manifest + const validation = validateManifest({ + application, + objects, + serverlessFunctions, + roles, }); - formatAndWarnTsDiagnostics({ - diagnostics, - }); - - const [objects, serverlessFunctions, application, roles, sources] = [ - collectObjects(program), - collectServerlessFunctions(program, appPath), - extractTwentyAppConfig(program), - collectRoles(program), - await loadFolderContentIntoJson(program, appPath), - ]; - - const shouldGenerate = isGeneratedModuleUsedInProgram(program); + if (!validation.isValid) { + throw new ManifestValidationError(validation.errors); + } return { packageJson, yarnLock, - manifest: { - application, - objects, - serverlessFunctions, - roles, - sources, - }, + manifest, shouldGenerate, + warnings: validation.warnings, }; }; diff --git a/packages/twenty-sdk/src/cli/utils/validate-manifest.ts b/packages/twenty-sdk/src/cli/utils/validate-manifest.ts new file mode 100644 index 00000000000..460b8da561c --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/validate-manifest.ts @@ -0,0 +1,384 @@ +import { + type ApplicationManifest, + type ServerlessFunctionManifest, + type ObjectManifest, + type RoleManifest, + type Application, +} from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +export type ValidationError = { + path: string; + message: string; +}; + +export type ValidationWarning = { + path?: string; + message: string; +}; + +export type ValidationResult = { + isValid: boolean; + errors: ValidationError[]; + warnings: ValidationWarning[]; +}; + +export class ManifestValidationError extends Error { + constructor(public readonly errors: ValidationError[]) { + const messages = errors.map((e) => ` • ${e.path}: ${e.message}`).join('\n'); + super(`Manifest validation failed:\n${messages}`); + this.name = 'ManifestValidationError'; + } +} + +/** + * Collect all universalIdentifiers from the manifest for duplicate checking. + */ +const collectAllIds = ( + manifest: Omit, +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + // Application + if (manifest.application?.universalIdentifier) { + ids.push({ + id: manifest.application.universalIdentifier, + location: 'application', + }); + } + + // Application variables + if (manifest.application?.applicationVariables) { + for (const [name, variable] of Object.entries( + manifest.application.applicationVariables, + )) { + if (variable.universalIdentifier) { + ids.push({ + id: variable.universalIdentifier, + location: `application.variables.${name}`, + }); + } + } + } + + // Objects + for (const obj of manifest.objects ?? []) { + if (obj.universalIdentifier) { + ids.push({ + id: obj.universalIdentifier, + location: `objects/${obj.nameSingular}`, + }); + } + // Object fields + for (const field of obj.fields ?? []) { + if (field.universalIdentifier) { + ids.push({ + id: field.universalIdentifier, + location: `objects/${obj.nameSingular}.fields.${field.label}`, + }); + } + } + } + + // Functions + for (const fn of manifest.serverlessFunctions ?? []) { + if (fn.universalIdentifier) { + ids.push({ + id: fn.universalIdentifier, + location: `functions/${fn.name ?? fn.handlerName}`, + }); + } + // Function triggers + for (const trigger of fn.triggers ?? []) { + if (trigger.universalIdentifier) { + ids.push({ + id: trigger.universalIdentifier, + location: `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`, + }); + } + } + } + + // Roles + for (const role of manifest.roles ?? []) { + if (role.universalIdentifier) { + ids.push({ + id: role.universalIdentifier, + location: `roles/${role.label}`, + }); + } + } + + return ids; +}; + +/** + * Find duplicate universalIdentifiers. + */ +const findDuplicates = ( + ids: Array<{ id: string; location: string }>, +): Array<{ id: string; locations: string[] }> => { + const seen = new Map(); + + for (const { id, location } of ids) { + const locations = seen.get(id) ?? []; + locations.push(location); + seen.set(id, locations); + } + + return Array.from(seen.entries()) + .filter(([_, locations]) => locations.length > 1) + .map(([id, locations]) => ({ id, locations })); +}; + +/** + * Validate an application config. + */ +const validateApplication = ( + application: Application | undefined, + errors: ValidationError[], +): void => { + if (!application) { + errors.push({ + path: 'application', + message: 'Application config is required', + }); + return; + } + + if (!application.universalIdentifier) { + errors.push({ + path: 'application', + message: 'Application must have a universalIdentifier', + }); + } +}; + +/** + * Validate objects and their fields. + */ +const validateObjects = ( + objects: ObjectManifest[], + errors: ValidationError[], +): void => { + for (const obj of objects) { + const objPath = `objects/${obj.nameSingular ?? 'unknown'}`; + + if (!obj.universalIdentifier) { + errors.push({ + path: objPath, + message: 'Object must have a universalIdentifier', + }); + } + + if (!obj.nameSingular) { + errors.push({ + path: objPath, + message: 'Object must have a nameSingular', + }); + } + + if (!obj.namePlural) { + errors.push({ + path: objPath, + message: 'Object must have a namePlural', + }); + } + + // Validate fields + for (const field of obj.fields ?? []) { + const fieldPath = `${objPath}.fields.${field.label ?? 'unknown'}`; + + if (!field.universalIdentifier) { + errors.push({ + path: fieldPath, + message: 'Field must have a universalIdentifier', + }); + } + + if (!field.type) { + errors.push({ + path: fieldPath, + message: 'Field must have a type', + }); + } + + if (!field.label) { + errors.push({ + path: fieldPath, + message: 'Field must have a label', + }); + } + + // Check SELECT/MULTI_SELECT fields have options + if ( + (field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT) && + (!field.options || (field.options as unknown[]).length === 0) + ) { + errors.push({ + path: fieldPath, + message: 'SELECT/MULTI_SELECT field must have options', + }); + } + } + } +}; + +/** + * Validate serverless functions. + */ +const validateFunctions = ( + functions: ServerlessFunctionManifest[], + errors: ValidationError[], +): void => { + for (const fn of functions) { + const fnPath = `functions/${fn.name ?? fn.handlerName ?? 'unknown'}`; + + if (!fn.universalIdentifier) { + errors.push({ + path: fnPath, + message: 'Function must have a universalIdentifier', + }); + } + + if (!fn.triggers || fn.triggers.length === 0) { + errors.push({ + path: fnPath, + message: 'Function must have at least one trigger', + }); + } + + // Validate triggers + for (const trigger of fn.triggers ?? []) { + const triggerPath = `${fnPath}.triggers.${trigger.type ?? 'unknown'}`; + + if (!trigger.universalIdentifier) { + errors.push({ + path: triggerPath, + message: 'Trigger must have a universalIdentifier', + }); + } + + if (!trigger.type) { + errors.push({ + path: triggerPath, + message: 'Trigger must have a type', + }); + continue; + } + + switch (trigger.type) { + case 'route': + if (!trigger.path) { + errors.push({ + path: triggerPath, + message: 'Route trigger must have a path', + }); + } + if (!trigger.httpMethod) { + errors.push({ + path: triggerPath, + message: 'Route trigger must have an httpMethod', + }); + } + break; + + case 'cron': + if (!trigger.pattern) { + errors.push({ + path: triggerPath, + message: 'Cron trigger must have a pattern', + }); + } + break; + + case 'databaseEvent': + if (!trigger.eventName) { + errors.push({ + path: triggerPath, + message: 'Database event trigger must have an eventName', + }); + } + break; + } + } + } +}; + +/** + * Validate roles. + */ +const validateRoles = ( + roles: RoleManifest[], + errors: ValidationError[], +): void => { + for (const role of roles) { + const rolePath = `roles/${role.label ?? 'unknown'}`; + + if (!role.universalIdentifier) { + errors.push({ + path: rolePath, + message: 'Role must have a universalIdentifier', + }); + } + + if (!role.label) { + errors.push({ + path: rolePath, + message: 'Role must have a label', + }); + } + } +}; + +/** + * Validate a complete application manifest. + */ +export const validateManifest = ( + manifest: Omit, +): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + // Validate application + validateApplication(manifest.application, errors); + + // Validate objects + validateObjects(manifest.objects ?? [], errors); + + // Validate functions + validateFunctions(manifest.serverlessFunctions ?? [], errors); + + // Validate roles + validateRoles(manifest.roles ?? [], errors); + + // Check for duplicate universalIdentifiers + const allIds = collectAllIds(manifest); + const duplicates = findDuplicates(allIds); + for (const dup of duplicates) { + errors.push({ + path: dup.locations.join(', '), + message: `Duplicate universalIdentifier: ${dup.id}`, + }); + } + + // Warnings + if (!manifest.objects || manifest.objects.length === 0) { + warnings.push({ + message: 'No objects defined in src/app/objects/', + }); + } + + if (!manifest.serverlessFunctions || manifest.serverlessFunctions.length === 0) { + warnings.push({ + message: 'No functions defined in src/app/functions/', + }); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +}; diff --git a/packages/twenty-sdk/vite.config.ts b/packages/twenty-sdk/vite.config.ts index ce68a55be5f..26194f56fb6 100644 --- a/packages/twenty-sdk/vite.config.ts +++ b/packages/twenty-sdk/vite.config.ts @@ -89,6 +89,7 @@ export default defineConfig(() => { ...Object.keys((packageJson as any).dependencies || {}), 'path', 'fs', + 'url', 'crypto', 'stream', 'util', diff --git a/packages/twenty-shared/src/application/fieldManifestType.ts b/packages/twenty-shared/src/application/fieldManifestType.ts index 00c4f2eb03c..4151d3fbf04 100644 --- a/packages/twenty-shared/src/application/fieldManifestType.ts +++ b/packages/twenty-shared/src/application/fieldManifestType.ts @@ -13,6 +13,7 @@ export type FieldManifest< >, > = SyncableEntityOptions & { type: T; + name: string; label: string; description?: string; icon?: string; diff --git a/packages/twenty-shared/src/application/objectManifestType.ts b/packages/twenty-shared/src/application/objectManifestType.ts index 2bffd0d426f..467880130f1 100644 --- a/packages/twenty-shared/src/application/objectManifestType.ts +++ b/packages/twenty-shared/src/application/objectManifestType.ts @@ -8,5 +8,5 @@ export type ObjectManifest = SyncableEntityOptions & { labelPlural: string; description?: string; icon?: string; - fields?: FieldManifest[]; + fields: FieldManifest[]; }; diff --git a/yarn.lock b/yarn.lock index 16bb5c6aae2..cf7107bb645 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35924,7 +35924,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.0.3": +"fast-glob@npm:^3.0.3, fast-glob@npm:^3.3.0": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -41815,6 +41815,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.0.0": + version: 2.6.1 + resolution: "jiti@npm:2.6.1" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b + languageName: node + linkType: hard + "jju@npm:~1.4.0": version: 1.4.0 resolution: "jju@npm:1.4.0" @@ -56879,11 +56888,13 @@ __metadata: chokidar: "npm:^4.0.0" commander: "npm:^12.0.0" dotenv: "npm:^16.4.0" + fast-glob: "npm:^3.3.0" fs-extra: "npm:^11.2.0" graphql: "npm:^16.8.1" graphql-sse: "npm:^2.5.4" inquirer: "npm:^10.0.0" jest: "npm:^29.5.0" + jiti: "npm:^2.0.0" jsonc-parser: "npm:^3.2.0" lodash.camelcase: "npm:^4.3.0" lodash.capitalize: "npm:^4.2.1"