diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts index afca2b08bc..b412423b9a 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts @@ -1,15 +1,19 @@ -import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util'; +import * as fs from 'fs-extra'; import { join } from 'path'; const APP_PATH = join(__dirname, '..'); +const MANIFEST_OUTPUT_PATH = join(APP_PATH, '.twenty/output/manifest.json'); describe('invalid-app manifest', () => { it('should fail to build manifest due to duplicate universalIdentifier', async () => { - const manifest = await runManifestBuild(APP_PATH, { - display: false, - writeOutput: false, - }); + const result = await runAppDev({ appPath: APP_PATH, timeout: 10000 }); - expect(manifest).toBeNull(); - }); + expect(result.success).toBe(false); + expect(result.output).toContain('Duplicate universalIdentifier'); + + const manifestExists = await fs.pathExists(MANIFEST_OUTPUT_PATH); + + expect(manifestExists).toBe(false); + }, 30000); }); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/src/application.config.ts b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/application.config.ts similarity index 78% rename from packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/src/application.config.ts rename to packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/application.config.ts index 66eff15155..90377a673b 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/src/application.config.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/application.config.ts @@ -5,4 +5,5 @@ export default defineApp({ displayName: 'Invalid App', description: 'An app with duplicate IDs for testing validation', icon: 'IconAlertTriangle', + functionRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002', }); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/tsconfig.json b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/tsconfig.json index a5f8f2a8c8..a86682adf1 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/tsconfig.json +++ b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/tsconfig.json @@ -12,6 +12,6 @@ "@/*": ["../../../../../src/*"] } }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["**/*"], + "exclude": ["node_modules", "dist", ".twenty"] } diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap new file mode 100644 index 0000000000..8d06c0e56b --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/__snapshots__/app-dev.integration.spec.ts.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`rich-app app:dev > console output > should match expected output 1`] = ` +"👩‍💻 Workspace - default +🚀 Starting Twenty Application Development Mode +📁 App Path: /root.function.ts) + - greeting-function (src/functions/greeting.function.ts) + - test-function-2 (src/utils/test-function-2.util.ts) + - test-function (src/functions/test-function.function.ts) + ✓ Found 4 front component(s) + 📍 Front component entry points: + - root-component (src/root.front-component.tsx) + - card-component (src/components/card.front-component.tsx) + - greeting-component (src/components/greeting.front-component.tsx) + - test-component (src/components/test.front-component.tsx) + ✓ Found 2 role(s) + ✓ Manifest written to /.twenty/output/manifest.json + 📂 Manifest watcher started + 📦 Building functions... + 🎨 Building front components... + ✓ Functions built + +👀 Watching for changes... (Press Ctrl+C to stop) + ✓ Front components built" +`; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts new file mode 100644 index 0000000000..aa5a3f33b7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/app-dev.integration.spec.ts @@ -0,0 +1,25 @@ +import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util'; +import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; +import { join } from 'path'; + +import { defineConsoleOutputTests } from './tests/console-output.tests'; +import { defineFrontComponentsTests } from './tests/front-components.tests'; +import { defineFunctionsTests } from './tests/functions.tests'; +import { defineManifestTests } from './tests/manifest.tests'; + +const APP_PATH = join(__dirname, '../..'); + +describe('rich-app app:dev', () => { + let result: RunCliCommandResult; + + beforeAll(async () => { + result = await runAppDev({ appPath: APP_PATH }); + + expect(result.success).toBe(true); + }, 60000); + + defineConsoleOutputTests(() => result); + defineManifestTests(APP_PATH); + defineFunctionsTests(APP_PATH); + defineFrontComponentsTests(APP_PATH); +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/manifest.expected.json b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json similarity index 93% rename from packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/manifest.expected.json rename to packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json index 98a5e54f19..3b56e51974 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/manifest.expected.json +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json @@ -24,7 +24,7 @@ }, { "componentName": "CardDisplay", - "componentPath": "src/utils/card-display.component.tsx", + "componentPath": "src/components/card.front-component.tsx", "description": "A component using an external component file", "name": "card-component", "universalIdentifier": "i0i1i2i3-i4i5-4000-8000-000000000001" @@ -189,7 +189,29 @@ } ], "packageJson": { - "name": "rich-app" + "name": "rich-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" + } }, "roles": [ { diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts new file mode 100644 index 0000000000..280a015578 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/console-output.tests.ts @@ -0,0 +1,14 @@ +import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; +import { sanitizeOutput } from '@/cli/__tests__/integration/utils/sanitize-output.util'; + +export const defineConsoleOutputTests = ( + getResult: () => RunCliCommandResult, +): void => { + describe('console output', () => { + it('should match expected output', () => { + const result = getResult(); + + expect(sanitizeOutput(result.output)).toMatchSnapshot(); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/front-components.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/front-components.tests.ts new file mode 100644 index 0000000000..8e5510c5d5 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/front-components.tests.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; + +export const defineFrontComponentsTests = (appPath: string): void => { + describe('front-components', () => { + it('should have built front components preserving source path structure', async () => { + const frontComponentsDir = join(appPath, '.twenty/output/front-components'); + const files = await fs.readdir(frontComponentsDir, { recursive: true }); + const sortedFiles = files.map((f) => f.toString()).sort(); + + expect(sortedFiles).toEqual([ + 'src', + 'src/components', + 'src/components/card.front-component.mjs', + 'src/components/card.front-component.mjs.map', + 'src/components/greeting.front-component.mjs', + 'src/components/greeting.front-component.mjs.map', + 'src/components/test.front-component.mjs', + 'src/components/test.front-component.mjs.map', + 'src/root.front-component.mjs', + 'src/root.front-component.mjs.map', + ]); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts new file mode 100644 index 0000000000..d1de205593 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; + +export const defineFunctionsTests = (appPath: string): void => { + describe('functions', () => { + it('should have built functions preserving source path structure', async () => { + const functionsDir = join(appPath, '.twenty/output/functions'); + const files = await fs.readdir(functionsDir, { recursive: true }); + const sortedFiles = files.map((f) => f.toString()).sort(); + + expect(sortedFiles).toEqual([ + 'src', + 'src/functions', + 'src/functions/greeting.function.mjs', + 'src/functions/greeting.function.mjs.map', + 'src/functions/test-function-2.function.mjs', + 'src/functions/test-function-2.function.mjs.map', + 'src/functions/test-function.function.mjs', + 'src/functions/test-function.function.mjs.map', + 'src/root.function.mjs', + 'src/root.function.mjs.map', + ]); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts new file mode 100644 index 0000000000..ee5e19c007 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; + +import expectedManifest from '../manifest.expected.json'; + +export const defineManifestTests = (appPath: string): void => { + const manifestOutputPath = join(appPath, '.twenty/output/manifest.json'); + + describe('manifest', () => { + it('should build manifest matching expected JSON', async () => { + const manifest = await fs.readJson(manifestOutputPath); + + expect(manifest).not.toBeNull(); + + const { sources: _sources, ...sanitizedManifest } = manifest; + + expect(sanitizedManifest).toEqual(expectedManifest); + }); + + it('should have correct application config', async () => { + const manifest = await fs.readJson(manifestOutputPath); + + expect(manifest?.application.displayName).toBe('Hello World'); + expect(manifest?.application.description).toBe('A simple hello world app'); + }); + + it('should load all entity types', async () => { + const manifest = await fs.readJson(manifestOutputPath); + + expect(manifest?.objects).toHaveLength(2); + expect(manifest?.serverlessFunctions).toHaveLength(4); + expect(manifest?.frontComponents).toHaveLength(4); + expect(manifest?.roles).toHaveLength(2); + expect(manifest?.objectExtensions).toHaveLength(1); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/manifest.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/manifest.integration.spec.ts deleted file mode 100644 index 2574f40e7d..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/manifest.integration.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; -import { join } from 'path'; - -import expectedManifest from './manifest.expected.json'; - -const APP_PATH = join(__dirname, '..'); - -describe('rich-app manifest', () => { - it('should build manifest matching expected JSON', async () => { - const manifest = await runManifestBuild(APP_PATH, { - display: false, - writeOutput: false, - }); - - expect(manifest).not.toBeNull(); - - const { sources: _sources, ...sanitizedManifest } = { - ...manifest, - packageJson: { - name: manifest!.packageJson.name, - }, - }; - - expect(sanitizedManifest).toEqual(expectedManifest); - }); - - it('should have correct application config', async () => { - const manifest = await runManifestBuild(APP_PATH, { - display: false, - writeOutput: false, - }); - - expect(manifest?.application.displayName).toBe('Hello World'); - expect(manifest?.application.description).toBe('A simple hello world app'); - }); - - it('should load all entity types', async () => { - const manifest = await runManifestBuild(APP_PATH, { - display: false, - writeOutput: false, - }); - - expect(manifest?.objects).toHaveLength(2); - expect(manifest?.serverlessFunctions).toHaveLength(4); - expect(manifest?.frontComponents).toHaveLength(4); - expect(manifest?.roles).toHaveLength(2); - expect(manifest?.objectExtensions).toHaveLength(1); - }); -}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/application.config.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/application.config.ts similarity index 85% rename from packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/application.config.ts rename to packages/twenty-sdk/src/cli/__tests__/apps/rich-app/application.config.ts index f23dd3eee9..a5dfbbc632 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/application.config.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/application.config.ts @@ -1,5 +1,5 @@ import { defineApp } from '@/application/define-app'; -import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role'; +import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './src/roles/default-function.role'; export default defineApp({ universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/tsconfig.json b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/tsconfig.json index 4ac1ff68c6..37e7e2155a 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/tsconfig.json +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/tsconfig.json @@ -12,5 +12,6 @@ "@/*": ["../../../../../src/*"] } }, - "include": ["src/**/*"] + "include": ["**/*"], + "exclude": ["node_modules", "dist", ".twenty"] } diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts new file mode 100644 index 0000000000..afcd75fec7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/app-dev.integration.spec.ts @@ -0,0 +1,25 @@ +import { join } from 'path'; + +import { runAppDev } from '../../../../integration/utils/run-app-dev.util'; +import { type RunCliCommandResult } from '../../../../integration/utils/run-cli-command.util'; +import { defineConsoleOutputTests } from './tests/console-output.tests'; +import { defineFrontComponentsTests } from './tests/front-components.tests'; +import { defineFunctionsTests } from './tests/functions.tests'; +import { defineManifestTests } from './tests/manifest.tests'; + +const APP_PATH = join(__dirname, '../..'); + +describe('root-app app:dev', () => { + let result: RunCliCommandResult; + + beforeAll(async () => { + result = await runAppDev({ appPath: APP_PATH }); + + expect(result.success).toBe(true); + }, 60000); + + defineConsoleOutputTests(() => result); + defineManifestTests(APP_PATH); + defineFunctionsTests(APP_PATH); + defineFrontComponentsTests(APP_PATH); +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/manifest.expected.json b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/manifest.expected.json new file mode 100644 index 0000000000..43812efbd9 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/manifest.expected.json @@ -0,0 +1,70 @@ +{ + "application": { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000001", + "displayName": "Root App", + "description": "An app with all entities at root level", + "icon": "IconFolder", + "functionRoleUniversalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000002" + }, + "objects": [ + { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000030", + "nameSingular": "myNote", + "namePlural": "myNotes", + "labelSingular": "My note", + "labelPlural": "My notes", + "description": "A simple root-level object", + "icon": "IconNote", + "fields": [ + { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000031", + "type": "TEXT", + "label": "Title", + "name": "title" + } + ] + } + ], + "serverlessFunctions": [ + { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000010", + "name": "my-function", + "timeoutSeconds": 5, + "triggers": [ + { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000011", + "type": "route", + "path": "/my-function", + "httpMethod": "GET", + "isAuthRequired": false + } + ], + "handlerName": "myHandler", + "handlerPath": "my.function.ts" + } + ], + "frontComponents": [ + { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000020", + "name": "my-component", + "description": "A root-level front component", + "componentName": "MyComponent", + "componentPath": "my.front-component.tsx" + } + ], + "roles": [ + { + "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000040", + "label": "My role", + "description": "A simple root-level role", + "canReadAllObjectRecords": true, + "canUpdateAllObjectRecords": false, + "canSoftDeleteAllObjectRecords": false, + "canDestroyAllObjectRecords": false, + "canUpdateAllSettings": false, + "canBeAssignedToAgents": false, + "canBeAssignedToUsers": true, + "canBeAssignedToApiKeys": false + } + ] +} diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts new file mode 100644 index 0000000000..cc9bf71912 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/console-output.tests.ts @@ -0,0 +1,22 @@ +import { type RunCliCommandResult } from '../../../../../integration/utils/run-cli-command.util'; + +export const defineConsoleOutputTests = ( + getResult: () => RunCliCommandResult, +): void => { + describe('console output', () => { + it('should contain key messages', () => { + const result = getResult(); + const output = result.output; + + expect(output).toContain('Starting Twenty Application Development Mode'); + expect(output).toContain('Building manifest'); + expect(output).toContain('Loaded "Root App"'); + expect(output).toContain('Found 1 object(s)'); + expect(output).toContain('Found 1 function(s)'); + expect(output).toContain('Found 1 front component(s)'); + expect(output).toContain('Found 1 role(s)'); + expect(output).toContain('Manifest written to'); + expect(output).toContain('Functions built'); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts new file mode 100644 index 0000000000..87ad0038c8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts @@ -0,0 +1,17 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; + +export const defineFrontComponentsTests = (appPath: string): void => { + describe('front-components', () => { + it('should have built front components at root level', async () => { + const frontComponentsDir = join(appPath, '.twenty/output/front-components'); + const files = await fs.readdir(frontComponentsDir, { recursive: true }); + const sortedFiles = files.map((f) => f.toString()).sort(); + + expect(sortedFiles).toEqual([ + 'my.front-component.mjs', + 'my.front-component.mjs.map', + ]); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/functions.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/functions.tests.ts new file mode 100644 index 0000000000..55ae97d9ae --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/functions.tests.ts @@ -0,0 +1,17 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; + +export const defineFunctionsTests = (appPath: string): void => { + describe('functions', () => { + it('should have built functions at root level', async () => { + const functionsDir = join(appPath, '.twenty/output/functions'); + const files = await fs.readdir(functionsDir, { recursive: true }); + const sortedFiles = files.map((f) => f.toString()).sort(); + + expect(sortedFiles).toEqual([ + 'my.function.mjs', + 'my.function.mjs.map', + ]); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts new file mode 100644 index 0000000000..505cca9f1e --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts @@ -0,0 +1,27 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; +import { type ApplicationManifest } from 'twenty-shared/application'; + +export const defineManifestTests = (appPath: string): void => { + describe('manifest', () => { + it('should have generated manifest.json', async () => { + const manifestPath = join(appPath, '.twenty/output/manifest.json'); + const exists = await fs.pathExists(manifestPath); + + expect(exists).toBe(true); + }); + + it('should have correct manifest content', async () => { + const manifestPath = join(appPath, '.twenty/output/manifest.json'); + const manifest: ApplicationManifest = await fs.readJSON(manifestPath); + const expectedPath = join(appPath, '__integration__/app-dev/manifest.expected.json'); + const expected: ApplicationManifest = await fs.readJSON(expectedPath); + + expect(manifest.application).toEqual(expected.application); + expect(manifest.objects).toEqual(expected.objects); + expect(manifest.serverlessFunctions).toEqual(expected.serverlessFunctions); + expect(manifest.frontComponents).toEqual(expected.frontComponents); + expect(manifest.roles).toEqual(expected.roles); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/application.config.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/application.config.ts new file mode 100644 index 0000000000..6315b9e7de --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/application.config.ts @@ -0,0 +1,9 @@ +import { defineApp } from '@/application/define-app'; + +export default defineApp({ + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001', + displayName: 'Root App', + description: 'An app with all entities at root level', + icon: 'IconFolder', + functionRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002', +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.front-component.tsx b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.front-component.tsx new file mode 100644 index 0000000000..c35f5b8766 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.front-component.tsx @@ -0,0 +1,16 @@ +import { defineFrontComponent } from '@/application/front-components/define-front-component'; + +export const MyComponent = () => { + return ( +
+

My Component

+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000020', + name: 'my-component', + description: 'A root-level front component', + component: MyComponent, +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.function.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.function.ts new file mode 100644 index 0000000000..ba55ca945c --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.function.ts @@ -0,0 +1,21 @@ +import { defineFunction } from '@/application/functions/define-function'; + +const myHandler = () => { + return 'my-function-result'; +}; + +export default defineFunction({ + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000010', + name: 'my-function', + timeoutSeconds: 5, + handler: myHandler, + triggers: [ + { + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000011', + type: 'route', + path: '/my-function', + httpMethod: 'GET', + isAuthRequired: false, + }, + ], +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.object.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.object.ts new file mode 100644 index 0000000000..179de5bc07 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.object.ts @@ -0,0 +1,20 @@ +import { FieldType } from '@/application/fields/field-type'; +import { defineObject } from '@/application/objects/define-object'; + +export default defineObject({ + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000030', + nameSingular: 'myNote', + namePlural: 'myNotes', + labelSingular: 'My note', + labelPlural: 'My notes', + description: 'A simple root-level object', + icon: 'IconNote', + fields: [ + { + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000031', + type: FieldType.TEXT, + label: 'Title', + name: 'title', + }, + ], +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.role.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.role.ts new file mode 100644 index 0000000000..1c0c667027 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/my.role.ts @@ -0,0 +1,15 @@ +import { defineRole } from '@/application/roles/define-role'; + +export default defineRole({ + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040', + label: 'My role', + description: 'A simple root-level role', + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canBeAssignedToAgents: false, + canBeAssignedToUsers: true, + canBeAssignedToApiKeys: false, +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/package.json b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/package.json new file mode 100644 index 0000000000..87e15cf6f7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "root-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__/apps/root-app/tsconfig.json b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/tsconfig.json new file mode 100644 index 0000000000..b6520539e5 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["../../../../../src/*"] + } + }, + "include": ["**/*"] +} diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts new file mode 100644 index 0000000000..fce5389668 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev.util.ts @@ -0,0 +1,22 @@ +import { runCliCommand, type RunCliCommandResult } from './run-cli-command.util'; + +export type RunAppDevOptions = { + appPath: string; + timeout?: number; +}; + +export const runAppDev = (options: RunAppDevOptions): Promise => { + const { appPath, timeout = 30000 } = options; + + + return runCliCommand({ + command: 'app:dev', + args: [appPath], + waitForOutput: [ + '✓ Manifest written to', + '✓ Functions built', + '✓ Front components built', + ], + timeout, + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts new file mode 100644 index 0000000000..9a5fd1c82f --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-cli-command.util.ts @@ -0,0 +1,87 @@ +import { spawn, type ChildProcess } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// CLI path and working directory (twenty-sdk src directory) +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const CLI_DIR = path.resolve(__dirname, '../../../..'); +const CLI_PATH = path.resolve(CLI_DIR, 'cli/cli.ts'); + +export type RunCliCommandOptions = { + command: string; + args?: string[]; + waitForOutput?: string | string[]; + timeout?: number; +}; + +export type RunCliCommandResult = { + success: boolean; + output: string; +}; + +export const runCliCommand = ( + options: RunCliCommandOptions, +): Promise => { + const { + command, + args = [], + waitForOutput, + timeout = 30000, + } = options; + + return new Promise((resolve) => { + // Run from CLI directory to use twenty-sdk's tsconfig paths + const child: ChildProcess = spawn( + 'npx', + ['tsx', CLI_PATH, command, ...args], + { + cwd: CLI_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, FORCE_COLOR: '0' }, + }, + ); + + let output = ''; + const timeoutId = setTimeout(() => { + child.kill(); + resolve({ success: false, output }); + }, timeout); + + const waitForOutputs = Array.isArray(waitForOutput) + ? waitForOutput + : waitForOutput + ? [waitForOutput] + : []; + + child.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + if ( + waitForOutputs.length > 0 && + waitForOutputs.every((w) => output.includes(w)) + ) { + clearTimeout(timeoutId); + child.kill(); + resolve({ success: true, output }); + } + }); + + child.stderr?.on('data', (data: Buffer) => { + output += data.toString(); + }); + + child.on('close', (code) => { + clearTimeout(timeoutId); + if (waitForOutputs.length === 0) { + resolve({ success: code === 0, output }); + } else { + resolve({ success: false, output }); + } + }); + + child.on('error', () => { + clearTimeout(timeoutId); + resolve({ success: false, output }); + }); + }); +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-output.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-output.util.ts new file mode 100644 index 0000000000..4fa4c0c8a9 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/sanitize-output.util.ts @@ -0,0 +1,16 @@ +export const sanitizeOutput = (output: string): string => { + return output + // Remove ANSI color codes + .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') + // Normalize file paths (replace any absolute path to a test app) + .replace(/\/[^\s]+\/__tests__\/apps\/[^/]+/g, '') + // Normalize timestamps + .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, '') + // Normalize durations + .replace(/\d+ms/g, '') + // Trim trailing whitespace from each line + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + .trim(); +}; diff --git a/packages/twenty-sdk/src/cli/commands/app/app-build.ts b/packages/twenty-sdk/src/cli/commands/app/app-build.ts index 21e6e92abf..cd58a4e726 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-build.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-build.ts @@ -1,5 +1,5 @@ -import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; +import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import chalk from 'chalk'; @@ -15,7 +15,7 @@ export class AppBuildCommand { console.log(chalk.gray(`📁 App Path: ${appPath}`)); console.log(''); - const manifest = await runManifestBuild(appPath); + const { manifest } = await runManifestBuild(appPath); if (!manifest) { return { success: false, error: 'Build failed' }; diff --git a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts index cca024625d..398bb3c7c3 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,17 +1,16 @@ import { FrontComponentsWatcher } from '@/cli/utilities/build/front-components/front-component-watcher'; import { FunctionsWatcher } from '@/cli/utilities/build/functions/function-watcher'; -import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { runManifestBuild, type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-build'; import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import chalk from 'chalk'; -import { type ApplicationManifest } from 'twenty-shared/application'; export type AppDevOptions = { appPath?: string; }; type AppDevState = { - manifest: ApplicationManifest | null; + buildResult: ManifestBuildResult | null; }; export class AppDevCommand { @@ -21,7 +20,7 @@ export class AppDevCommand { private appPath: string = ''; private state: AppDevState = { - manifest: null, + buildResult: null, }; async execute(options: AppDevOptions): Promise { @@ -37,32 +36,32 @@ export class AppDevCommand { } private async startWatchers(): Promise { - const manifest = await runManifestBuild(this.appPath); + const buildResult = await runManifestBuild(this.appPath); - if (!manifest) { + if (!buildResult.manifest) { return; } - this.state.manifest = manifest; + this.state.buildResult = buildResult; await this.startManifestWatcher(); - await this.startFunctionsWatcher(manifest); - await this.startFrontComponentsWatcher(manifest); + await this.startFunctionsWatcher(buildResult); + await this.startFrontComponentsWatcher(buildResult); } private async startManifestWatcher(): Promise { this.manifestWatcher = new ManifestWatcher({ appPath: this.appPath, callbacks: { - onBuildSuccess: (manifest) => { - this.state.manifest = manifest; + onBuildSuccess: (result) => { + this.state.buildResult = result; - if (this.functionsWatcher?.shouldRestart(manifest)) { - this.functionsWatcher.restart(manifest); + if (this.functionsWatcher?.shouldRestart(result)) { + this.functionsWatcher.restart(result); } - if (this.frontComponentsWatcher?.shouldRestart(manifest)) { - this.frontComponentsWatcher.restart(manifest); + if (this.frontComponentsWatcher?.shouldRestart(result)) { + this.frontComponentsWatcher.restart(result); } }, }, @@ -71,19 +70,19 @@ export class AppDevCommand { await this.manifestWatcher.start(); } - private async startFunctionsWatcher(manifest: ApplicationManifest): Promise { + private async startFunctionsWatcher(buildResult: ManifestBuildResult): Promise { this.functionsWatcher = new FunctionsWatcher({ appPath: this.appPath, - manifest, + buildResult, }); await this.functionsWatcher.start(); } - private async startFrontComponentsWatcher(manifest: ApplicationManifest): Promise { + private async startFrontComponentsWatcher(buildResult: ManifestBuildResult): Promise { this.frontComponentsWatcher = new FrontComponentsWatcher({ appPath: this.appPath, - manifest, + buildResult, }); await this.frontComponentsWatcher.start(); diff --git a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts b/packages/twenty-sdk/src/cli/commands/app/app-sync.ts index 103291f852..5a3c422c52 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-sync.ts @@ -16,7 +16,7 @@ export class AppSyncCommand { console.log(chalk.gray(`📁 App Path: ${appPath}`)); console.log(''); - const manifest = await runManifestBuild(appPath, { writeOutput: false }); + const { manifest } = await runManifestBuild(appPath, { writeOutput: false }); if (!manifest) { return { success: false, error: 'Build failed' }; diff --git a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts index 9293a81892..668b9b5c0e 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts @@ -1,9 +1,9 @@ -import chalk from 'chalk'; -import inquirer from 'inquirer'; -import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import { ApiService } from '@/cli/utilities/api/services/api.service'; import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; export class AppUninstallCommand { private apiService = new ApiService(); @@ -25,7 +25,7 @@ export class AppUninstallCommand { process.exit(1); } - const manifest = await runManifestBuild(appPath, { display: false, writeOutput: false }); + const { manifest } = await runManifestBuild(appPath, { display: false, writeOutput: false }); if (!manifest) { return { success: false, error: 'Build failed' }; diff --git a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts index f249fdcc7a..605de012d7 100644 --- a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts @@ -30,7 +30,7 @@ export class FunctionExecuteCommand { process.exit(1); } - const manifest = await runManifestBuild(appPath); + const { manifest } = await runManifestBuild(appPath); if (!manifest) { console.error(chalk.red('Failed to build manifest.')); diff --git a/packages/twenty-sdk/src/cli/commands/function/function-logs.ts b/packages/twenty-sdk/src/cli/commands/function/function-logs.ts index 08ee38a3cd..41db599172 100644 --- a/packages/twenty-sdk/src/cli/commands/function/function-logs.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-logs.ts @@ -1,7 +1,7 @@ -import chalk from 'chalk'; -import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import { ApiService } from '@/cli/utilities/api/services/api.service'; import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; +import chalk from 'chalk'; export class FunctionLogsCommand { private apiService = new ApiService(); @@ -16,7 +16,7 @@ export class FunctionLogsCommand { functionName?: string; }): Promise { try { - const manifest = await runManifestBuild(appPath, { display: false, writeOutput: false }); + const { manifest } = await runManifestBuild(appPath, { display: false, writeOutput: false }); if (!manifest) { process.exit(1); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher.interface.ts b/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher.interface.ts index 97e9dfa44c..b2a34064b0 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher.interface.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/restartable-watcher.interface.ts @@ -1,17 +1,13 @@ -import { type ApplicationManifest } from 'twenty-shared/application'; - +import { type ManifestBuildResult } from '../manifest/manifest-build'; export interface RestartableWatcher { - restart(manifest: ApplicationManifest): Promise; + restart(result: ManifestBuildResult): Promise; start(): Promise; close(): Promise; - shouldRestart( - oldManifest: ApplicationManifest | null, - newManifest: ApplicationManifest, - ): boolean; + shouldRestart(result: ManifestBuildResult): boolean; } export type RestartableWatcherOptions = { appPath: string; - manifest: ApplicationManifest | null; + buildResult: ManifestBuildResult | null; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts deleted file mode 100644 index 7c2d0bbe4f..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-paths.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const computeFrontComponentOutputPath = (componentPath: string): string => { - const normalizedPath = componentPath.replace(/\\/g, '/'); - - let relativePath = normalizedPath; - if (relativePath.startsWith('src/')) { - relativePath = relativePath.slice('src/'.length); - } - - return relativePath.replace(/\.tsx?$/, '.js'); -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts index 6a58eed4b8..9dbc109321 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts @@ -1,7 +1,6 @@ import chalk from 'chalk'; import * as fs from 'fs-extra'; import path from 'path'; -import type { ApplicationManifest } from 'twenty-shared/application'; import { build, type InlineConfig, type Rollup } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; import { OUTPUT_DIR } from '../common/constants'; @@ -10,68 +9,42 @@ import { type RestartableWatcher, type RestartableWatcherOptions, } from '../common/restartable-watcher.interface'; +import { type ManifestBuildResult } from '../manifest/manifest-build'; import { FRONT_COMPONENTS_DIR } from './constants'; -import { computeFrontComponentOutputPath } from './front-component-paths'; - -const buildFrontComponentEntries = ( - appPath: string, - componentPaths: Array<{ componentPath: string }>, -): Record => { - const entries: Record = {}; - - for (const component of componentPaths) { - const relativePath = computeFrontComponentOutputPath(component.componentPath); - const chunkName = relativePath.replace(/\.js$/, ''); - entries[chunkName] = path.join(appPath, component.componentPath); - } - - return entries; -}; export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [ 'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', + /^twenty-sdk/, + /^twenty-shared/, + /^@\//, ]; export class FrontComponentsWatcher implements RestartableWatcher { private appPath: string; - private entries: Record; + private componentPaths: string[]; private innerWatcher: Rollup.RollupWatcher | null = null; private isRestarting = false; constructor(options: RestartableWatcherOptions) { this.appPath = options.appPath; - this.entries = buildFrontComponentEntries( - options.appPath, - options.manifest?.frontComponents ?? [], - ); + this.componentPaths = options.buildResult?.filePaths.frontComponents ?? []; } - shouldRestart(manifest: ApplicationManifest): boolean { - const newEntries = buildFrontComponentEntries(this.appPath, manifest.frontComponents ?? []); - const currentKeys = Object.keys(this.entries).sort(); - const newKeys = Object.keys(newEntries).sort(); + shouldRestart(result: ManifestBuildResult): boolean { + const currentPaths = this.componentPaths.sort().join(','); + const newPaths = result.filePaths.frontComponents.sort().join(','); - if (currentKeys.length !== newKeys.length) { - return true; - } - - for (let i = 0; i < currentKeys.length; i++) { - if (currentKeys[i] !== newKeys[i]) { - return true; - } - } - - return false; + return currentPaths !== newPaths; } async start(): Promise { const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); await fs.ensureDir(outputDir); - if (this.hasEntries()) { + if (this.componentPaths.length > 0) { console.log(chalk.blue(' 🎨 Building front components...')); this.innerWatcher = await this.createWatcher(); } else { @@ -84,7 +57,7 @@ export class FrontComponentsWatcher implements RestartableWatcher { await this.innerWatcher?.close(); } - async restart(manifest: ApplicationManifest): Promise { + async restart(result: ManifestBuildResult): Promise { if (this.isRestarting) { return; } @@ -96,9 +69,9 @@ export class FrontComponentsWatcher implements RestartableWatcher { await this.innerWatcher?.close(); this.innerWatcher = null; - this.entries = buildFrontComponentEntries(this.appPath, manifest.frontComponents ?? []); + this.componentPaths = result.filePaths.frontComponents; - if (this.hasEntries()) { + if (this.componentPaths.length > 0) { console.log(chalk.blue(' 🎨 Building front components...')); this.innerWatcher = await this.createWatcher(); } else { @@ -112,10 +85,6 @@ export class FrontComponentsWatcher implements RestartableWatcher { } } - private hasEntries(): boolean { - return Object.keys(this.entries).length > 0; - } - private async createWatcher(): Promise { const config = this.createConfig(); const watcher = await build(config) as Rollup.RollupWatcher; @@ -135,6 +104,13 @@ export class FrontComponentsWatcher implements RestartableWatcher { private createConfig(): InlineConfig { const frontComponentsOutputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); + const entries = Object.fromEntries( + this.componentPaths.map((filePath) => [ + filePath.replace(/\.tsx?$/, ''), + path.join(this.appPath, filePath), + ]), + ); + return { root: this.appPath, plugins: [ @@ -147,21 +123,17 @@ export class FrontComponentsWatcher implements RestartableWatcher { outDir: frontComponentsOutputDir, emptyOutDir: false, watch: { - include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], + include: ['**/*.ts', '**/*.tsx', '**/*.json'], exclude: ['node_modules/**', '.twenty/**', 'dist/**'], }, lib: { - entry: this.entries, + entry: entries, formats: ['es'], - fileName: (_, entryName) => `${entryName}.js`, + fileName: (_, entryName) => `${entryName}.mjs`, }, rollupOptions: { external: FRONT_COMPONENT_EXTERNAL_MODULES, treeshake: true, - output: { - preserveModules: false, - exports: 'named', - }, }, minify: false, sourcemap: true, diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts deleted file mode 100644 index a3eb60216b..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { computeFunctionOutputPath } from '../function-paths'; - -describe('computeFunctionOutputPath', () => { - it('should handle function in src/ root', () => { - const result = computeFunctionOutputPath('src/hello.function.ts'); - - expect(result).toBe('hello.function.js'); - }); - - it('should handle function in subdirectory', () => { - const result = computeFunctionOutputPath('src/utils/greet.function.ts'); - - expect(result).toBe('utils/greet.function.js'); - }); - - it('should handle deeply nested function', () => { - const result = computeFunctionOutputPath( - 'src/modules/auth/handlers/login.function.ts', - ); - - expect(result).toBe('modules/auth/handlers/login.function.js'); - }); - - it('should handle path without src/ prefix', () => { - const result = computeFunctionOutputPath('handlers/webhook.function.ts'); - - expect(result).toBe('handlers/webhook.function.js'); - }); - - it('should normalize Windows path separators', () => { - const result = computeFunctionOutputPath('src\\utils\\greet.function.ts'); - - expect(result).toBe('utils/greet.function.js'); - }); - - it('should change .ts extension to .js', () => { - const result = computeFunctionOutputPath('src/test.function.ts'); - - expect(result.endsWith('.js')).toBe(true); - expect(result.endsWith('.ts')).toBe(false); - }); -}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts deleted file mode 100644 index 26d34c7936..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const computeFunctionOutputPath = (handlerPath: string): string => { - const normalizedPath = handlerPath.replace(/\\/g, '/'); - - let relativePath = normalizedPath; - if (relativePath.startsWith('src/')) { - relativePath = relativePath.slice('src/'.length); - } - - return relativePath.replace(/\.ts$/, '.js'); -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts index 263605c64b..5417283f39 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts @@ -1,32 +1,16 @@ import chalk from 'chalk'; import * as fs from 'fs-extra'; import path from 'path'; -import type { ApplicationManifest } from 'twenty-shared/application'; import { build, type InlineConfig, type Rollup } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { GENERATED_DIR, OUTPUT_DIR } from '../common/constants'; +import { OUTPUT_DIR } from '../common/constants'; import { printWatchingMessage } from '../common/display'; import { type RestartableWatcher, type RestartableWatcherOptions, } from '../common/restartable-watcher.interface'; +import { type ManifestBuildResult } from '../manifest/manifest-build'; import { FUNCTIONS_DIR } from './constants'; -import { computeFunctionOutputPath } from './function-paths'; - -const buildFunctionEntries = ( - appPath: string, - handlerPaths: Array<{ handlerPath: string }>, -): Record => { - const entries: Record = {}; - - for (const fn of handlerPaths) { - const relativePath = computeFunctionOutputPath(fn.handlerPath); - const chunkName = relativePath.replace(/\.js$/, ''); - entries[chunkName] = path.join(appPath, fn.handlerPath); - } - - return entries; -}; export const FUNCTION_EXTERNAL_MODULES: (string | RegExp)[] = [ 'path', 'fs', 'crypto', 'stream', 'util', 'os', 'url', 'http', 'https', @@ -37,41 +21,27 @@ export const FUNCTION_EXTERNAL_MODULES: (string | RegExp)[] = [ export class FunctionsWatcher implements RestartableWatcher { private appPath: string; - private entries: Record; + private functionPaths: string[]; private innerWatcher: Rollup.RollupWatcher | null = null; private isRestarting = false; constructor(options: RestartableWatcherOptions) { this.appPath = options.appPath; - this.entries = buildFunctionEntries( - options.appPath, - options.manifest?.serverlessFunctions ?? [], - ); + this.functionPaths = options.buildResult?.filePaths.functions ?? []; } - shouldRestart(manifest: ApplicationManifest): boolean { - const newEntries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions ?? []); - const currentKeys = Object.keys(this.entries).sort(); - const newKeys = Object.keys(newEntries).sort(); + shouldRestart(result: ManifestBuildResult): boolean { + const currentPaths = this.functionPaths.sort().join(','); + const newPaths = result.filePaths.functions.sort().join(','); - if (currentKeys.length !== newKeys.length) { - return true; - } - - for (let i = 0; i < currentKeys.length; i++) { - if (currentKeys[i] !== newKeys[i]) { - return true; - } - } - - return false; + return currentPaths !== newPaths; } async start(): Promise { const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); await fs.ensureDir(outputDir); - if (this.hasEntries()) { + if (this.functionPaths.length > 0) { console.log(chalk.blue(' 📦 Building functions...')); this.innerWatcher = await this.createWatcher(); } else { @@ -84,7 +54,7 @@ export class FunctionsWatcher implements RestartableWatcher { await this.innerWatcher?.close(); } - async restart(manifest: ApplicationManifest): Promise { + async restart(result: ManifestBuildResult): Promise { if (this.isRestarting) { return; } @@ -96,9 +66,9 @@ export class FunctionsWatcher implements RestartableWatcher { await this.innerWatcher?.close(); this.innerWatcher = null; - this.entries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions ?? []); + this.functionPaths = result.filePaths.functions; - if (this.hasEntries()) { + if (this.functionPaths.length > 0) { console.log(chalk.blue(' 📦 Building functions...')); this.innerWatcher = await this.createWatcher(); } else { @@ -112,10 +82,6 @@ export class FunctionsWatcher implements RestartableWatcher { } } - private hasEntries(): boolean { - return Object.keys(this.entries).length > 0; - } - private async createWatcher(): Promise { const config = this.createConfig(); const watcher = await build(config) as Rollup.RollupWatcher; @@ -135,6 +101,13 @@ export class FunctionsWatcher implements RestartableWatcher { private createConfig(): InlineConfig { const functionsOutputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); + const entries = Object.fromEntries( + this.functionPaths.map((filePath) => [ + filePath.replace(/\.tsx?$/, ''), + path.join(this.appPath, filePath), + ]), + ); + return { root: this.appPath, plugins: [ @@ -144,27 +117,17 @@ export class FunctionsWatcher implements RestartableWatcher { outDir: functionsOutputDir, emptyOutDir: false, watch: { - include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], + include: ['**/*.ts', '**/*.tsx', '**/*.json'], exclude: ['node_modules/**', '.twenty/**', 'dist/**'], }, lib: { - entry: this.entries, + entry: entries, formats: ['es'], - fileName: (_, entryName) => `${entryName}.js`, + fileName: (_, entryName) => `${entryName}.mjs`, }, rollupOptions: { external: FUNCTION_EXTERNAL_MODULES, treeshake: true, - output: { - preserveModules: false, - exports: 'named', - paths: (id: string) => { - if (/(?:^|\/)generated(?:\/|$)/.test(id)) { - return `../${GENERATED_DIR}/index.js`; - } - return id; - }, - }, }, minify: false, sourcemap: true, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts index 0a134ce9ff..0e8d57a533 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts @@ -1,24 +1,43 @@ import chalk from 'chalk'; +import * as fs from 'fs-extra'; import path from 'path'; import { type Application } from 'twenty-shared/application'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { - type EntityIdWithLocation, - type ManifestEntityBuilder, - type ManifestWithoutSources, + type EntityBuildResult, + type EntityIdWithLocation, + type ManifestEntityBuilder, + type ManifestWithoutSources, } from './entity.interface'; +const findApplicationConfigPath = async (appPath: string): Promise => { + const configFile = path.join(appPath, 'application.config.ts'); + + if (await fs.pathExists(configFile)) { + return configFile; + } + + throw new Error('Missing application.config.ts in your app root'); +}; + export class ApplicationEntityBuilder implements ManifestEntityBuilder { - async build(appPath: string): Promise { - const applicationConfigPath = path.join(appPath, 'src', 'application.config.ts'); + async build(appPath: string): Promise> { + const applicationConfigPath = await findApplicationConfigPath(appPath); + const application = + await manifestExtractFromFileServer.extractManifestFromFile( + applicationConfigPath, + ); + const relativePath = path.relative(appPath, applicationConfigPath); - return manifestExtractFromFileServer.extractManifestFromFile(applicationConfigPath); + return { manifests: [application], filePaths: [relativePath] }; } - validate(application: Application, errors: ValidationError[]): void { + validate(applications: Application[], errors: ValidationError[]): void { + const application = applications[0]; + if (!application) { errors.push({ path: 'application', @@ -35,8 +54,9 @@ export class ApplicationEntityBuilder } } - display(application: Application): void { - const appName = application.displayName ?? 'Application'; + display(applications: Application[]): void { + const application = applications[0]; + const appName = application?.displayName ?? 'Application'; console.log(chalk.green(` ✓ Loaded "${appName}"`)); } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity.interface.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity.interface.ts index 893ccfe3d5..1620b87d95 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity.interface.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/entity.interface.ts @@ -11,9 +11,14 @@ export type ManifestWithoutSources = Omit< 'sources' | 'packageJson' >; +export type EntityBuildResult = { + manifests: TManifest[]; + filePaths: string[]; +}; + export type ManifestEntityBuilder = { - build(appPath: string): Promise; - validate(data: EntityManifest, errors: ValidationError[]): void; - display(data: EntityManifest): void; + build(appPath: string): Promise>; + validate(data: EntityManifest[], errors: ValidationError[]): void; + display(data: EntityManifest[]): void; findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[]; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts index 649d72cbd8..5b8b662452 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts @@ -1,44 +1,53 @@ -import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type FrontComponentManifest } from 'twenty-shared/application'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { + type EntityBuildResult, type EntityIdWithLocation, type ManifestEntityBuilder, type ManifestWithoutSources, } from './entity.interface'; +type FrontComponentConfig = Omit & { + component: { name: string }; +}; + export class FrontComponentEntityBuilder - implements ManifestEntityBuilder + implements ManifestEntityBuilder { - async build(appPath: string): Promise { - const componentFiles = await glob(['src/**/*.front-component.tsx'], { + async build(appPath: string): Promise> { + const componentFiles = await glob(['**/*.front-component.tsx'], { cwd: appPath, - absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'], }); - const frontComponentManifests: FrontComponentManifest[] = []; + const manifests: FrontComponentManifest[] = []; - for (const filepath of componentFiles) { + for (const filePath of componentFiles) { try { - frontComponentManifests.push( - await manifestExtractFromFileServer.extractManifestFromFile( - filepath, - { entryProperty: 'component' }, - ), - ); + const absolutePath = `${appPath}/${filePath}`; + const config = + await manifestExtractFromFileServer.extractManifestFromFile( + absolutePath, + ); + + const { component, ...rest } = config; + + manifests.push({ + ...rest, + componentName: component.name, + componentPath: filePath, + }); } catch (error) { - const relPath = toPosixRelative(filepath, appPath); throw new Error( - `Failed to load front component from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to load front component from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } } - return frontComponentManifests; + return { manifests, filePaths: componentFiles }; } validate( diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts index 5213c629fc..0817b4f910 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts @@ -1,44 +1,44 @@ -import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type ServerlessFunctionManifest } from 'twenty-shared/application'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { + type EntityBuildResult, type EntityIdWithLocation, type ManifestEntityBuilder, type ManifestWithoutSources, } from './entity.interface'; export class FunctionEntityBuilder - implements ManifestEntityBuilder + implements ManifestEntityBuilder { - async build(appPath: string): Promise { - const functionFiles = await glob(['src/**/*.function.ts'], { + async build(appPath: string): Promise> { + const functionFiles = await glob(['**/*.function.ts'], { cwd: appPath, - absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'], }); - const functionManifests: ServerlessFunctionManifest[] = []; + const manifests: ServerlessFunctionManifest[] = []; - for (const filepath of functionFiles) { + for (const filePath of functionFiles) { try { - functionManifests.push( + const absolutePath = `${appPath}/${filePath}`; + + manifests.push( await manifestExtractFromFileServer.extractManifestFromFile( - filepath, + absolutePath, { entryProperty: 'handler' }, ), ); } catch (error) { - const relPath = toPosixRelative(filepath, appPath); throw new Error( - `Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to load function from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } } - return functionManifests; + return { manifests, filePaths: functionFiles }; } validate( diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts index 3d82f461d4..521d817cb2 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts @@ -1,4 +1,3 @@ -import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import { glob } from 'fast-glob'; import { type ObjectExtensionManifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; @@ -6,37 +5,40 @@ import { isNonEmptyArray } from 'twenty-shared/utils'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { + type EntityBuildResult, type EntityIdWithLocation, type ManifestEntityBuilder, type ManifestWithoutSources, } from './entity.interface'; export class ObjectExtensionEntityBuilder - implements ManifestEntityBuilder + implements ManifestEntityBuilder { - async build(appPath: string): Promise { - const extensionFiles = await glob(['src/**/*.object-extension.ts'], { + async build(appPath: string): Promise> { + const extensionFiles = await glob(['**/*.object-extension.ts'], { cwd: appPath, - absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'], }); - const objectExtensionManifests: ObjectExtensionManifest[] = []; + const manifests: ObjectExtensionManifest[] = []; - for (const filepath of extensionFiles) { + for (const filePath of extensionFiles) { try { - objectExtensionManifests.push( - await manifestExtractFromFileServer.extractManifestFromFile(filepath), + const absolutePath = `${appPath}/${filePath}`; + + manifests.push( + await manifestExtractFromFileServer.extractManifestFromFile( + absolutePath, + ), ); } catch (error) { - const relPath = toPosixRelative(filepath, appPath); throw new Error( - `Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to load object extension from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } } - return objectExtensionManifests; + return { manifests, filePaths: extensionFiles }; } validate( diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts index 5317919257..14d68a1086 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts @@ -1,4 +1,3 @@ -import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type ObjectManifest } from 'twenty-shared/application'; @@ -7,37 +6,38 @@ import { isNonEmptyArray } from 'twenty-shared/utils'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { + type EntityBuildResult, type EntityIdWithLocation, type ManifestEntityBuilder, type ManifestWithoutSources, } from './entity.interface'; export class ObjectEntityBuilder - implements ManifestEntityBuilder + implements ManifestEntityBuilder { - async build(appPath: string): Promise { - const objectFiles = await glob(['src/**/*.object.ts'], { + async build(appPath: string): Promise> { + const objectFiles = await glob(['**/*.object.ts'], { cwd: appPath, - absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'], }); - const objectManifests: ObjectManifest[] = []; + const manifests: ObjectManifest[] = []; - for (const filepath of objectFiles) { + for (const filePath of objectFiles) { try { - objectManifests.push( - await manifestExtractFromFileServer.extractManifestFromFile(filepath), + const absolutePath = `${appPath}/${filePath}`; + + manifests.push( + await manifestExtractFromFileServer.extractManifestFromFile(absolutePath), ); } catch (error) { - const relPath = toPosixRelative(filepath, appPath); throw new Error( - `Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to load object from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } } - return objectManifests; + return { manifests, filePaths: objectFiles }; } validate(objects: ObjectManifest[], errors: ValidationError[]): void { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts index a0a99dc84d..61f468c191 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts @@ -1,39 +1,39 @@ -import { toPosixRelative } from '@/cli/utilities/file/utils/file-path'; import chalk from 'chalk'; import { glob } from 'fast-glob'; import { type RoleManifest } from 'twenty-shared/application'; import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server'; import { type ValidationError } from '../manifest.types'; import { - type EntityIdWithLocation, - type ManifestEntityBuilder, - type ManifestWithoutSources, + type EntityBuildResult, + type EntityIdWithLocation, + type ManifestEntityBuilder, + type ManifestWithoutSources, } from './entity.interface'; -export class RoleEntityBuilder implements ManifestEntityBuilder { - async build(appPath: string): Promise { - const roleFiles = await glob(['src/**/*.role.ts'], { +export class RoleEntityBuilder implements ManifestEntityBuilder { + async build(appPath: string): Promise> { + const roleFiles = await glob(['**/*.role.ts'], { cwd: appPath, - absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'], }); - const roleManifests: RoleManifest[] = []; + const manifests: RoleManifest[] = []; - for (const filepath of roleFiles) { + for (const filePath of roleFiles) { try { - roleManifests.push( - await manifestExtractFromFileServer.extractManifestFromFile(filepath), + const absolutePath = `${appPath}/${filePath}`; + + manifests.push( + await manifestExtractFromFileServer.extractManifestFromFile(absolutePath), ); } catch (error) { - const relPath = toPosixRelative(filepath, appPath); throw new Error( - `Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to load role from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } } - return roleManifests; + return { manifests, filePaths: roleFiles }; } validate(roles: RoleManifest[], errors: ValidationError[]): void { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 04a8a9678d..80c8814663 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -18,28 +18,22 @@ import { manifestExtractFromFileServer } from './manifest-extract-from-file-serv import { validateManifest } from './manifest-validate'; import { ManifestValidationError } from './manifest.types'; -const validateFolderStructure = async (appPath: string): Promise => { - const srcFolder = path.join(appPath, 'src'); - - if (!(await fs.pathExists(srcFolder))) { - throw new Error( - `Missing src/ folder in ${appPath}.\n` + 'Create it with: mkdir -p src', - ); - } - - const configFile = path.join(appPath, 'src', 'application.config.ts'); - if (!(await fs.pathExists(configFile))) { - throw new Error('Missing src/application.config.ts'); - } +export type EntityFilePaths = { + application: string[]; + objects: string[]; + objectExtensions: string[]; + functions: string[]; + frontComponents: string[]; + roles: string[]; }; const loadSources = async (appPath: string): Promise => { const sources: Sources = {}; - const tsFiles = await glob(['src/**/*.ts', 'src/**/*.tsx', 'generated/**/*.ts'], { + const tsFiles = await glob(['**/*.ts', '**/*.tsx'], { cwd: appPath, absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'], }); for (const filepath of tsFiles) { @@ -87,10 +81,24 @@ export type RunManifestBuildOptions = { writeOutput?: boolean; }; +const EMPTY_FILE_PATHS: EntityFilePaths = { + application: [], + objects: [], + objectExtensions: [], + functions: [], + frontComponents: [], + roles: [], +}; + +export type ManifestBuildResult = { + manifest: ApplicationManifest | null; + filePaths: EntityFilePaths; +}; + export const runManifestBuild = async ( appPath: string, options: RunManifestBuildOptions = {}, -): Promise => { +): Promise => { const { display = true, writeOutput = true } = options; if (display) { @@ -98,7 +106,6 @@ export const runManifestBuild = async ( } try { - await validateFolderStructure(appPath); manifestExtractFromFileServer.init(appPath); const packageJson = await parseJsoncFile( @@ -106,12 +113,12 @@ export const runManifestBuild = async ( ); const [ - application, - objectManifests, - objectExtensionManifests, - functionManifests, - frontComponentManifests, - roleManifests, + applicationBuildResult, + objectBuildResult, + objectExtensionBuildResult, + functionBuildResult, + frontComponentBuildResult, + roleBuildResult, sources, ] = await Promise.all([ applicationEntityBuilder.build(appPath), @@ -123,6 +130,22 @@ export const runManifestBuild = async ( loadSources(appPath), ]); + const application = applicationBuildResult.manifests[0]; + const objectManifests = objectBuildResult.manifests; + const objectExtensionManifests = objectExtensionBuildResult.manifests; + const functionManifests = functionBuildResult.manifests; + const frontComponentManifests = frontComponentBuildResult.manifests; + const roleManifests = roleBuildResult.manifests; + + const filePaths: EntityFilePaths = { + application: applicationBuildResult.filePaths, + objects: objectBuildResult.filePaths, + objectExtensions: objectExtensionBuildResult.filePaths, + functions: functionBuildResult.filePaths, + frontComponents: frontComponentBuildResult.filePaths, + roles: roleBuildResult.filePaths, + }; + const manifest: ApplicationManifest = { application, objects: objectManifests, @@ -160,7 +183,7 @@ export const runManifestBuild = async ( await writeManifestToOutput(appPath, manifest); } - return manifest; + return { manifest, filePaths }; } catch (error) { if (display) { if (error instanceof ManifestValidationError) { @@ -172,6 +195,6 @@ export const runManifestBuild = async ( ); } } - return null; + return { manifest: null, filePaths: EMPTY_FILE_PATHS }; } }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts index 4559c9bb47..54e07175b2 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts @@ -8,9 +8,11 @@ import { roleEntityBuilder } from './entities/role'; import { type ManifestValidationError, type ValidationWarning } from './manifest.types'; export const displayEntitySummary = (manifest: ApplicationManifest): void => { - applicationEntityBuilder.display(manifest.application); - objectEntityBuilder.display(manifest.objects); - functionEntityBuilder.display(manifest.serverlessFunctions); + applicationEntityBuilder.display( + manifest.application ? [manifest.application] : [], + ); + objectEntityBuilder.display(manifest.objects ?? []); + functionEntityBuilder.display(manifest.serverlessFunctions ?? []); frontComponentEntityBuilder.display(manifest.frontComponents ?? []); roleEntityBuilder.display(manifest.roles ?? []); }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index b7e40ff232..87eb91f42f 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -34,7 +34,10 @@ export const validateManifest = ( const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; - applicationEntityBuilder.validate(manifest.application, errors); + applicationEntityBuilder.validate( + manifest.application ? [manifest.application] : [], + errors, + ); objectEntityBuilder.validate(manifest.objects ?? [], errors); objectExtensionEntityBuilder.validate(manifest.objectExtensions ?? [], errors); functionEntityBuilder.validate(manifest.serverlessFunctions ?? [], errors); @@ -51,13 +54,13 @@ export const validateManifest = ( if (!isNonEmptyArray(manifest.objects)) { warnings.push({ - message: 'No objects defined in src/', + message: 'No objects defined', }); } if (!isNonEmptyArray(manifest.serverlessFunctions)) { warnings.push({ - message: 'No functions defined in src/', + message: 'No functions defined', }); } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts index a3078eb169..73138f36f2 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts @@ -1,12 +1,11 @@ import chalk from 'chalk'; import chokidar, { type FSWatcher } from 'chokidar'; import path from 'path'; -import { type ApplicationManifest } from 'twenty-shared/application'; import { printWatchingMessage } from '../common/display'; -import { runManifestBuild } from './manifest-build'; +import { runManifestBuild, type ManifestBuildResult } from './manifest-build'; export type ManifestWatcherCallbacks = { - onBuildSuccess?: (manifest: ApplicationManifest) => void; + onBuildSuccess?: (result: ManifestBuildResult) => void; }; export type ManifestWatcherOptions = { @@ -25,9 +24,7 @@ export class ManifestWatcher { } async start(): Promise { - const srcPath = path.join(this.appPath, 'src'); - - this.watcher = chokidar.watch(srcPath, { + this.watcher = chokidar.watch(this.appPath, { ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'], ignoreInitial: true, awaitWriteFinish: { @@ -43,11 +40,11 @@ export class ManifestWatcher { console.log(chalk.gray(` File ${event}: ${path.relative(this.appPath, filePath)}`)); - const manifest = await runManifestBuild(this.appPath); + const result = await runManifestBuild(this.appPath); - if (manifest) { + if (result.manifest) { printWatchingMessage(); - this.callbacks.onBuildSuccess?.(manifest); + this.callbacks.onBuildSuccess?.(result); } });