From 41d470e68754ca33844d875cc2ed17d56b879400 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 26 Jan 2026 20:32:13 +0100 Subject: [PATCH] Implement sync in dev mode (#17405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit implement orchestrator to sync application in dev mode Log example when starting dev mode, delete and add back functions ```bash 👩‍💻 Workspace - default [init] 🚀 Starting Twenty Application Development Mode [init] 📁 App Path: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/.gitignore [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/.nvmrc [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/.yarnrc.yml [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/README.md [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/eslint.config.mjs [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/package.json [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/tsconfig.json [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/yarn.lock [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/.yarn/install-state.gz [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/ooo.front-component.tsx [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/tata.object.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/application.config.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/default-function.role.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-2.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-3.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world.front-component.tsx [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/myObject.object.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/utils/toto.ts [dev-mode] Building manifest... [dev-mode] Successfully built manifest [dev-mode] Syncing... [dev-mode] ✓ Successfully built src/ooo.front-component.tsx [dev-mode] Uploading .twenty/output/src/ooo.front-component.mjs... [dev-mode] ✓ Successfully built src/app/hello-world-2.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world-2.function.mjs... [dev-mode] ✓ Successfully built src/app/hello-world.front-component.tsx [dev-mode] Uploading .twenty/output/src/app/hello-world.front-component.mjs... [dev-mode] ✓ Successfully built src/app/hello-world-3.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world-3.function.mjs... [dev-mode] ✓ Successfully built src/app/hello-world.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world.function.mjs... [dev-mode] Successfully uploaded .twenty/output/src/ooo.front-component.mjs [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world-3.function.mjs [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world.front-component.mjs [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world.function.mjs [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world-2.function.mjs [dev-mode] ✓ Synced [dev-mode] ✓ Successfully built src/app/hello-world-2.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world-2.function.mjs... [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world-2.function.mjs [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-2.function.ts [dev-mode] Building manifest... [dev-mode] Successfully built manifest [dev-mode] Syncing... [dev-mode] ✓ Synced [dev-mode] Build failed: [dev-mode] Could not resolve "/Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world.function.ts" [dev-mode] Could not resolve "/Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-3.function.ts" [dev-mode] Could not resolve "/Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-2.function.ts" [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-2.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-3.function.ts [dev-mode] Building manifest... [dev-mode] ⚠ No functions defined [dev-mode] Successfully built manifest [dev-mode] Syncing... 🗑️ Removed src/app/hello-world-2.function.mjs 🗑️ Removed src/app/hello-world-3.function.mjs 🗑️ Removed src/app/hello-world.function.mjs [dev-mode] ✓ Synced [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-2.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world-3.function.ts [dev-mode] File changed: /Users/martinmuller/Desktop/twenty/packages/twenty-apps/toto/src/app/hello-world.function.ts [dev-mode] Building manifest... [dev-mode] Successfully built manifest [dev-mode] Syncing... [dev-mode] ✓ Successfully built src/app/hello-world-2.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world-2.function.mjs... [dev-mode] ✓ Successfully built src/app/hello-world-3.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world-3.function.mjs... [dev-mode] ✓ Successfully built src/app/hello-world.function.ts [dev-mode] Uploading .twenty/output/src/app/hello-world.function.mjs... [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world.function.mjs [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world-2.function.mjs [dev-mode] Successfully uploaded .twenty/output/src/app/hello-world-3.function.mjs [dev-mode] ✓ Synced ``` --- .../manifest.integration.spec.ts | 3 +- .../cli/__tests__/apps/invalid-app/yarn.lock | 0 ...tions-install-delete-reinstall.e2e-spec.ts | 13 +- .../app-build/app-build.integration.spec.ts | 25 -- .../app-build/tests/console-output.tests.ts | 52 --- .../app-dev/app-dev.integration.spec.ts | 8 +- .../app-dev/expected-manifest.ts | 34 +- .../app-dev/manifest.expected.json | 372 ------------------ .../app-dev/tests/console-output.tests.ts | 31 +- .../{functions.tests.ts => entities.tests.ts} | 25 +- .../app-dev/tests/front-components.tests.ts | 28 -- .../src/functions/greeting.function.ts | 4 +- .../src/utils/test-function-2.util.ts | 6 +- .../app-build/app-build.integration.spec.ts | 25 -- .../app-build/tests/console-output.tests.ts | 52 --- .../app-dev/expected-manifest.ts | 119 ++++++ .../app-dev/manifest.expected.json | 74 ---- .../app-dev/tests/console-output.tests.ts | 31 +- .../app-dev/tests/front-components.tests.ts | 14 +- .../app-dev/tests/functions.tests.ts | 11 +- .../app-dev/tests/manifest.tests.ts | 19 +- .../src/cli/__tests__/apps/root-app/yarn.lock | 0 .../utils/get-output-by-prefix.util.ts | 1 + .../integration/utils/run-app-build.util.ts | 22 -- .../integration/utils/run-app-dev.util.ts | 6 +- .../integration/utils/run-cli-command.util.ts | 6 +- .../src/cli/commands/app-command.ts | 33 -- .../src/cli/commands/app/app-build.ts | 117 ------ .../src/cli/commands/app/app-dev.ts | 258 +++++------- .../src/cli/commands/app/app-publish.ts | 8 - .../src/cli/commands/app/app-sync.ts | 61 --- .../src/cli/commands/app/app-test.ts | 8 - .../src/cli/commands/app/app-uninstall.ts | 5 +- .../cli/commands/function/function-logs.ts | 5 +- .../src/cli/commands/instance/instance-run.ts | 8 - .../cli/commands/instance/instance-setup.ts | 8 - .../cli/commands/instance/instance-upgrade.ts | 8 - .../src/cli/utilities/api/api-service.ts | 10 +- .../build/common/cleanup-removed-files.ts | 3 - .../build/common/esbuild-result-processor.ts | 47 +-- .../utilities/build/common/esbuild-watcher.ts | 222 +++++++++++ .../src/cli/utilities/build/common/logger.ts | 27 +- .../common/restartable-watcher-interface.ts | 17 +- .../build/front-components/constants.ts | 1 - .../front-component-watcher.ts | 176 --------- .../utilities/build/functions/constants.ts | 1 - .../build/functions/function-watcher.ts | 198 ---------- .../build/manifest/entities/application.ts | 4 +- .../manifest/entities/entity-interface.ts | 2 +- .../manifest/entities/front-component.ts | 9 +- .../build/manifest/entities/function.ts | 22 +- .../manifest/entities/object-extension.ts | 7 +- .../build/manifest/entities/object.ts | 9 +- .../utilities/build/manifest/entities/role.ts | 9 +- .../build/manifest/manifest-build.ts | 152 +++---- .../build/manifest/manifest-display.ts | 41 -- .../manifest-extract-from-file-server.ts | 192 +++------ .../build/manifest/manifest-types.ts | 10 - .../build/manifest/manifest-watcher.ts | 47 +-- .../utilities/dev/dev-mode-orchestrator.ts | 203 ++++++++++ .../src/cli/utilities/file/file-uploader.ts | 42 +- packages/twenty-sdk/vite.config.ts | 1 + .../application/application-sync.service.ts | 2 + .../application/applicationManifestType.ts | 1 + 64 files changed, 946 insertions(+), 2009 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/yarn.lock delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/app-build.integration.spec.ts delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/tests/console-output.tests.ts delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json rename packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/{functions.tests.ts => entities.tests.ts} (54%) delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/front-components.tests.ts delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/app-build.integration.spec.ts delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/tests/console-output.tests.ts create mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts delete mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/manifest.expected.json create mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/root-app/yarn.lock delete mode 100644 packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-build.util.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/app/app-build.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/app/app-publish.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/app/app-sync.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/app/app-test.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/instance/instance-run.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/instance/instance-setup.ts delete mode 100644 packages/twenty-sdk/src/cli/commands/instance/instance-upgrade.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts 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 b412423b9a..cfcfc9ea3d 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,9 +1,10 @@ import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util'; import * as fs from 'fs-extra'; import { join } from 'path'; +import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; const APP_PATH = join(__dirname, '..'); -const MANIFEST_OUTPUT_PATH = join(APP_PATH, '.twenty/output/manifest.json'); +const MANIFEST_OUTPUT_PATH = join(APP_PATH, OUTPUT_DIR, 'ioi', 'manifest.json'); describe('invalid-app manifest', () => { it('should fail to build manifest due to duplicate universalIdentifier', async () => { diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/yarn.lock b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/yarn.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts index e9bd4068b5..cdff589347 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__e2e__/applications-install-delete-reinstall.e2e-spec.ts @@ -1,14 +1,15 @@ import { getTestedApplicationPath } from '@/cli/__tests__/e2e/utils/get-tested-application-path.util'; -import { AppSyncCommand } from '@/cli/commands/app/app-sync'; import { AppUninstallCommand } from '@/cli/commands/app/app-uninstall'; import { existsSync } from 'fs'; import { inspect } from 'util'; +import { join } from 'path'; +import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util'; +import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; inspect.defaultOptions.depth = 10; describe('Application: install delete and reinstall rich-app', () => { const applicationName = 'rich-app'; - const syncCommand = new AppSyncCommand(); const deleteCommand = new AppUninstallCommand(); const appPath = getTestedApplicationPath(applicationName); @@ -26,9 +27,9 @@ describe('Application: install delete and reinstall rich-app', () => { }); it(`should successfully install ${applicationName} application`, async () => { - const result = await syncCommand.execute(appPath); + await runAppDev({ appPath }); - expect(result.success).toBe(true); + expect(existsSync(join(appPath, OUTPUT_DIR, 'manifest.json'))).toBe(true); }); it(`should successfully delete ${applicationName} application`, async () => { @@ -41,8 +42,8 @@ describe('Application: install delete and reinstall rich-app', () => { }); it(`should successfully re-install ${applicationName} application`, async () => { - const result = await syncCommand.execute(appPath); + await runAppDev({ appPath }); - expect(result.success).toBe(true); + expect(existsSync(join(appPath, OUTPUT_DIR, 'manifest.json'))).toBe(true); }); }); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/app-build.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/app-build.integration.spec.ts deleted file mode 100644 index d6bcdc5436..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/app-build.integration.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { join } from 'path'; - -import { runAppBuild } from '@/cli/__tests__/integration/utils/run-app-build.util'; -import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; -import { defineConsoleOutputTests } from './tests/console-output.tests'; -import { defineManifestTests } from '../app-dev/tests/manifest.tests'; -import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests'; -import { defineFunctionsTests } from '../app-dev/tests/functions.tests'; - -const APP_PATH = join(__dirname, '../..'); - -describe('rich-app app:build', () => { - let result: RunCliCommandResult; - - beforeAll(async () => { - result = await runAppBuild({ 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__/app-build/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/tests/console-output.tests.ts deleted file mode 100644 index 6040e8f4e7..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-build/tests/console-output.tests.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util'; -import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; - -export const defineConsoleOutputTests = ( - getResult: () => RunCliCommandResult, -): void => { - describe('console output', () => { - it('should contain init messages', () => { - const output = getOutputByPrefix(getResult().output, 'init'); - - expect(output).toContain('[init] 🚀 Building Twenty Application'); - expect(output).toContain('[init] 📁 App Path:'); - expect(output).toContain('[init] ✅ Build completed successfully'); - }); - - it('should contain manifest-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'manifest-watch'); - - expect(output).toContain('[manifest-watch] 🔄 Building...'); - expect(output).toContain('[manifest-watch] ✓ Loaded "Hello World"'); - expect(output).toContain('[manifest-watch] ✓ Found 2 object(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 4 function(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 4 front component(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 2 role(s)'); - expect(output).toContain('[manifest-watch] ✓ Written to'); - }); - - it('should contain functions-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'functions-watch'); - - expect(output).toContain('[functions-watch] 📦 Building...'); - expect(output).toContain('[functions-watch] ✓ Built'); - }); - - it('should contain front-components-watch messages', () => { - const output = getOutputByPrefix( - getResult().output, - 'front-components-watch', - ); - - expect(output).toContain('[front-components-watch] 🎨 Building...'); - expect(output).toContain('[front-components-watch] ✓ Built'); - }); - - it('should not contain watching messages', () => { - const output = getResult().output; - - expect(output).not.toContain('👀 Watching for changes...'); - expect(output).not.toContain('📂 Watcher started'); - }); - }); -}; 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 index ed07d94a3c..ddfa038d51 100644 --- 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 @@ -3,9 +3,8 @@ import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run- import { join } from 'path'; import { defineConsoleOutputTests } from './tests/console-output.tests'; -import { defineManifestTests } from '../app-dev/tests/manifest.tests'; -import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests'; -import { defineFunctionsTests } from '../app-dev/tests/functions.tests'; +import { defineManifestTests } from './tests/manifest.tests'; +import { defineEntitiesTests } from './tests/entities.tests'; const APP_PATH = join(__dirname, '../..'); @@ -20,6 +19,5 @@ describe('rich-app app:dev', () => { defineConsoleOutputTests(() => result); defineManifestTests(APP_PATH); - defineFunctionsTests(APP_PATH); - defineFrontComponentsTests(APP_PATH); + defineEntitiesTests(APP_PATH); }); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index b2cda45074..11fdb67b5c 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -4,6 +4,7 @@ import { PermissionFlagType } from 'twenty-shared/constants'; export const EXPECTED_MANIFEST: ApplicationManifest = { sources: {}, + yarnLock: '', application: { applicationVariables: { DEFAULT_RECIPIENT_NAME: { @@ -21,7 +22,7 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { }, frontComponents: [ { - builtComponentPath: 'front-components/src/root.front-component.mjs', + builtComponentPath: 'src/root.front-component.mjs', builtComponentChecksum: '[checksum]', componentName: 'RootComponent', description: 'A root-level front component', @@ -30,8 +31,7 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { universalIdentifier: 'a0a1a2a3-a4a5-4000-8000-000000000001', }, { - builtComponentPath: - 'front-components/src/components/card.front-component.mjs', + builtComponentPath: 'src/components/card.front-component.mjs', builtComponentChecksum: '[checksum]', componentName: 'CardDisplay', description: 'A component using an external component file', @@ -40,8 +40,7 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { universalIdentifier: 'i0i1i2i3-i4i5-4000-8000-000000000001', }, { - builtComponentPath: - 'front-components/src/components/greeting.front-component.mjs', + builtComponentPath: 'src/components/greeting.front-component.mjs', builtComponentChecksum: '[checksum]', componentName: 'GreetingComponent', description: 'A component that uses greeting utility', @@ -50,8 +49,7 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { universalIdentifier: 'h0h1h2h3-h4h5-4000-8000-000000000001', }, { - builtComponentPath: - 'front-components/src/components/test.front-component.mjs', + builtComponentPath: 'src/components/test.front-component.mjs', builtComponentChecksum: '[checksum]', componentName: 'TestComponent', description: 'A test front component', @@ -293,8 +291,8 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { functions: [ { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'functions/src/root.function.mjs', - handlerName: 'rootHandler', + builtHandlerPath: 'src/root.function.mjs', + handlerName: 'default.handler', name: 'root-function', sourceHandlerPath: 'src/root.function.ts', timeoutSeconds: 5, @@ -311,8 +309,8 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { }, { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'functions/src/functions/greeting.function.mjs', - handlerName: 'greetingHandler', + builtHandlerPath: 'src/functions/greeting.function.mjs', + handlerName: 'default.handler', name: 'greeting-function', sourceHandlerPath: 'src/functions/greeting.function.ts', timeoutSeconds: 5, @@ -322,17 +320,17 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { isAuthRequired: false, path: '/greet', type: 'route', - universalIdentifier: 'g0g1g2g3-g4g5-4000-8000-000000000002', + universalIdentifier: 'f7b12160-3d25-4c92-a13f-a325dd60eb04', }, ], - universalIdentifier: 'g0g1g2g3-g4g5-4000-8000-000000000001', + universalIdentifier: '9d412d9e-2caf-487c-8b66-d1585883dd4e', }, { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'functions/src/functions/test-function-2.function.mjs', - handlerName: 'testFunction2', + builtHandlerPath: 'src/functions/test-function-2.function.mjs', + handlerName: 'config.handler', name: 'test-function-2', - sourceHandlerPath: 'src/utils/test-function-2.util.ts', + sourceHandlerPath: 'src/functions/test-function-2.function.ts', timeoutSeconds: 2, triggers: [ { @@ -345,8 +343,8 @@ export const EXPECTED_MANIFEST: ApplicationManifest = { }, { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'functions/src/functions/test-function.function.mjs', - handlerName: 'handler', + builtHandlerPath: 'src/functions/test-function.function.mjs', + handlerName: 'default.handler', name: 'test-function', sourceHandlerPath: 'src/functions/test-function.function.ts', timeoutSeconds: 2, diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json deleted file mode 100644 index 08ccfc67df..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/manifest.expected.json +++ /dev/null @@ -1,372 +0,0 @@ -{ - "application": { - "applicationVariables": { - "DEFAULT_RECIPIENT_NAME": { - "description": "Default recipient name for postcards", - "isSecret": false, - "universalIdentifier": "19e94e59-d4fe-4251-8981-b96d0a9f74de", - "value": "Alex Karp" - } - }, - "description": "A simple hello world app", - "displayName": "Hello World", - "functionRoleUniversalIdentifier": "b648f87b-1d26-4961-b974-0908fd991061", - "icon": "IconWorld", - "universalIdentifier": "4ec0391d-18d5-411c-b2f3-266ddc1c3ef7" - }, - "frontComponents": [ - { - "builtComponentPath": "front-components/src/root.front-component.mjs", - "builtComponentChecksum": "[checksum]", - "componentName": "RootComponent", - "description": "A root-level front component", - "name": "root-component", - "sourceComponentPath": "src/root.front-component.tsx", - "universalIdentifier": "a0a1a2a3-a4a5-4000-8000-000000000001" - }, - { - "builtComponentPath": "front-components/src/components/card.front-component.mjs", - "builtComponentChecksum": "[checksum]", - "componentName": "CardDisplay", - "description": "A component using an external component file", - "name": "card-component", - "sourceComponentPath": "src/components/card.front-component.tsx", - "universalIdentifier": "i0i1i2i3-i4i5-4000-8000-000000000001" - }, - { - "builtComponentPath": "front-components/src/components/greeting.front-component.mjs", - "builtComponentChecksum": "[checksum]", - "componentName": "GreetingComponent", - "description": "A component that uses greeting utility", - "name": "greeting-component", - "sourceComponentPath": "src/components/greeting.front-component.tsx", - "universalIdentifier": "h0h1h2h3-h4h5-4000-8000-000000000001" - }, - { - "builtComponentPath": "front-components/src/components/test.front-component.mjs", - "builtComponentChecksum": "[checksum]", - "componentName": "TestComponent", - "description": "A test front component", - "name": "test-component", - "sourceComponentPath": "src/components/test.front-component.tsx", - "universalIdentifier": "f1234567-abcd-4000-8000-000000000001" - } - ], - "objectExtensions": [ - { - "fields": [ - { - "description": "Priority level for the post card (1-10)", - "label": "Priority", - "name": "priority", - "type": "NUMBER", - "universalIdentifier": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" - }, - { - "description": "Post card category", - "label": "Category", - "name": "category", - "options": [ - { - "color": "blue", - "label": "Personal", - "position": 0, - "value": "PERSONAL" - }, - { - "color": "green", - "label": "Business", - "position": 1, - "value": "BUSINESS" - }, - { - "color": "orange", - "label": "Promotional", - "position": 2, - "value": "PROMOTIONAL" - } - ], - "type": "SELECT", - "universalIdentifier": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e" - } - ], - "targetObject": { - "nameSingular": "postCard" - } - } - ], - "objects": [ - { - "description": "A simple root-level object", - "fields": [ - { - "label": "Title", - "name": "title", - "type": "TEXT", - "universalIdentifier": "b0b1b2b3-b4b5-4000-8000-000000000002" - }, - { - "label": "Body", - "name": "body", - "type": "TEXT", - "universalIdentifier": "b0b1b2b3-b4b5-4000-8000-000000000003" - } - ], - "icon": "IconNote", - "labelPlural": "Root notes", - "labelSingular": "Root note", - "namePlural": "rootNotes", - "nameSingular": "rootNote", - "universalIdentifier": "b0b1b2b3-b4b5-4000-8000-000000000001" - }, - { - "description": "A post card object", - "fields": [ - { - "description": "Postcard's content", - "icon": "IconAbc", - "label": "Content", - "name": "content", - "type": "TEXT", - "universalIdentifier": "58a0a314-d7ea-4865-9850-7fb84e72f30b" - }, - { - "icon": "IconUser", - "label": "Recipient name", - "name": "recipientName", - "type": "FULL_NAME", - "universalIdentifier": "c6aa31f3-da76-4ac6-889f-475e226009ac" - }, - { - "icon": "IconHome", - "label": "Recipient address", - "name": "recipientAddress", - "type": "ADDRESS", - "universalIdentifier": "95045777-a0ad-49ec-98f9-22f9fc0c8266" - }, - { - "defaultValue": "'DRAFT'", - "icon": "IconSend", - "label": "Status", - "name": "status", - "options": [ - { - "color": "gray", - "label": "Draft", - "position": 0, - "value": "DRAFT" - }, - { - "color": "orange", - "label": "Sent", - "position": 1, - "value": "SENT" - }, - { - "color": "green", - "label": "Delivered", - "position": 2, - "value": "DELIVERED" - }, - { - "color": "orange", - "label": "Returned", - "position": 3, - "value": "RETURNED" - } - ], - "type": "SELECT", - "universalIdentifier": "87b675b8-dd8c-4448-b4ca-20e5a2234a1e" - }, - { - "defaultValue": null, - "icon": "IconCheck", - "isNullable": true, - "label": "Delivered at", - "name": "deliveredAt", - "type": "DATE_TIME", - "universalIdentifier": "e06abe72-5b44-4e7f-93be-afc185a3c433" - } - ], - "icon": "IconMail", - "labelPlural": "Post cards", - "labelSingular": "Post card", - "namePlural": "postCards", - "nameSingular": "postCard", - "universalIdentifier": "54b589ca-eeed-4950-a176-358418b85c05" - } - ], - "packageJson": { - "name": "rich-app", - "version": "0.1.0", - "license": "MIT", - "engines": { - "node": "^24.5.0", - "npm": "please-use-yarn", - "yarn": ">=4.0.2" - }, - "packageManager": "yarn@4.9.2", - "scripts": { - "auth:login": "twenty auth:login", - "auth:logout": "twenty auth:logout", - "auth:status": "twenty auth:status", - "auth:switch": "twenty auth:switch", - "auth:list": "twenty auth:list", - "app:dev": "twenty app:dev", - "app:build": "twenty app:build", - "app:sync": "twenty app:sync", - "entity:add": "twenty entity:add", - "app:generate": "twenty app:generate", - "function:logs": "twenty function:logs", - "function:execute": "twenty function:execute", - "app:uninstall": "twenty app:uninstall", - "help": "twenty help", - "lint": "eslint", - "lint:fix": "eslint --fix" - }, - "dependencies": { - "twenty-sdk": "latest" - }, - "devDependencies": { - "typescript": "^5.9.3", - "@types/node": "^24.7.2", - "@types/react": "^19.0.2", - "react": "^19.0.2", - "eslint": "^9.32.0", - "typescript-eslint": "^8.50.0" - } - }, - "roles": [ - { - "canBeAssignedToAgents": false, - "canBeAssignedToApiKeys": false, - "canBeAssignedToUsers": true, - "canDestroyAllObjectRecords": false, - "canReadAllObjectRecords": true, - "canSoftDeleteAllObjectRecords": false, - "canUpdateAllObjectRecords": false, - "canUpdateAllSettings": false, - "description": "A simple root-level role", - "label": "Root role", - "universalIdentifier": "c0c1c2c3-c4c5-4000-8000-000000000001" - }, - { - "canBeAssignedToAgents": false, - "canBeAssignedToApiKeys": false, - "canBeAssignedToUsers": false, - "canDestroyAllObjectRecords": false, - "canReadAllObjectRecords": false, - "canSoftDeleteAllObjectRecords": false, - "canUpdateAllObjectRecords": false, - "canUpdateAllSettings": false, - "description": "Default role for function Twenty client", - "fieldPermissions": [ - { - "canReadFieldValue": false, - "canUpdateFieldValue": false, - "fieldName": "content", - "objectNameSingular": "postCard" - } - ], - "label": "Default function role", - "objectPermissions": [ - { - "canDestroyObjectRecords": false, - "canReadObjectRecords": true, - "canSoftDeleteObjectRecords": false, - "canUpdateObjectRecords": true, - "objectNameSingular": "postCard" - } - ], - "permissionFlags": [ - "APPLICATIONS" - ], - "universalIdentifier": "b648f87b-1d26-4961-b974-0908fd991061" - } - ], - "functions": [ - { - "builtHandlerChecksum": "[checksum]", - "builtHandlerPath": "functions/src/root.function.mjs", - "handlerName": "rootHandler", - "name": "root-function", - "sourceHandlerPath": "src/root.function.ts", - "timeoutSeconds": 5, - "triggers": [ - { - "httpMethod": "GET", - "isAuthRequired": false, - "path": "/root", - "type": "route", - "universalIdentifier": "f0f1f2f3-f4f5-4000-8000-000000000002" - } - ], - "universalIdentifier": "f0f1f2f3-f4f5-4000-8000-000000000001" - }, - { - "builtHandlerChecksum": "[checksum]", - "builtHandlerPath": "functions/src/functions/greeting.function.mjs", - "handlerName": "greetingHandler", - "name": "greeting-function", - "sourceHandlerPath": "src/functions/greeting.function.ts", - "timeoutSeconds": 5, - "triggers": [ - { - "httpMethod": "GET", - "isAuthRequired": false, - "path": "/greet", - "type": "route", - "universalIdentifier": "g0g1g2g3-g4g5-4000-8000-000000000002" - } - ], - "universalIdentifier": "g0g1g2g3-g4g5-4000-8000-000000000001" - }, - { - "builtHandlerChecksum": "[checksum]", - "builtHandlerPath": "functions/src/functions/test-function-2.function.mjs", - "handlerName": "testFunction2", - "name": "test-function-2", - "sourceHandlerPath": "src/utils/test-function-2.util.ts", - "timeoutSeconds": 2, - "triggers": [ - { - "pattern": "0 0 1 1 *", - "type": "cron", - "universalIdentifier": "9fd0dda9-4664-4fbc-9656-509f4477b9ff" - } - ], - "universalIdentifier": "eb3ffc98-88ec-45d4-9b4a-56833b219ccb" - }, - { - "builtHandlerChecksum": "[checksum]", - "builtHandlerPath": "functions/src/functions/test-function.function.mjs", - "handlerName": "handler", - "name": "test-function", - "sourceHandlerPath": "src/functions/test-function.function.ts", - "timeoutSeconds": 2, - "triggers": [ - { - "forwardedRequestHeaders": [ - "signature" - ], - "httpMethod": "GET", - "isAuthRequired": false, - "path": "/post-card/create", - "type": "route", - "universalIdentifier": "c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6" - }, - { - "pattern": "0 0 1 1 *", - "type": "cron", - "universalIdentifier": "dd802808-0695-49e1-98c9-d5c9e2704ce2" - }, - { - "eventName": "person.created", - "type": "databaseEvent", - "universalIdentifier": "203f1df3-4a82-4d06-a001-b8cf22a31156" - } - ], - "universalIdentifier": "e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf" - } - ] -} 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 index 36ab0d70db..2744d2cdfb 100644 --- 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 @@ -14,34 +14,23 @@ export const defineConsoleOutputTests = ( expect(output).toContain('[init] 📁 App Path:'); }); - it('should contain manifest-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'manifest-watch'); + it('should contain dev-mode build messages', () => { + const output = getOutputByPrefix(getResult().output, 'dev-mode'); - expect(output).toContain('[manifest-watch] 🔄 Building...'); - expect(output).toContain('[manifest-watch] ✓ Loaded "Hello World"'); - expect(output).toContain('[manifest-watch] ✓ Found 2 object(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 4 function(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 4 front component(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 2 role(s)'); - expect(output).toContain('[manifest-watch] ✓ Written to'); - expect(output).toContain('[manifest-watch] 📂 Watcher started'); + expect(output).toContain('[dev-mode] Building manifest...'); + expect(output).toContain('[dev-mode] Successfully built manifest'); }); - it('should contain functions-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'functions-watch'); + it('should contain dev-mode function build messages', () => { + const output = getOutputByPrefix(getResult().output, 'dev-mode'); - expect(output).toContain('[functions-watch] 📦 Building...'); - expect(output).toContain('[functions-watch] ✓ Built'); + expect(output).toContain('[dev-mode] ✓ Successfully built'); }); - it('should contain front-components-watch messages', () => { - const output = getOutputByPrefix( - getResult().output, - 'front-components-watch', - ); + it('should contain dev-mode sync messages', () => { + const output = getOutputByPrefix(getResult().output, 'dev-mode'); - expect(output).toContain('[front-components-watch] 🎨 Building...'); - expect(output).toContain('[front-components-watch] ✓ Built'); + expect(output).toContain('[dev-mode] ✓ Synced'); }); }); }; 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/entities.tests.ts similarity index 54% rename from packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/functions.tests.ts rename to packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts index ffb4c653b5..eac14951c9 100644 --- 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/entities.tests.ts @@ -1,15 +1,24 @@ import * as fs from 'fs-extra'; import { join } from 'path'; +import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; -export const defineFunctionsTests = (appPath: string): void => { +export const defineEntitiesTests = (appPath: string): void => { + const outputDir = join(appPath, OUTPUT_DIR); 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 files = await fs.readdir(outputDir, { recursive: true }); const sortedFiles = files.map((f) => f.toString()).sort(); expect(sortedFiles).toEqual([ + 'manifest.json', '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/functions', 'src/functions/greeting.function.mjs', 'src/functions/greeting.function.mjs.map', @@ -17,19 +26,23 @@ export const defineFunctionsTests = (appPath: string): void => { 'src/functions/test-function-2.function.mjs.map', 'src/functions/test-function.function.mjs', 'src/functions/test-function.function.mjs.map', + 'src/root.front-component.mjs', + 'src/root.front-component.mjs.map', 'src/root.function.mjs', 'src/root.function.mjs.map', ]); }); it('should not create shared chunk files for utilities', async () => { - const functionsDir = join(appPath, '.twenty/output/functions'); - const files = await fs.readdir(functionsDir, { recursive: true }); + const files = await fs.readdir(outputDir, { recursive: true }); // Chunk files have a hash suffix like "greeting.util-CipJsYK0.mjs" const chunkFiles = files .map((f) => f.toString()) - .filter((f) => f.endsWith('.mjs') && !f.includes('.function.mjs')); + .filter((f) => f.endsWith('.mjs') && !f.includes('.function.mjs')) + .filter( + (f) => f.endsWith('.mjs') && !f.includes('.front-component.mjs'), + ); expect(chunkFiles).toEqual([]); }); 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 deleted file mode 100644 index 368362be8c..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/front-components.tests.ts +++ /dev/null @@ -1,28 +0,0 @@ -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/src/functions/greeting.function.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/greeting.function.ts index f38a7bef9b..8c35f8132a 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/greeting.function.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/functions/greeting.function.ts @@ -6,13 +6,13 @@ const greetingHandler = () => { }; export default defineFunction({ - universalIdentifier: 'g0g1g2g3-g4g5-4000-8000-000000000001', + universalIdentifier: '9d412d9e-2caf-487c-8b66-d1585883dd4e', name: 'greeting-function', timeoutSeconds: 5, handler: greetingHandler, triggers: [ { - universalIdentifier: 'g0g1g2g3-g4g5-4000-8000-000000000002', + universalIdentifier: 'f7b12160-3d25-4c92-a13f-a325dd60eb04', type: 'route', path: '/greet', httpMethod: 'GET', diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/utils/test-function-2.util.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/utils/test-function-2.util.ts index 8849aafbd6..fb2f09cf64 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/utils/test-function-2.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/utils/test-function-2.util.ts @@ -1,7 +1,3 @@ export const testFunction2 = () => { - const Twenty = require('../../generated').default; - - const client = new Twenty(); - - return client.query('testQuery'); + return 'test-function-2-result'; }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/app-build.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/app-build.integration.spec.ts deleted file mode 100644 index 3de298b438..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/app-build.integration.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { join } from 'path'; - -import { runAppBuild } from '@/cli/__tests__/integration/utils/run-app-build.util'; -import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; -import { defineConsoleOutputTests } from './tests/console-output.tests'; -import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests'; -import { defineFunctionsTests } from '../app-dev/tests/functions.tests'; -import { defineManifestTests } from '../app-dev/tests/manifest.tests'; - -const APP_PATH = join(__dirname, '../..'); - -describe('root-app app:build', () => { - let result: RunCliCommandResult; - - beforeAll(async () => { - result = await runAppBuild({ 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-build/tests/console-output.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/tests/console-output.tests.ts deleted file mode 100644 index 823fb7aa05..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-build/tests/console-output.tests.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util'; -import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util'; - -export const defineConsoleOutputTests = ( - getResult: () => RunCliCommandResult, -): void => { - describe('console output', () => { - it('should contain init messages', () => { - const output = getOutputByPrefix(getResult().output, 'init'); - - expect(output).toContain('[init] 🚀 Building Twenty Application'); - expect(output).toContain('[init] 📁 App Path:'); - expect(output).toContain('[init] ✅ Build completed successfully'); - }); - - it('should contain manifest-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'manifest-watch'); - - expect(output).toContain('[manifest-watch] 🔄 Building...'); - expect(output).toContain('[manifest-watch] ✓ Loaded "Root App"'); - expect(output).toContain('[manifest-watch] ✓ Found 1 object(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 1 function(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 1 front component(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 1 role(s)'); - expect(output).toContain('[manifest-watch] ✓ Written to'); - }); - - it('should contain functions-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'functions-watch'); - - expect(output).toContain('[functions-watch] 📦 Building...'); - expect(output).toContain('[functions-watch] ✓ Built'); - }); - - it('should contain front-components-watch messages', () => { - const output = getOutputByPrefix( - getResult().output, - 'front-components-watch', - ); - - expect(output).toContain('[front-components-watch] 🎨 Building...'); - expect(output).toContain('[front-components-watch] ✓ Built'); - }); - - it('should not contain watching messages', () => { - const output = getResult().output; - - expect(output).not.toContain('👀 Watching for changes...'); - expect(output).not.toContain('📂 Watcher started'); - }); - }); -}; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts new file mode 100644 index 0000000000..610d85f121 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts @@ -0,0 +1,119 @@ +import { FieldType } from '@/application'; +import type { ApplicationManifest } from 'twenty-shared/application'; + +export const EXPECTED_MANIFEST: ApplicationManifest = { + sources: {}, + yarnLock: '', + packageJson: { + name: 'root-app', + version: '0.1.0', + license: 'MIT', + engines: { + node: '^24.5.0', + npm: 'please-use-yarn', + yarn: '>=4.0.2', + }, + packageManager: 'yarn@4.9.2', + scripts: { + 'auth:login': 'twenty auth:login', + 'auth:logout': 'twenty auth:logout', + 'auth:status': 'twenty auth:status', + 'auth:switch': 'twenty auth:switch', + 'auth:list': 'twenty auth:list', + 'app:dev': 'twenty app:dev', + 'app:build': 'twenty app:build', + 'app:sync': 'twenty app:sync', + 'entity:add': 'twenty entity:add', + 'app:generate': 'twenty app:generate', + 'function:logs': 'twenty function:logs', + 'function:execute': 'twenty function:execute', + 'app:uninstall': 'twenty app:uninstall', + help: 'twenty help', + lint: 'eslint', + 'lint:fix': 'eslint --fix', + }, + dependencies: { + 'twenty-sdk': 'latest', + }, + devDependencies: { + typescript: '^5.9.3', + '@types/node': '^24.7.2', + '@types/react': '^19.0.2', + react: '^19.0.2', + eslint: '^9.32.0', + 'typescript-eslint': '^8.50.0', + }, + }, + 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: FieldType.TEXT, + label: 'Title', + name: 'title', + }, + ], + }, + ], + functions: [ + { + 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: 'default.handler', + sourceHandlerPath: 'my.function.ts', + builtHandlerPath: 'my.function.mjs', + builtHandlerChecksum: '[checksum]', + }, + ], + frontComponents: [ + { + universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000020', + name: 'my-component', + description: 'A root-level front component', + componentName: 'MyComponent', + sourceComponentPath: 'my.front-component.tsx', + builtComponentPath: 'my.front-component.mjs', + builtComponentChecksum: '[checksum]', + }, + ], + 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/manifest.expected.json b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/manifest.expected.json deleted file mode 100644 index 615da3609b..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/manifest.expected.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "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" - } - ] - } - ], - "functions": [ - { - "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", - "sourceHandlerPath": "my.function.ts", - "builtHandlerPath": "functions/my.function.mjs", - "builtHandlerChecksum": "[checksum]" - } - ], - "frontComponents": [ - { - "universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000020", - "name": "my-component", - "description": "A root-level front component", - "componentName": "MyComponent", - "sourceComponentPath": "my.front-component.tsx", - "builtComponentPath": "front-components/my.front-component.mjs", - "builtComponentChecksum": "[checksum]" - } - ], - "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 index 7412699d79..2744d2cdfb 100644 --- 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 @@ -14,34 +14,23 @@ export const defineConsoleOutputTests = ( expect(output).toContain('[init] 📁 App Path:'); }); - it('should contain manifest-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'manifest-watch'); + it('should contain dev-mode build messages', () => { + const output = getOutputByPrefix(getResult().output, 'dev-mode'); - expect(output).toContain('[manifest-watch] 🔄 Building...'); - expect(output).toContain('[manifest-watch] ✓ Loaded "Root App"'); - expect(output).toContain('[manifest-watch] ✓ Found 1 object(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 1 function(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 1 front component(s)'); - expect(output).toContain('[manifest-watch] ✓ Found 1 role(s)'); - expect(output).toContain('[manifest-watch] ✓ Written to'); - expect(output).toContain('[manifest-watch] 📂 Watcher started'); + expect(output).toContain('[dev-mode] Building manifest...'); + expect(output).toContain('[dev-mode] Successfully built manifest'); }); - it('should contain functions-watch messages', () => { - const output = getOutputByPrefix(getResult().output, 'functions-watch'); + it('should contain dev-mode function build messages', () => { + const output = getOutputByPrefix(getResult().output, 'dev-mode'); - expect(output).toContain('[functions-watch] 📦 Building...'); - expect(output).toContain('[functions-watch] ✓ Built'); + expect(output).toContain('[dev-mode] ✓ Successfully built'); }); - it('should contain front-components-watch messages', () => { - const output = getOutputByPrefix( - getResult().output, - 'front-components-watch', - ); + it('should contain dev-mode sync messages', () => { + const output = getOutputByPrefix(getResult().output, 'dev-mode'); - expect(output).toContain('[front-components-watch] 🎨 Building...'); - expect(output).toContain('[front-components-watch] ✓ Built'); + expect(output).toContain('[dev-mode] ✓ Synced'); }); }); }; 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 index 3ccf4c2a0b..fe8dd8e738 100644 --- 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 @@ -4,14 +4,14 @@ 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(); + const outputDir = join(appPath, '.twenty/output'); + const files = await fs.readdir(outputDir, { recursive: true }); + const componentFiles = files + .map((f) => f.toString()) + .filter((f) => f.includes('.front-component.')) + .sort(); - expect(sortedFiles).toEqual([ + expect(componentFiles).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 index acb83a859d..d99a87505e 100644 --- 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 @@ -4,11 +4,14 @@ 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(); + const outputDir = join(appPath, '.twenty/output'); + const files = await fs.readdir(outputDir, { recursive: true }); + const functionFiles = files + .map((f) => f.toString()) + .filter((f) => f.includes('.function.')) + .sort(); - expect(sortedFiles).toEqual(['my.function.mjs', 'my.function.mjs.map']); + expect(functionFiles).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 index 7a05d61d02..57af6e942b 100644 --- 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 @@ -3,6 +3,7 @@ import { join } from 'path'; import { type ApplicationManifest } from 'twenty-shared/application'; import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util'; +import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest'; export const defineManifestTests = (appPath: string): void => { describe('manifest', () => { @@ -16,21 +17,17 @@ export const defineManifestTests = (appPath: string): void => { 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.application).toEqual(EXPECTED_MANIFEST.application); + expect(manifest.objects).toEqual(EXPECTED_MANIFEST.objects); expect( normalizeManifestForComparison({ functions: manifest.functions }) .functions, ).toEqual( - normalizeManifestForComparison({ functions: expected.functions }) - .functions, + normalizeManifestForComparison({ + functions: EXPECTED_MANIFEST.functions, + }).functions, ); for (const fn of manifest.functions) { @@ -45,7 +42,7 @@ export const defineManifestTests = (appPath: string): void => { }).frontComponents, ).toEqual( normalizeManifestForComparison({ - frontComponents: expected.frontComponents, + frontComponents: EXPECTED_MANIFEST.frontComponents, }).frontComponents, ); @@ -54,7 +51,7 @@ export const defineManifestTests = (appPath: string): void => { expect(component.builtComponentChecksum).not.toBeNull(); expect(typeof component.builtComponentChecksum).toBe('string'); } - expect(manifest.roles).toEqual(expected.roles); + expect(manifest.roles).toEqual(EXPECTED_MANIFEST.roles); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/yarn.lock b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/yarn.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts index e37e93043e..2232131c49 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/get-output-by-prefix.util.ts @@ -1,5 +1,6 @@ export type LogPrefix = | 'init' + | 'dev-mode' | 'manifest-watch' | 'functions-watch' | 'front-components-watch'; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-build.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-build.util.ts deleted file mode 100644 index ae59fa449d..0000000000 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-build.util.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - runCliCommand, - type RunCliCommandResult, -} from './run-cli-command.util'; - -export type RunAppBuildOptions = { - appPath: string; - timeout?: number; -}; - -export const runAppBuild = ( - options: RunAppBuildOptions, -): Promise => { - const { appPath, timeout = 30000 } = options; - - // app:build runs once and exits, so we don't wait for specific output - return runCliCommand({ - command: 'app:build', - args: [appPath], - timeout, - }); -}; 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 index d319a35ebb..fdf6c7c8cb 100644 --- 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 @@ -16,11 +16,7 @@ export const runAppDev = ( return runCliCommand({ command: 'app:dev', args: [appPath], - waitForOutput: [ - '[manifest-watch] ✓ Written to', - '[functions-watch] ✓ Built', - '[front-components-watch] ✓ Built', - ], + waitForOutput: ['[dev-mode] ✓ Synced'], 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 index e88bb8d588..6346d07392 100644 --- 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 @@ -33,7 +33,11 @@ export const runCliCommand = ( { cwd: CLI_DIR, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, FORCE_COLOR: '0' }, + env: { + ...process.env, + FORCE_COLOR: '0', + TWENTY_SKIP_SERVER_CHECK: 'true', + }, }, ); diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index d389481c9d..ac4c0398db 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -1,10 +1,8 @@ import { formatPath } from '@/cli/utilities/file/file-path'; import chalk from 'chalk'; import type { Command } from 'commander'; -import { AppBuildCommand } from './app/app-build'; import { AppDevCommand } from './app/app-dev'; import { AppGenerateCommand } from './app/app-generate'; -import { AppSyncCommand } from './app/app-sync'; import { AppUninstallCommand } from './app/app-uninstall'; import { AuthListCommand } from './auth/auth-list'; import { AuthLoginCommand } from './auth/auth-login'; @@ -66,13 +64,11 @@ export const registerCommands = (program: Command): void => { // App commands const devCommand = new AppDevCommand(); - const syncCommand = new AppSyncCommand(); const uninstallCommand = new AppUninstallCommand(); const addCommand = new EntityAddCommand(); const generateCommand = new AppGenerateCommand(); const logsCommand = new FunctionLogsCommand(); const executeCommand = new FunctionExecuteCommand(); - const buildCommand = new AppBuildCommand(); program .command('app:dev [appPath]') @@ -83,35 +79,6 @@ export const registerCommands = (program: Command): void => { }); }); - program - .command('app:build [appPath]') - .description('Build application for deployment') - .option('-w, --watch', 'Watch for changes and rebuild') - .option('-t, --tarball', 'Create a tarball after build') - .action(async (appPath, options) => { - try { - const result = await buildCommand.execute({ - ...options, - appPath: formatPath(appPath), - }); - process.exit(result.success ? 0 : 1); - } catch { - process.exit(1); - } - }); - - program - .command('app:sync [appPath]') - .description('Sync application to Twenty') - .action(async (appPath?: string) => { - try { - const result = await syncCommand.execute(formatPath(appPath)); - process.exit(result.success ? 0 : 1); - } catch { - process.exit(1); - } - }); - program .command('app:uninstall [appPath]') .description('Uninstall application from Twenty') diff --git a/packages/twenty-sdk/src/cli/commands/app/app-build.ts b/packages/twenty-sdk/src/cli/commands/app/app-build.ts deleted file mode 100644 index e16d870ab1..0000000000 --- a/packages/twenty-sdk/src/cli/commands/app/app-build.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; -import { createLogger } from '@/cli/utilities/build/common/logger'; -import { FrontComponentsWatcher } from '@/cli/utilities/build/front-components/front-component-watcher'; -import { FunctionsWatcher } from '@/cli/utilities/build/functions/function-watcher'; -import { - type ManifestBuildResult, - runManifestBuild, - updateManifestChecksum, -} from '@/cli/utilities/build/manifest/manifest-build'; -import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server'; -import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; -import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; - -const initLogger = createLogger('init'); - -export type AppBuildOptions = { - appPath?: string; -}; - -export class AppBuildCommand { - private functionsBuilder: FunctionsWatcher | null = null; - private frontComponentsBuilder: FrontComponentsWatcher | null = null; - - private appPath: string = ''; - - async execute( - options: AppBuildOptions, - ): Promise> { - this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - - initLogger.log('🚀 Building Twenty Application'); - initLogger.log(`📁 App Path: ${this.appPath}`); - console.log(''); - - const buildResult = await this.runBuild(); - - if (!buildResult) { - return { success: false, error: 'Build failed' }; - } - - initLogger.success('✅ Build completed successfully'); - - return { success: true, data: buildResult }; - } - - private async runBuild(): Promise { - const buildResult = await runManifestBuild(this.appPath); - - if (!buildResult.manifest) { - return null; - } - - await this.buildFunctions(buildResult); - await this.buildFrontComponents(buildResult); - await writeManifestToOutput(this.appPath, buildResult.manifest); - await this.cleanup(); - - return buildResult; - } - - private async buildFunctions( - buildResult: ManifestBuildResult, - ): Promise { - this.functionsBuilder = new FunctionsWatcher({ - appPath: this.appPath, - sourcePaths: buildResult.filePaths.functions, - watch: false, - onFileBuilt: (builtPath, checksum) => { - if (buildResult.manifest) { - const updatedManifest = updateManifestChecksum({ - manifest: buildResult.manifest, - entityType: 'function', - builtPath, - checksum, - }); - - if (updatedManifest) { - buildResult.manifest = updatedManifest; - } - } - }, - }); - - await this.functionsBuilder.start(); - } - - private async buildFrontComponents( - buildResult: ManifestBuildResult, - ): Promise { - this.frontComponentsBuilder = new FrontComponentsWatcher({ - appPath: this.appPath, - sourcePaths: buildResult.filePaths.frontComponents, - watch: false, - onFileBuilt: (builtPath, checksum) => { - if (buildResult.manifest) { - const updatedManifest = updateManifestChecksum({ - manifest: buildResult.manifest, - entityType: 'frontComponent', - builtPath, - checksum, - }); - if (updatedManifest) { - buildResult.manifest = updatedManifest; - } - } - }, - }); - - await this.frontComponentsBuilder.start(); - } - - private async cleanup(): Promise { - await this.functionsBuilder?.close(); - await this.frontComponentsBuilder?.close(); - await manifestExtractFromFileServer.closeViteServer(); - } -} 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 2c8b0361e3..23f182d998 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,14 +1,17 @@ import { createLogger } from '@/cli/utilities/build/common/logger'; -import { FrontComponentsWatcher } from '@/cli/utilities/build/front-components/front-component-watcher'; -import { FunctionsWatcher } from '@/cli/utilities/build/functions/function-watcher'; import { - runManifestBuild, - updateManifestChecksum, -} from '@/cli/utilities/build/manifest/manifest-build'; + createFrontComponentsWatcher, + createFunctionsWatcher, + type EsbuildWatcher, +} from '@/cli/utilities/build/common/esbuild-watcher'; +import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-build'; import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher'; -import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; -import { type ApplicationManifest } from 'twenty-shared/application'; +import { DevModeOrchestrator } from '@/cli/utilities/dev/dev-mode-orchestrator'; +import { ApiService } from '@/cli/utilities/api/api-service'; +import path from 'path'; +import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; +import * as fs from 'fs-extra'; const initLogger = createLogger('init'); @@ -16,140 +19,104 @@ export type AppDevOptions = { appPath?: string; }; -export type FileStatus = { - sourcePath: string; - builtPath: string; - checksum: string | null; - isUploaded: boolean; -}; - -export type FileStatusMaps = { - functions: Map; - frontComponents: Map; -}; - -type AppDevState = { - manifest: ApplicationManifest | null; - fileStatusMaps: FileStatusMaps; -}; - export class AppDevCommand { + private appPath = ''; + private orchestrator: DevModeOrchestrator | null = null; private manifestWatcher: ManifestWatcher | null = null; - private functionsWatcher: FunctionsWatcher | null = null; - private frontComponentsWatcher: FrontComponentsWatcher | null = null; - - private appPath: string = ''; - private state: AppDevState = { - manifest: null, - fileStatusMaps: { - functions: new Map(), - frontComponents: new Map(), - }, - }; + private functionsWatcher: EsbuildWatcher | null = null; + private frontComponentsWatcher: EsbuildWatcher | null = null; + private watchersStarted = false; + private apiService = new ApiService(); async execute(options: AppDevOptions): Promise { this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; + await this.checkServer(); initLogger.log('🚀 Starting Twenty Application Development Mode'); initLogger.log(`📁 App Path: ${this.appPath}`); console.log(''); - await this.startWatchers(); + await this.cleanOutputDir(); + this.orchestrator = new DevModeOrchestrator({ + appPath: this.appPath, + handleManifestBuilt: this.handleWatcherRestarts.bind(this), + }); + + await this.startManifestWatcher(); this.setupGracefulShutdown(); } - private async startWatchers(): Promise { - const buildResult = await runManifestBuild(this.appPath); - - if (!buildResult.manifest) { + private async checkServer(): Promise { + if (process.env.TWENTY_SKIP_SERVER_CHECK === 'true') { return; } - this.state.manifest = buildResult.manifest; - this.initializeFunctionsFileUploadStatus(buildResult.manifest); - this.initializeFrontComponentsFileUploadStatus(buildResult.manifest); + const isAuthenticated = await this.apiService.validateAuth(); - await this.startManifestWatcher(); - await this.startFunctionsWatcher(buildResult.filePaths.functions); - await this.startFrontComponentsWatcher( - buildResult.filePaths.frontComponents, - ); - } - - private initializeFunctionsFileUploadStatus( - manifest: ApplicationManifest, - ): void { - this.state.fileStatusMaps.functions.clear(); - - for (const fn of manifest.functions ?? []) { - this.state.fileStatusMaps.functions.set(fn.universalIdentifier, { - sourcePath: fn.sourceHandlerPath, - builtPath: fn.builtHandlerPath, - checksum: null, - isUploaded: false, - }); - } - } - - private initializeFrontComponentsFileUploadStatus( - manifest: ApplicationManifest, - ): void { - this.state.fileStatusMaps.frontComponents.clear(); - - for (const component of manifest.frontComponents ?? []) { - this.state.fileStatusMaps.frontComponents.set( - component.universalIdentifier, - { - sourcePath: component.sourceComponentPath, - builtPath: component.builtComponentPath, - checksum: null, - isUploaded: false, - }, + if (!isAuthenticated) { + initLogger.error( + 'Please check your server is up and your credentials are correct.', ); + process.exit(1); } } + private async cleanOutputDir() { + const outputDir = path.join(this.appPath, OUTPUT_DIR); + await fs.ensureDir(outputDir); + await fs.emptyDir(outputDir); + } + private async startManifestWatcher(): Promise { this.manifestWatcher = new ManifestWatcher({ appPath: this.appPath, - callbacks: { - onBuildSuccess: (result) => { - this.state.manifest = result.manifest; - - const functionSourcePaths = result.filePaths.functions; - const shouldRestartFunctions = - this.functionsWatcher?.shouldRestart(functionSourcePaths); - if (shouldRestartFunctions) { - if (result.manifest) { - this.initializeFunctionsFileUploadStatus(result.manifest); - } - this.functionsWatcher?.restart(functionSourcePaths); - } - - const componentSourcePaths = result.filePaths.frontComponents; - const shouldRestartFrontComponents = - this.frontComponentsWatcher?.shouldRestart(componentSourcePaths); - if (shouldRestartFrontComponents) { - if (result.manifest) { - this.initializeFrontComponentsFileUploadStatus(result.manifest); - } - this.frontComponentsWatcher?.restart(componentSourcePaths); - } - }, - }, + handleChangeDetected: this.orchestrator!.handleChangeDetected.bind( + this.orchestrator, + ), }); await this.manifestWatcher.start(); } + private async handleWatcherRestarts(result: ManifestBuildResult) { + const { functions, frontComponents } = result.filePaths; + + if (!this.watchersStarted) { + this.watchersStarted = true; + await this.startFileWatchers(functions, frontComponents); + return; + } + + if (this.functionsWatcher?.shouldRestart(functions)) { + await this.functionsWatcher.restart(functions); + } + + if (this.frontComponentsWatcher?.shouldRestart(frontComponents)) { + await this.frontComponentsWatcher.restart(frontComponents); + } + } + + private async startFileWatchers( + functions: string[], + frontComponents: string[], + ): Promise { + await Promise.all([ + this.startFunctionsWatcher(functions), + this.startFrontComponentsWatcher(frontComponents), + ]); + } + private async startFunctionsWatcher(sourcePaths: string[]): Promise { - this.functionsWatcher = new FunctionsWatcher({ + this.functionsWatcher = createFunctionsWatcher({ appPath: this.appPath, sourcePaths, - onFileBuilt: async (builtPath, checksum) => { - await this.updateFileStatus('function', builtPath, checksum); - }, + handleBuildError: this.orchestrator!.handleFileBuildError.bind( + this.orchestrator, + ), + handleFileBuilt: this.orchestrator!.handleFileBuilt.bind( + this.orchestrator, + ), }); await this.functionsWatcher.start(); @@ -158,76 +125,35 @@ export class AppDevCommand { private async startFrontComponentsWatcher( sourcePaths: string[], ): Promise { - this.frontComponentsWatcher = new FrontComponentsWatcher({ + this.frontComponentsWatcher = createFrontComponentsWatcher({ appPath: this.appPath, sourcePaths, - onFileBuilt: async (builtPath, checksum) => { - await this.updateFileStatus('frontComponent', builtPath, checksum); - }, + handleBuildError: this.orchestrator!.handleFileBuildError.bind( + this.orchestrator, + ), + handleFileBuilt: this.orchestrator!.handleFileBuilt.bind( + this.orchestrator, + ), }); await this.frontComponentsWatcher.start(); } - private async updateFileStatus( - entityType: 'function' | 'frontComponent', - builtPath: string, - checksum: string, - ): Promise { - const statusMap = - entityType === 'function' - ? this.state.fileStatusMaps.functions - : this.state.fileStatusMaps.frontComponents; - - for (const [_id, status] of statusMap) { - if (status.builtPath === builtPath) { - status.checksum = checksum; - status.isUploaded = false; - break; - } - } - - const manifest = this.state.manifest; - if (manifest) { - const updatedManifest = updateManifestChecksum({ - manifest, - entityType, - builtPath, - checksum, - }); - if (updatedManifest) { - this.state.manifest = updatedManifest; - await writeManifestToOutput(this.appPath, updatedManifest); - } - } - } - - private markFileAsUploaded( - entityType: 'function' | 'frontComponent', - builtPath: string, - success: boolean, - ): void { - const statusMap = - entityType === 'function' - ? this.state.fileStatusMaps.functions - : this.state.fileStatusMaps.frontComponents; - - for (const [_id, status] of statusMap) { - if (status.builtPath === builtPath) { - status.isUploaded = success; - break; - } - } - } - private setupGracefulShutdown(): void { - const shutdown = () => { + const shutdown = async () => { console.log(''); initLogger.warn('🛑 Stopping...'); + + await Promise.all([ + this.manifestWatcher?.close(), + this.functionsWatcher?.close(), + this.frontComponentsWatcher?.close(), + ]); + process.exit(0); }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); + process.on('SIGINT', () => void shutdown()); + process.on('SIGTERM', () => void shutdown()); } } diff --git a/packages/twenty-sdk/src/cli/commands/app/app-publish.ts b/packages/twenty-sdk/src/cli/commands/app/app-publish.ts deleted file mode 100644 index 261761141e..0000000000 --- a/packages/twenty-sdk/src/cli/commands/app/app-publish.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Placeholder for app publish command -// TODO: Implement application publishing functionality - -export class AppPublishCommand { - async execute(): Promise { - throw new Error('Not implemented'); - } -} diff --git a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts b/packages/twenty-sdk/src/cli/commands/app/app-sync.ts deleted file mode 100644 index b05ee2a0c8..0000000000 --- a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; -import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; -import chalk from 'chalk'; -import * as fs from 'fs-extra'; -import path from 'path'; -import { AppBuildCommand } from '@/cli/commands/app/app-build'; -import { FileUploader } from '@/cli/utilities/file/file-uploader'; -import { ApiService } from '@/cli/utilities/api/api-service'; - -export class AppSyncCommand { - private apiService = new ApiService(); - private buildCommand = new AppBuildCommand(); - - async execute( - appPath: string = CURRENT_EXECUTION_DIRECTORY, - ): Promise> { - console.log(chalk.blue('🚀 Syncing Twenty Application')); - console.log(''); - - const result = await this.buildCommand.execute({ - appPath, - }); - - if (!result.success) { - return result; - } - - const manifest = result.data.manifest; - - if (!manifest) { - return { success: false, error: 'No manifest found. Build failed?' }; - } - - const uploadService = new FileUploader({ - applicationUniversalIdentifier: manifest.application.universalIdentifier, - appPath, - }); - - await uploadService.uploadManifestBuiltFiles(manifest); - - const yarnLockPath = path.join(appPath, 'yarn.lock'); - let yarnLock = ''; - - if (await fs.pathExists(yarnLockPath)) { - yarnLock = await fs.readFile(yarnLockPath, 'utf8'); - } - - const syncResult = await this.apiService.syncApplication({ - manifest, - yarnLock, - }); - - if (!syncResult.success) { - console.error(chalk.red('❌ Application Sync failed:'), syncResult.error); - } else { - console.log(chalk.green('✅ Application synced successfully')); - } - - return syncResult; - } -} diff --git a/packages/twenty-sdk/src/cli/commands/app/app-test.ts b/packages/twenty-sdk/src/cli/commands/app/app-test.ts deleted file mode 100644 index a597ebb0ca..0000000000 --- a/packages/twenty-sdk/src/cli/commands/app/app-test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Placeholder for app test command -// TODO: Implement application testing functionality - -export class AppTestCommand { - async execute(): Promise { - throw new Error('Not implemented'); - } -} 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 e5a2562a21..c6f6fa8c36 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts @@ -25,10 +25,7 @@ export class AppUninstallCommand { process.exit(1); } - const { manifest } = await runManifestBuild(appPath, { - display: false, - writeOutput: false, - }); + const { manifest } = await runManifestBuild(appPath); if (!manifest) { return { success: false, error: 'Build failed' }; 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 1452cce725..57ca8e7907 100644 --- a/packages/twenty-sdk/src/cli/commands/function/function-logs.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-logs.ts @@ -16,10 +16,7 @@ export class FunctionLogsCommand { functionName?: string; }): Promise { try { - const { manifest } = await runManifestBuild(appPath, { - display: false, - writeOutput: false, - }); + const { manifest } = await runManifestBuild(appPath); if (!manifest) { process.exit(1); diff --git a/packages/twenty-sdk/src/cli/commands/instance/instance-run.ts b/packages/twenty-sdk/src/cli/commands/instance/instance-run.ts deleted file mode 100644 index f197ccaefc..0000000000 --- a/packages/twenty-sdk/src/cli/commands/instance/instance-run.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Placeholder for instance run command -// TODO: Implement instance run functionality - -export class InstanceRunCommand { - async execute(): Promise { - throw new Error('Not implemented'); - } -} diff --git a/packages/twenty-sdk/src/cli/commands/instance/instance-setup.ts b/packages/twenty-sdk/src/cli/commands/instance/instance-setup.ts deleted file mode 100644 index 6a55397e88..0000000000 --- a/packages/twenty-sdk/src/cli/commands/instance/instance-setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Placeholder for instance setup command -// TODO: Implement instance setup functionality - -export class InstanceSetupCommand { - async execute(): Promise { - throw new Error('Not implemented'); - } -} diff --git a/packages/twenty-sdk/src/cli/commands/instance/instance-upgrade.ts b/packages/twenty-sdk/src/cli/commands/instance/instance-upgrade.ts deleted file mode 100644 index 3efa9fa292..0000000000 --- a/packages/twenty-sdk/src/cli/commands/instance/instance-upgrade.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Placeholder for instance upgrade command -// TODO: Implement instance upgrade functionality - -export class InstanceUpgradeCommand { - async execute(): Promise { - throw new Error('Not implemented'); - } -} diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index dbcffb9987..63e47a36ae 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -88,13 +88,7 @@ export class ApiService { } } - async syncApplication({ - manifest, - yarnLock, - }: { - manifest: ApplicationManifest; - yarnLock: string; - }): Promise { + async syncApplication(manifest: ApplicationManifest): Promise { try { const mutation = ` mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) { @@ -105,7 +99,7 @@ export class ApiService { const variables = { manifest, packageJson: manifest.packageJson, - yarnLock, + yarnLock: manifest.yarnLock, }; const response: AxiosResponse = await this.client.post( diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts b/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts index 517822b513..cf113ee78c 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import * as fs from 'fs-extra'; import path from 'path'; @@ -17,7 +16,5 @@ export const cleanupRemovedFiles = async ( await fs.remove(outputFile); await fs.remove(sourceMapFile); - - console.log(chalk.gray(` 🗑️ Removed ${outputBaseName}`)); } }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts index 040849717c..2fc66795be 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts @@ -3,57 +3,52 @@ import type * as esbuild from 'esbuild'; import * as fs from 'fs-extra'; import path from 'path'; import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface'; +import { type FileFolder } from 'twenty-shared/types'; export type ProcessEsbuildResultParams = { result: esbuild.BuildResult; - outputDir: string; - builtDir: string; + appPath: string; + fileFolder: FileFolder; lastChecksums: Map; onFileBuilt?: OnFileBuiltCallback; - onSuccess: (relativePath: string) => void; -}; - -export type ProcessEsbuildResultOutput = { - hasChanges: boolean; }; export const processEsbuildResult = async ({ result, - outputDir, - builtDir, + appPath, + fileFolder, lastChecksums, onFileBuilt, - onSuccess, -}: ProcessEsbuildResultParams): Promise => { +}: ProcessEsbuildResultParams) => { const outputFiles = Object.keys(result.metafile?.outputs ?? {}).filter( (file) => file.endsWith('.mjs'), ); - let hasChanges = false; - for (const outputFile of outputFiles) { - const absoluteOutputFile = path.resolve(outputFile); - const relativePath = path.relative(outputDir, absoluteOutputFile); - // Normalize path separators to forward slashes for consistent matching - const normalizedRelativePath = relativePath.split(path.sep).join('/'); - const builtPath = `${builtDir}/${normalizedRelativePath}`; + const absoluteBuiltFile = path.resolve(outputFile); + const relativeBuiltPath = path.relative(appPath, absoluteBuiltFile); + const absoluteSourcePath = + result.metafile?.outputs?.[outputFile]?.entryPoint || ''; + const relativeSourcePath = path.relative(appPath, absoluteSourcePath); - const content = await fs.readFile(absoluteOutputFile); + const content = await fs.readFile(absoluteBuiltFile); const checksum = crypto.createHash('md5').update(content).digest('hex'); - const lastChecksum = lastChecksums.get(builtPath); + const lastChecksum = lastChecksums.get(relativeBuiltPath); + if (lastChecksum === checksum) { continue; } - hasChanges = true; - lastChecksums.set(builtPath, checksum); - onSuccess(relativePath); + lastChecksums.set(relativeBuiltPath, checksum); if (onFileBuilt) { - await onFileBuilt(builtPath, checksum); + await onFileBuilt({ + fileFolder, + builtPath: relativeBuiltPath, + filePath: relativeSourcePath, + checksum, + }); } } - - return { hasChanges }; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts new file mode 100644 index 0000000000..1f2dc5468c --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts @@ -0,0 +1,222 @@ +import * as esbuild from 'esbuild'; +import path from 'path'; +import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files'; +import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; +import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor'; +import { + type OnBuildErrorCallback, + type OnFileBuiltCallback, + type RestartableWatcher, + type RestartableWatcherOptions, +} from '@/cli/utilities/build/common/restartable-watcher-interface'; +import { FileFolder } from 'twenty-shared/types'; + +export const FUNCTION_EXTERNAL_MODULES: string[] = [ + 'path', + 'fs', + 'crypto', + 'stream', + 'util', + 'os', + 'url', + 'http', + 'https', + 'events', + 'buffer', + 'querystring', + 'assert', + 'zlib', + 'net', + 'tls', + 'child_process', + 'worker_threads', + 'twenty-sdk', + 'twenty-sdk/*', + 'twenty-shared', + 'twenty-shared/*', +]; + +export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'twenty-sdk', + 'twenty-sdk/*', + 'twenty-shared', + 'twenty-shared/*', +]; + +export type EsbuildWatcherConfig = { + externalModules: string[]; + fileFolder: FileFolder; + platform?: esbuild.Platform; + jsx?: 'automatic'; + extraPlugins?: esbuild.Plugin[]; +}; + +export type EsbuildWatcherOptions = RestartableWatcherOptions & { + config: EsbuildWatcherConfig; +}; + +export class EsbuildWatcher implements RestartableWatcher { + private appPath: string; + private sourcePaths: string[]; + private esBuildContext: esbuild.BuildContext | null = null; + private isRestarting = false; + private watchMode: boolean; + private lastChecksums: Map = new Map(); + private onFileBuilt?: OnFileBuiltCallback; + private onBuildError?: OnBuildErrorCallback; + private buildCompletePromise: Promise = Promise.resolve(); + private resolveBuildComplete: (() => void) | null = null; + private config: EsbuildWatcherConfig; + + constructor(options: EsbuildWatcherOptions) { + this.appPath = options.appPath; + this.sourcePaths = options.sourcePaths; + this.watchMode = options.watch ?? true; + this.onFileBuilt = options.handleFileBuilt; + this.onBuildError = options.handleBuildError; + this.config = options.config; + } + + shouldRestart(sourcePaths: string[]): boolean { + const currentPaths = this.sourcePaths.sort().join(','); + const newPaths = [...sourcePaths].sort().join(','); + return currentPaths !== newPaths; + } + + async start(): Promise { + if (this.sourcePaths.length > 0) { + await this.createContext(); + } + } + + async close(): Promise { + await this.esBuildContext?.dispose(); + this.esBuildContext = null; + } + + async restart(sourcePaths: string[]): Promise { + if (this.isRestarting) return; + + this.isRestarting = true; + try { + await this.close(); + + const outputDir = path.join(this.appPath, OUTPUT_DIR); + await cleanupRemovedFiles(outputDir, this.sourcePaths, sourcePaths); + this.sourcePaths = sourcePaths; + this.lastChecksums.clear(); + + if (this.sourcePaths.length > 0) { + await this.createContext(); + } + } finally { + this.isRestarting = false; + } + } + + private async createContext(): Promise { + const outputDir = path.join(this.appPath, OUTPUT_DIR); + + const entryPoints: Record = {}; + for (const sourcePath of this.sourcePaths) { + const entryName = sourcePath.replace(/\.tsx?$/, ''); + entryPoints[entryName] = path.join(this.appPath, sourcePath); + } + + const watcher = this; + + const plugins: esbuild.Plugin[] = [ + ...(this.config.extraPlugins ?? []), + { + name: 'build-notifications', + setup: (build) => { + build.onEnd(async (result) => { + try { + if (result.errors.length > 0) { + await this.onBuildError?.(result.errors.map((err) => err.text)); + return; + } + + await processEsbuildResult({ + result, + appPath: this.appPath, + fileFolder: this.config.fileFolder, + lastChecksums: watcher.lastChecksums, + onFileBuilt: watcher.onFileBuilt, + }); + } finally { + watcher.resolveBuildComplete?.(); + } + }); + }, + }, + ]; + + this.esBuildContext = await esbuild.context({ + entryPoints, + bundle: true, + splitting: false, + format: 'esm', + platform: this.config.platform, + outdir: outputDir, + outExtension: { '.js': '.mjs' }, + external: this.config.externalModules, + tsconfig: path.join(this.appPath, 'tsconfig.json'), + jsx: this.config.jsx, + sourcemap: true, + metafile: true, + logLevel: 'silent', + plugins, + }); + + this.buildCompletePromise = new Promise((resolve) => { + this.resolveBuildComplete = resolve; + }); + + await this.esBuildContext.rebuild(); + await this.buildCompletePromise; + + if (this.watchMode) { + await this.esBuildContext.watch(); + } + } +} + +const externalPatternsPlugin: esbuild.Plugin = { + name: 'external-patterns', + setup: (build) => { + build.onResolve({ filter: /(?:^|\/)generated(?:\/|$)/ }, (args) => ({ + path: args.path, + external: true, + })); + }, +}; + +export const createFunctionsWatcher = ( + options: RestartableWatcherOptions, +): EsbuildWatcher => + new EsbuildWatcher({ + ...options, + config: { + externalModules: FUNCTION_EXTERNAL_MODULES, + fileFolder: FileFolder.BuiltFunction, + platform: 'node', + extraPlugins: [externalPatternsPlugin], + }, + }); + +export const createFrontComponentsWatcher = ( + options: RestartableWatcherOptions, +): EsbuildWatcher => + new EsbuildWatcher({ + ...options, + config: { + externalModules: FRONT_COMPONENT_EXTERNAL_MODULES, + fileFolder: FileFolder.BuiltFrontComponent, + jsx: 'automatic', + }, + }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts b/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts index 34c487d122..fd4e35f209 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/logger.ts @@ -1,11 +1,6 @@ import chalk, { type ChalkInstance } from 'chalk'; -export type LoggerContext = - | 'init' - | 'manifest-watch' - | 'functions-watch' - | 'front-components-watch' - | 'file-upload'; +export type LoggerContext = 'init' | 'manifest-builder' | 'dev-mode'; type LoggerConfig = { prefix: string; @@ -17,22 +12,14 @@ const LOGGER_CONFIGS: Record = { prefix: '[init]', color: chalk.cyan, }, - 'manifest-watch': { - prefix: '[manifest-watch]', - color: chalk.magenta, - }, - 'functions-watch': { - prefix: '[functions-watch]', - color: chalk.yellow, - }, - 'front-components-watch': { - prefix: '[front-components-watch]', - color: chalk.green, - }, - 'file-upload': { - prefix: '[file-upload]', + 'manifest-builder': { + prefix: '[manifest-builder]', color: chalk.blue, }, + 'dev-mode': { + prefix: '[dev-mode]', + color: chalk.blueBright, + }, }; export type Logger = { 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 7dbea25cc3..ae08c27732 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,3 +1,5 @@ +import { type FileFolder } from 'twenty-shared/types'; + export interface RestartableWatcher { restart(sourcePaths: string[]): Promise; start(): Promise; @@ -5,14 +7,19 @@ export interface RestartableWatcher { shouldRestart(sourcePaths: string[]): boolean; } -export type OnFileBuiltCallback = ( - builtPath: string, - checksum: string, -) => void | Promise; +export type OnFileBuiltCallback = (options: { + fileFolder: FileFolder; + builtPath: string; + filePath: string; + checksum: string; +}) => void | Promise; + +export type OnBuildErrorCallback = (errors: string[]) => void | Promise; export type RestartableWatcherOptions = { appPath: string; sourcePaths: string[]; watch?: boolean; - onFileBuilt?: OnFileBuiltCallback; + handleFileBuilt: OnFileBuiltCallback; + handleBuildError: OnBuildErrorCallback; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts deleted file mode 100644 index 90ecb017dc..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const FRONT_COMPONENTS_DIR = 'front-components'; 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 deleted file mode 100644 index 4753877c5c..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts +++ /dev/null @@ -1,176 +0,0 @@ -import * as esbuild from 'esbuild'; -import * as fs from 'fs-extra'; -import path from 'path'; -import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files'; -import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; -import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor'; -import { createLogger } from '@/cli/utilities/build/common/logger'; -import { - type OnFileBuiltCallback, - type RestartableWatcher, - type RestartableWatcherOptions, -} from '@/cli/utilities/build/common/restartable-watcher-interface'; -import { FRONT_COMPONENTS_DIR } from '@/cli/utilities/build/front-components/constants'; - -const logger = createLogger('front-components-watch'); - -export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [ - 'react', - 'react-dom', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'twenty-sdk', - 'twenty-sdk/*', - 'twenty-shared', - 'twenty-shared/*', -]; - -export class FrontComponentsWatcher implements RestartableWatcher { - private appPath: string; - private componentPaths: string[]; - private esBuildContext: esbuild.BuildContext | null = null; - private isRestarting = false; - private watchMode: boolean; - private lastChecksums: Map = new Map(); - private onFileBuilt?: OnFileBuiltCallback; - private buildCompletePromise: Promise = Promise.resolve(); - private resolveBuildComplete: (() => void) | null = null; - - constructor(options: RestartableWatcherOptions) { - this.appPath = options.appPath; - this.componentPaths = options.sourcePaths; - this.watchMode = options.watch ?? true; - this.onFileBuilt = options.onFileBuilt; - } - - shouldRestart(sourcePaths: string[]): boolean { - const currentPaths = this.componentPaths.sort().join(','); - const newPaths = [...sourcePaths].sort().join(','); - - return currentPaths !== newPaths; - } - - async start(): Promise { - const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); - await fs.emptyDir(outputDir); - - if (this.componentPaths.length > 0) { - logger.log('🎨 Building...'); - await this.createContext(); - } else { - logger.log('No front components to build'); - if (this.watchMode) { - logger.log('👀 Watching for changes...'); - } - } - } - - async close(): Promise { - await this.esBuildContext?.dispose(); - this.esBuildContext = null; - } - - async restart(sourcePaths: string[]): Promise { - if (this.isRestarting) return; - - this.isRestarting = true; - try { - logger.warn('🔄 Restarting...'); - await this.close(); - - const outputDir = path.join( - this.appPath, - OUTPUT_DIR, - FRONT_COMPONENTS_DIR, - ); - await cleanupRemovedFiles(outputDir, this.componentPaths, sourcePaths); - this.componentPaths = sourcePaths; - this.lastChecksums.clear(); - - if (this.componentPaths.length > 0) { - logger.log('🎨 Building...'); - await this.createContext(); - } else { - logger.log('No front components to build'); - logger.log('👀 Watching for changes...'); - } - - logger.success('✓ Restarted'); - } finally { - this.isRestarting = false; - } - } - - private async createContext(): Promise { - const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); - - const entryPoints: Record = {}; - for (const componentPath of this.componentPaths) { - const entryName = componentPath.replace(/\.tsx?$/, ''); - entryPoints[entryName] = path.join(this.appPath, componentPath); - } - - const watchMode = this.watchMode; - const watcher = this; - - this.esBuildContext = await esbuild.context({ - entryPoints, - bundle: true, - splitting: false, - format: 'esm', - outdir: outputDir, - outExtension: { '.js': '.mjs' }, - external: FRONT_COMPONENT_EXTERNAL_MODULES, - tsconfig: path.join(this.appPath, 'tsconfig.json'), - jsx: 'automatic', - sourcemap: true, - metafile: true, - logLevel: 'silent', - plugins: [ - { - name: 'build-notifications', - setup: (build) => { - build.onEnd(async (result) => { - try { - if (result.errors.length > 0) { - logger.error('✗ Build error:'); - for (const error of result.errors) { - logger.error(` ${error.text}`); - } - return; - } - - const { hasChanges } = await processEsbuildResult({ - result, - outputDir, - builtDir: FRONT_COMPONENTS_DIR, - lastChecksums: watcher.lastChecksums, - onFileBuilt: watcher.onFileBuilt, - onSuccess: (relativePath) => - logger.success(`✓ Built ${relativePath}`), - }); - - if (hasChanges && watchMode) { - logger.log('👀 Watching for changes...'); - } - } finally { - watcher.resolveBuildComplete?.(); - } - }); - }, - }, - ], - }); - - this.buildCompletePromise = new Promise((resolve) => { - this.resolveBuildComplete = resolve; - }); - - await this.esBuildContext.rebuild(); - await this.buildCompletePromise; - - if (this.watchMode) { - await this.esBuildContext.watch(); - } - } -} diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts deleted file mode 100644 index 816055d358..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const FUNCTIONS_DIR = 'functions'; 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 deleted file mode 100644 index 0d42ac1ba2..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as esbuild from 'esbuild'; -import * as fs from 'fs-extra'; -import path from 'path'; -import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files'; -import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; -import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor'; -import { createLogger } from '@/cli/utilities/build/common/logger'; -import { - type OnFileBuiltCallback, - type RestartableWatcher, - type RestartableWatcherOptions, -} from '@/cli/utilities/build/common/restartable-watcher-interface'; -import { FUNCTIONS_DIR } from '@/cli/utilities/build/functions/constants'; - -const logger = createLogger('functions-watch'); - -export const FUNCTION_EXTERNAL_MODULES: string[] = [ - 'path', - 'fs', - 'crypto', - 'stream', - 'util', - 'os', - 'url', - 'http', - 'https', - 'events', - 'buffer', - 'querystring', - 'assert', - 'zlib', - 'net', - 'tls', - 'child_process', - 'worker_threads', - 'twenty-sdk', - 'twenty-sdk/*', - 'twenty-shared', - 'twenty-shared/*', -]; - -export class FunctionsWatcher implements RestartableWatcher { - private appPath: string; - private functionPaths: string[]; - private esBuildContext: esbuild.BuildContext | null = null; - private isRestarting = false; - private watchMode: boolean; - private lastChecksums: Map = new Map(); - private onFileBuilt?: OnFileBuiltCallback; - private buildCompletePromise: Promise = Promise.resolve(); - private resolveBuildComplete: (() => void) | null = null; - - constructor(options: RestartableWatcherOptions) { - this.appPath = options.appPath; - this.functionPaths = options.sourcePaths; - this.watchMode = options.watch ?? true; - this.onFileBuilt = options.onFileBuilt; - } - - shouldRestart(sourcePaths: string[]): boolean { - const currentPaths = this.functionPaths.sort().join(','); - const newPaths = [...sourcePaths].sort().join(','); - - return currentPaths !== newPaths; - } - - async start(): Promise { - const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); - await fs.emptyDir(outputDir); - - if (this.functionPaths.length > 0) { - logger.log('📦 Building...'); - await this.createContext(); - } else { - logger.log('No functions to build'); - if (this.watchMode) { - logger.log('👀 Watching for changes...'); - } - } - } - - async close(): Promise { - await this.esBuildContext?.dispose(); - this.esBuildContext = null; - } - - async restart(sourcePaths: string[]): Promise { - if (this.isRestarting) return; - - this.isRestarting = true; - try { - logger.warn('🔄 Restarting...'); - await this.close(); - - const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); - await cleanupRemovedFiles(outputDir, this.functionPaths, sourcePaths); - this.functionPaths = sourcePaths; - this.lastChecksums.clear(); - - if (this.functionPaths.length > 0) { - logger.log('📦 Building...'); - await this.createContext(); - } else { - logger.log('No functions to build'); - logger.log('👀 Watching for changes...'); - } - - logger.success('✓ Restarted'); - } finally { - this.isRestarting = false; - } - } - - private async createContext(): Promise { - const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); - - const entryPoints: Record = {}; - for (const functionPath of this.functionPaths) { - const entryName = functionPath.replace(/\.tsx?$/, ''); - entryPoints[entryName] = path.join(this.appPath, functionPath); - } - - const watchMode = this.watchMode; - const watcher = this; - - this.esBuildContext = await esbuild.context({ - entryPoints, - bundle: true, - splitting: false, - format: 'esm', - platform: 'node', - outdir: outputDir, - outExtension: { '.js': '.mjs' }, - external: FUNCTION_EXTERNAL_MODULES, - tsconfig: path.join(this.appPath, 'tsconfig.json'), - sourcemap: true, - metafile: true, - logLevel: 'silent', - plugins: [ - { - name: 'external-patterns', - setup: (build) => { - build.onResolve( - { filter: /(?:^|\/)generated(?:\/|$)/ }, - (args) => ({ - path: args.path, - external: true, - }), - ); - }, - }, - { - name: 'build-notifications', - setup: (build) => { - build.onEnd(async (result) => { - try { - if (result.errors.length > 0) { - logger.error('✗ Build error:'); - for (const error of result.errors) { - logger.error(` ${error.text}`); - } - return; - } - - const { hasChanges } = await processEsbuildResult({ - result, - outputDir, - builtDir: FUNCTIONS_DIR, - lastChecksums: watcher.lastChecksums, - onFileBuilt: watcher.onFileBuilt, - onSuccess: (relativePath) => - logger.success(`✓ Built ${relativePath}`), - }); - - if (hasChanges && watchMode) { - logger.log('👀 Watching for changes...'); - } - } finally { - watcher.resolveBuildComplete?.(); - } - }); - }, - }, - ], - }); - - this.buildCompletePromise = new Promise((resolve) => { - this.resolveBuildComplete = resolve; - }); - - await this.esBuildContext.rebuild(); - await this.buildCompletePromise; - - if (this.watchMode) { - await this.esBuildContext.watch(); - } - } -} 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 742404f339..91cf07c3af 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 @@ -14,7 +14,7 @@ import { } from '@/cli/utilities/build/manifest/entities/entity-interface'; import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types'; -const logger = createLogger('manifest-watch'); +const logger = createLogger('manifest-builder'); const findApplicationConfigPath = async (appPath: string): Promise => { const files = await glob('**/application.config.ts', { @@ -40,7 +40,7 @@ export class ApplicationEntityBuilder { async build(appPath: string): Promise> { const applicationConfigPath = await findApplicationConfigPath(appPath); - const application = + const { manifest: application } = await manifestExtractFromFileServer.extractManifestFromFile( applicationConfigPath, ); 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 84aae13fdd..1d6ff3dcc3 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 @@ -8,7 +8,7 @@ export type EntityIdWithLocation = { export type ManifestWithoutSources = Omit< ApplicationManifest, - 'sources' | 'packageJson' + 'sources' | 'packageJson' | 'yarnLock' >; export type EntityBuildResult = { 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 705341e9f5..f46712fac5 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 @@ -10,9 +10,8 @@ import { type ManifestEntityBuilder, type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -import { FRONT_COMPONENTS_DIR } from '@/cli/utilities/build/front-components/constants'; -const logger = createLogger('manifest-watch'); +const logger = createLogger('manifest-builder'); type FrontComponentConfig = Omit< FrontComponentManifest, @@ -45,7 +44,7 @@ export class FrontComponentEntityBuilder for (const filePath of componentFiles) { try { const absolutePath = `${appPath}/${filePath}`; - const config = + const { manifest: config } = await manifestExtractFromFileServer.extractManifestFromFile( absolutePath, ); @@ -71,9 +70,7 @@ export class FrontComponentEntityBuilder } private computeBuiltComponentPath(sourceComponentPath: string): string { - const builtPath = sourceComponentPath.replace(/\.tsx?$/, '.mjs'); - - return `${FRONT_COMPONENTS_DIR}/${builtPath}`; + return sourceComponentPath.replace(/\.tsx?$/, '.mjs'); } 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 bb256307f4..25abcaab17 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 @@ -10,15 +10,14 @@ import { type ManifestEntityBuilder, type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -import { FUNCTIONS_DIR } from '@/cli/utilities/build/functions/constants'; -const logger = createLogger('manifest-watch'); +const logger = createLogger('manifest-builder'); type ExtractedFunctionManifest = Omit< ServerlessFunctionManifest, 'sourceHandlerPath' | 'builtHandlerPath' | 'builtHandlerChecksum' > & { - handlerPath: string; + handler: string; }; export class FunctionEntityBuilder @@ -43,20 +42,25 @@ export class FunctionEntityBuilder try { const absolutePath = `${appPath}/${filePath}`; - const extracted = + const { manifest, exportName } = await manifestExtractFromFileServer.extractManifestFromFile( absolutePath, - { entryProperty: 'handler' }, ); - const { handlerPath, ...rest } = extracted; + const { handler: _, ...rest } = manifest; // builtHandlerPath is computed from filePath (the .function.ts file) // since that's what esbuild actually builds, not handlerPath const builtHandlerPath = this.computeBuiltHandlerPath(filePath); + // For default exports, use 'default.handler' + // For named exports like 'export const anyName = ...', use 'anyName.handler' + const handlerName = + exportName !== null ? `${exportName}.handler` : 'default.handler'; + manifests.push({ ...rest, - sourceHandlerPath: handlerPath, + handlerName, + sourceHandlerPath: filePath, builtHandlerPath, builtHandlerChecksum: null, }); @@ -71,9 +75,7 @@ export class FunctionEntityBuilder } private computeBuiltHandlerPath(sourceHandlerPath: string): string { - const builtPath = sourceHandlerPath.replace(/\.tsx?$/, '.mjs'); - - return `${FUNCTIONS_DIR}/${builtPath}`; + return sourceHandlerPath.replace(/\.tsx?$/, '.mjs'); } 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 aeecd811fe..24c7cae2c7 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 @@ -33,11 +33,12 @@ export class ObjectExtensionEntityBuilder try { const absolutePath = `${appPath}/${filePath}`; - manifests.push( + const { manifest } = await manifestExtractFromFileServer.extractManifestFromFile( absolutePath, - ), - ); + ); + + manifests.push(manifest); } catch (error) { throw new Error( `Failed to load object extension from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 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 62bdfbd8d5..4949fdbef7 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 @@ -12,7 +12,7 @@ import { type ManifestWithoutSources, } from '@/cli/utilities/build/manifest/entities/entity-interface'; -const logger = createLogger('manifest-watch'); +const logger = createLogger('manifest-builder'); export class ObjectEntityBuilder implements ManifestEntityBuilder @@ -34,11 +34,12 @@ export class ObjectEntityBuilder try { const absolutePath = `${appPath}/${filePath}`; - manifests.push( + const { manifest } = await manifestExtractFromFileServer.extractManifestFromFile( absolutePath, - ), - ); + ); + + manifests.push(manifest); } catch (error) { throw new Error( `Failed to load object from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 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 8050a7f75c..ebdbaa26fb 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 @@ -10,7 +10,7 @@ import { } from '@/cli/utilities/build/manifest/entities/entity-interface'; import { createLogger } from '@/cli/utilities/build/common/logger'; -const logger = createLogger('manifest-watch'); +const logger = createLogger('manifest-builder'); export class RoleEntityBuilder implements ManifestEntityBuilder { async build(appPath: string): Promise> { @@ -30,11 +30,12 @@ export class RoleEntityBuilder implements ManifestEntityBuilder { try { const absolutePath = `${appPath}/${filePath}`; - manifests.push( + const { manifest } = await manifestExtractFromFileServer.extractManifestFromFile( absolutePath, - ), - ); + ); + + manifests.push(manifest); } catch (error) { throw new Error( `Failed to load role from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 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 a842970e82..7383d90b53 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 @@ -2,27 +2,19 @@ import { findPathFile } from '@/cli/utilities/file/file-find'; import { parseJsoncFile } from '@/cli/utilities/file/file-jsonc'; import { glob } from 'fast-glob'; import * as fs from 'fs-extra'; +import { readFile } from 'fs-extra'; import { relative, sep } from 'path'; import { type ApplicationManifest } from 'twenty-shared/application'; -import { type Sources } from 'twenty-shared/types'; +import { FileFolder, type Sources } from 'twenty-shared/types'; import { applicationEntityBuilder } from '@/cli/utilities/build/manifest/entities/application'; import { frontComponentEntityBuilder } from '@/cli/utilities/build/manifest/entities/front-component'; import { functionEntityBuilder } from '@/cli/utilities/build/manifest/entities/function'; import { objectEntityBuilder } from '@/cli/utilities/build/manifest/entities/object'; import { objectExtensionEntityBuilder } from '@/cli/utilities/build/manifest/entities/object-extension'; import { roleEntityBuilder } from '@/cli/utilities/build/manifest/entities/role'; -import { - displayEntitySummary, - displayErrors, - displayWarnings, -} from '@/cli/utilities/build/manifest/manifest-display'; -import { manifestExtractFromFileServer } from './manifest-extract-from-file-server'; -import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; -import { ManifestValidationError } from '@/cli/utilities/build/manifest/manifest-types'; -import { createLogger } from '@/cli/utilities/build/common/logger'; -import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate'; -const logger = createLogger('manifest-watch'); +import { manifestExtractFromFileServer } from './manifest-extract-from-file-server'; +import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; export type EntityFilePaths = { application: string[]; @@ -62,12 +54,7 @@ const loadSources = async (appPath: string): Promise => { return sources; }; -export type RunManifestBuildOptions = { - display?: boolean; - writeOutput?: boolean; -}; - -const EMPTY_FILE_PATHS: EntityFilePaths = { +export const EMPTY_FILE_PATHS: EntityFilePaths = { application: [], objects: [], objectExtensions: [], @@ -79,65 +66,64 @@ const EMPTY_FILE_PATHS: EntityFilePaths = { export type ManifestBuildResult = { manifest: ApplicationManifest | null; filePaths: EntityFilePaths; + error?: string; }; -export type ManifestEntityType = 'function' | 'frontComponent'; - export type UpdateManifestChecksumParams = { manifest: ApplicationManifest; - entityType: ManifestEntityType; - builtPath: string; - checksum: string; + builtFileInfos: Map< + string, + { checksum: string; builtPath: string; fileFolder: FileFolder } + >; }; export const updateManifestChecksum = ({ manifest, - entityType, - builtPath, - checksum, -}: UpdateManifestChecksumParams): ApplicationManifest | null => { - if (entityType === 'function') { - const fnIndex = manifest.functions.findIndex( - (f) => f.builtHandlerPath === builtPath, - ); - if (fnIndex === -1) { - return null; + builtFileInfos, +}: UpdateManifestChecksumParams): ApplicationManifest => { + let result = structuredClone(manifest); + for (const [ + builtPath, + { fileFolder, checksum }, + ] of builtFileInfos.entries()) { + const rootBuiltPath = relative(OUTPUT_DIR, builtPath); + if (fileFolder === FileFolder.BuiltFunction) { + const fnIndex = result.functions.findIndex( + (f) => f.builtHandlerPath === rootBuiltPath, + ); + if (fnIndex === -1) { + continue; + } + result = { + ...result, + functions: result.functions.map((fn, index) => + index === fnIndex ? { ...fn, builtHandlerChecksum: checksum } : fn, + ), + }; } - return { - ...manifest, - functions: manifest.functions.map((fn, index) => - index === fnIndex ? { ...fn, builtHandlerChecksum: checksum } : fn, + + const componentIndex = + result.frontComponents.findIndex( + (c) => c.builtComponentPath === rootBuiltPath, + ) ?? -1; + if (componentIndex === -1) { + continue; + } + result = { + ...result, + frontComponents: result.frontComponents.map((component, index) => + index === componentIndex + ? { ...component, builtComponentChecksum: checksum } + : component, ), }; } - - const componentIndex = - manifest.frontComponents.findIndex( - (c) => c.builtComponentPath === builtPath, - ) ?? -1; - if (componentIndex === -1) { - return null; - } - return { - ...manifest, - frontComponents: manifest.frontComponents.map((component, index) => - index === componentIndex - ? { ...component, builtComponentChecksum: checksum } - : component, - ), - }; + return result; }; export const runManifestBuild = async ( appPath: string, - options: RunManifestBuildOptions = {}, ): Promise => { - const { display = true, writeOutput = true } = options; - - if (display) { - logger.log('🔄 Building...'); - } - try { manifestExtractFromFileServer.init(appPath); @@ -145,6 +131,11 @@ export const runManifestBuild = async ( await findPathFile(appPath, 'package.json'), ); + const yarnLock = await readFile( + await findPathFile(appPath, 'yarn.lock'), + 'utf8', + ); + const [ applicationBuildResult, objectBuildResult, @@ -188,44 +179,15 @@ export const runManifestBuild = async ( roles: roleManifests, sources, packageJson, + yarnLock, }; - const validation = validateManifest({ - application, - objects: objectManifests, - objectExtensions: objectExtensionManifests, - functions: functionManifests, - frontComponents: frontComponentManifests, - roles: roleManifests, - }); - - if (!validation.isValid) { - throw new ManifestValidationError(validation.errors); - } - - if (display) { - displayEntitySummary(manifest); - if (validation.warnings.length > 0) { - displayWarnings(validation.warnings); - } - } - - if (writeOutput) { - const manifestPath = await writeManifestToOutput(appPath, manifest); - logger.success(`✓ Written to ${manifestPath}`); - } - return { manifest, filePaths }; } catch (error) { - if (display) { - if (error instanceof ManifestValidationError) { - displayErrors(error); - } else { - logger.error( - `✗ Build failed: ${error instanceof Error ? error.message : error}`, - ); - } - } - return { manifest: null, filePaths: EMPTY_FILE_PATHS }; + return { + manifest: null, + filePaths: EMPTY_FILE_PATHS, + error: error instanceof Error ? error.message : `${error}`, + }; } }; 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 deleted file mode 100644 index f6f20e3ca2..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { type ApplicationManifest } from 'twenty-shared/application'; -import { createLogger } from '../common/logger'; -import { frontComponentEntityBuilder } from '@/cli/utilities/build/manifest/entities/front-component'; -import { functionEntityBuilder } from '@/cli/utilities/build/manifest/entities/function'; -import { objectEntityBuilder } from '@/cli/utilities/build/manifest/entities/object'; -import { roleEntityBuilder } from '@/cli/utilities/build/manifest/entities/role'; -import { - type ManifestValidationError, - type ValidationWarning, -} from '@/cli/utilities/build/manifest/manifest-types'; -import { applicationEntityBuilder } from '@/cli/utilities/build/manifest/entities/application'; - -const logger = createLogger('manifest-watch'); - -export const displayEntitySummary = (manifest: ApplicationManifest): void => { - applicationEntityBuilder.display( - manifest.application ? [manifest.application] : [], - ); - objectEntityBuilder.display(manifest.objects ?? []); - functionEntityBuilder.display(manifest.functions ?? []); - frontComponentEntityBuilder.display(manifest.frontComponents ?? []); - roleEntityBuilder.display(manifest.roles ?? []); -}; - -export const displayErrors = (error: ManifestValidationError): void => { - logger.error('✗ Validation failed:'); - for (const err of error.errors) { - logger.error(` • ${err.path}: ${err.message}`); - } -}; - -export const displayWarnings = (warnings: ValidationWarning[]): void => { - if (warnings.length === 0) { - return; - } - - for (const warning of warnings) { - const path = warning.path ? `${warning.path}: ` : ''; - logger.warn(`⚠ ${path}${warning.message}`); - } -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts index 0d0c6aa851..1bed0d4377 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-from-file-server.ts @@ -1,186 +1,114 @@ +import * as esbuild from 'esbuild'; import * as fs from 'fs-extra'; +import { createRequire } from 'module'; +import * as os from 'os'; import path from 'path'; import { isDefined, isPlainObject } from 'twenty-shared/utils'; -import { createServer, type ViteDevServer } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; -export type ExtractManifestOptions = { - entryProperty?: string; +export type ExtractedManifest = { + manifest: TManifest; + exportName: string | null; }; export class ManifestExtractFromFileServer { - private server: ViteDevServer | null = null; private appPath: string | null = null; init(appPath: string): void { - if (this.appPath !== appPath) { - this.closeViteServer(); - } this.appPath = appPath; } async extractManifestFromFile( filepath: string, - options: ExtractManifestOptions = {}, - ): Promise { + ): Promise> { if (!this.appPath) { throw new Error( 'ManifestExtractFromFileServer not initialized. Call init(appPath) first.', ); } - const { entryProperty } = options; - const server = await this.getServer(); - const module = (await server.ssrLoadModule(filepath)) as Record< - string, - unknown - >; + const module = await this.loadModule(filepath); - const config = this.extractConfigFromModule>( - module, - entryProperty, - ); + const result = this.extractConfigFromModule(module); - if (!config) { - const expectedExport = entryProperty - ? `a config object with a "${entryProperty}" property` - : 'a config object (default export or any named object export)'; - throw new Error(`Config file ${filepath} must export ${expectedExport}`); - } - - if (!entryProperty) { - return config as TManifest; - } - - const entryFunction = config[entryProperty] as Function; - const entryName = entryFunction.name; - - if (!entryName) { + if (!result) { throw new Error( - `${entryProperty} function in ${filepath} must be a named function`, + `Config file ${filepath} must export a config object (default export or any named object export)`, ); } - const importSource = await this.resolveEntryPath(filepath, entryName); - const entryPath = - importSource ?? path.relative(this.appPath, filepath).replace(/\\/g, '/'); - - const { [entryProperty]: _, ...configWithoutEntry } = config; - - return { - ...configWithoutEntry, - [`${entryProperty}Name`]: entryName, - [`${entryProperty}Path`]: entryPath, - } as TManifest; + return result; } - async closeViteServer(): Promise { - if (this.server) { - await this.server.close(); - this.server = null; - } - } - - private async getServer(): Promise { + private async loadModule(filepath: string): Promise> { if (!this.appPath) { throw new Error( 'ManifestExtractFromFileServer not initialized. Call init(appPath) first.', ); } - if (this.server) { - return this.server; + const tsconfigPath = path.join(this.appPath, 'tsconfig.json'); + const hasTsconfig = await fs.pathExists(tsconfigPath); + + // Resolve react from the app's node_modules for the alias + const appRequire = createRequire(path.join(this.appPath, 'package.json')); + let reactPath: string | undefined; + let reactDomPath: string | undefined; + + try { + reactPath = path.dirname(appRequire.resolve('react/package.json')); + reactDomPath = path.dirname(appRequire.resolve('react-dom/package.json')); + } catch { + // React not installed in app, will be bundled if used } - this.server = await createServer({ - root: this.appPath, - plugins: [tsconfigPaths({ root: this.appPath })], - server: { middlewareMode: true }, - optimizeDeps: { disabled: true }, + const result = await esbuild.build({ + entryPoints: [filepath], + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + target: 'node18', + jsx: 'automatic', + tsconfig: hasTsconfig ? tsconfigPath : undefined, + // Use alias to resolve react from app's node_modules + alias: { + ...(reactPath && { react: reactPath }), + ...(reactDomPath && { 'react-dom': reactDomPath }), + }, logLevel: 'silent', - configFile: false, - esbuild: { jsx: 'automatic' }, }); - return this.server; + const code = result.outputFiles[0].text; + + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'twenty-manifest-'), + ); + const tempFile = path.join(tempDir, 'module.cjs'); + + try { + await fs.writeFile(tempFile, code); + + return require(tempFile) as Record; + } finally { + await fs.remove(tempDir); + } } private extractConfigFromModule( module: Record, - entryProperty?: string, - ): T | undefined { - const hasValidEntry = (value: unknown): boolean => - isPlainObject(value) && - typeof (value as Record)[entryProperty!] === 'function'; - - if ( - isDefined(module.default) && - (!entryProperty || hasValidEntry(module.default)) - ) { - return module.default as T; + ): ExtractedManifest | undefined { + if (isDefined(module.default) && isPlainObject(module.default)) { + return { manifest: module.default as T, exportName: null }; } - for (const value of Object.values(module)) { - if (isPlainObject(value) && (!entryProperty || hasValidEntry(value))) { - return value as T; + for (const [key, value] of Object.entries(module)) { + if (isPlainObject(value)) { + return { manifest: value as T, exportName: key }; } } return undefined; } - - private async resolveEntryPath( - filepath: string, - entryName: string, - ): Promise { - if (!this.appPath) { - return null; - } - - const source = await fs.readFile(filepath, 'utf8'); - - const patterns = [ - new RegExp( - `import\\s*\\{[^}]*\\b${entryName}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, - ), - new RegExp(`import\\s+${entryName}\\s+from\\s*['"]([^'"]+)['"]`), - ]; - - let importSpecifier: string | null = null; - for (const pattern of patterns) { - const match = source.match(pattern); - if (match) { - importSpecifier = match[1]; - break; - } - } - - if (!importSpecifier) { - return null; - } - - const server = await this.getServer(); - const resolved = await server.pluginContainer.resolveId( - importSpecifier, - filepath, - ); - if (resolved?.id) { - return path.relative(this.appPath, resolved.id).replace(/\\/g, '/'); - } - - if (importSpecifier.startsWith('.')) { - const absolutePath = path.resolve( - path.dirname(filepath), - importSpecifier, - ); - const relativePath = path.relative(this.appPath, absolutePath); - return ( - relativePath.endsWith('.ts') ? relativePath : `${relativePath}.ts` - ).replace(/\\/g, '/'); - } - - return null; - } } export const manifestExtractFromFileServer = diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-types.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-types.ts index 8fdf166f22..b4093d76b8 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-types.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-types.ts @@ -13,13 +13,3 @@ export type ValidationResult = { 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'; - } -} 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 c7de5d7de0..7ff2b2a176 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,69 +1,36 @@ import chokidar, { type FSWatcher } from 'chokidar'; -import path from 'path'; -import { createLogger } from '@/cli/utilities/build/common/logger'; -import { - type ManifestBuildResult, - runManifestBuild, -} from '@/cli/utilities/build/manifest/manifest-build'; - -const logger = createLogger('manifest-watch'); - -export type ManifestWatcherCallbacks = { - onBuildSuccess?: (result: ManifestBuildResult) => void; -}; export type ManifestWatcherOptions = { appPath: string; - callbacks?: ManifestWatcherCallbacks; + handleChangeDetected: (filePath: string) => void; }; export class ManifestWatcher { private appPath: string; - private callbacks: ManifestWatcherCallbacks; + private handleChangeDetected: (filePath: string) => void; private watcher: FSWatcher | null = null; constructor(options: ManifestWatcherOptions) { this.appPath = options.appPath; - this.callbacks = options.callbacks ?? {}; + this.handleChangeDetected = options.handleChangeDetected; } async start(): Promise { this.watcher = chokidar.watch(this.appPath, { - ignored: [ - '**/node_modules/**', - '**/.twenty/**', - '**/dist/**', - (filePath: string) => - filePath.includes('/.twenty/') || filePath.includes('\\.twenty\\'), - ], - ignoreInitial: true, + ignored: [/node_modules/, /dist/, /\.twenty/], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50, }, + usePolling: true, }); this.watcher.on('all', async (event, filePath) => { - if (!filePath.match(/\.(ts|tsx|json)$/)) { + if (event === 'addDir') { return; } - - // Double-check to prevent watching our own output - if (filePath.includes('.twenty')) { - return; - } - - logger.log(`File ${event}: ${path.relative(this.appPath, filePath)}`); - - const result = await runManifestBuild(this.appPath); - - if (result.manifest) { - logger.log('👀 Watching for changes...'); - this.callbacks.onBuildSuccess?.(result); - } + this.handleChangeDetected(filePath); }); - - logger.log('📂 Watcher started'); } async close(): Promise { diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts new file mode 100644 index 0000000000..ce12c3ef3f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts @@ -0,0 +1,203 @@ +import { createLogger } from '@/cli/utilities/build/common/logger'; +import { + type ManifestBuildResult, + runManifestBuild, + updateManifestChecksum, +} from '@/cli/utilities/build/manifest/manifest-build'; +import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; +import { ApiService } from '@/cli/utilities/api/api-service'; +import { FileUploader } from '@/cli/utilities/file/file-uploader'; +import { type FileFolder } from 'twenty-shared/types'; +import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate'; + +const logger = createLogger('dev-mode'); + +export type DevModeOrchestratorOptions = { + appPath: string; + debounceMs?: number; + handleManifestBuilt: (result: ManifestBuildResult) => void | Promise; +}; + +export class DevModeOrchestrator { + private appPath: string; + private debounceMs: number; + + private builtFileInfos = new Map< + string, + { checksum: string; builtPath: string; fileFolder: FileFolder } + >(); + + private fileUploader: FileUploader | null = null; + private apiService = new ApiService(); + + private activeUploads = new Set>(); + + private syncTimer: NodeJS.Timeout | null = null; + private isSyncing = false; + + private handleManifestBuilt: ( + result: ManifestBuildResult, + ) => void | Promise; + + constructor(options: DevModeOrchestratorOptions) { + this.appPath = options.appPath; + this.debounceMs = options.debounceMs ?? 200; + this.handleManifestBuilt = options.handleManifestBuilt; + } + + async handleChangeDetected(filePath: string) { + logger.log(`File changed: ${filePath}`); + this.scheduleSync(); + } + + handleFileBuildError(errors: string[]): void { + logger.error(`Build failed:`); + for (const error of errors) { + logger.error(` ${error}`); + } + } + + handleFileBuilt({ + fileFolder, + builtPath, + filePath, + checksum, + }: { + fileFolder: FileFolder; + builtPath: string; + filePath: string; + checksum: string; + }): void { + logger.success(`✓ Successfully built ${filePath}`); + + this.builtFileInfos.set(builtPath, { checksum, builtPath, fileFolder }); + + if (this.fileUploader) { + this.uploadFile(builtPath, fileFolder); + } + + this.scheduleSync(); + } + + private uploadFile(builtPath: string, fileFolder: FileFolder): void { + logger.log(`Uploading ${builtPath}...`); + const uploadPromise = this.fileUploader!.uploadFile({ + builtPath, + fileFolder, + }) + .then((result) => { + if (result.success) { + logger.success(`Successfully uploaded ${builtPath}`); + } else { + logger.error(`Failed to upload ${builtPath}: ${result.error}`); + } + }) + .catch((error) => { + logger.error(`Upload failed for ${builtPath}: ${error}`); + }) + .finally(() => { + this.activeUploads.delete(uploadPromise); + }); + + this.activeUploads.add(uploadPromise); + } + + private cancelPendingSync(): void { + if (this.syncTimer) { + clearTimeout(this.syncTimer); + this.syncTimer = null; + } + } + + private scheduleSync(): void { + this.cancelPendingSync(); + + this.syncTimer = setTimeout(() => { + this.syncTimer = null; + void this.performSync(); + }, this.debounceMs); + } + + private async performSync(): Promise { + if (this.isSyncing) { + return; + } + + this.isSyncing = true; + + try { + logger.log(`Building manifest...`); + + const result = await runManifestBuild(this.appPath); + + if (result.error || !result.manifest) { + logger.error( + `Failed to build manifest: ${result.error ?? 'Unknown error'}`, + ); + return; + } + + const validation = validateManifest(result.manifest); + + if (!validation.isValid) { + const messages = validation.errors + .map((e) => ` • ${e.path}: ${e.message}`) + .join('\n'); + logger.error(`Invalid manifest:\n${messages}`); + + return; + } + + if (validation.warnings.length > 0) { + for (const warning of validation.warnings) { + const path = warning.path ? `${warning.path}: ` : ''; + logger.warn(`⚠ ${path}${warning.message}`); + } + } + + logger.success(`Successfully built manifest`); + + await this.handleManifestBuilt(result); + + if (!this.fileUploader) { + this.fileUploader = new FileUploader({ + appPath: this.appPath, + applicationUniversalIdentifier: + result.manifest.application.universalIdentifier, + }); + for (const [ + builtPath, + { fileFolder }, + ] of this.builtFileInfos.entries()) { + this.uploadFile(builtPath, fileFolder); + } + } + + while (this.activeUploads.size > 0) { + await Promise.all(this.activeUploads); + } + + const manifest = updateManifestChecksum({ + manifest: result.manifest, + builtFileInfos: this.builtFileInfos, + }); + + await writeManifestToOutput(this.appPath, manifest); + + logger.log('Syncing...'); + const syncResult = await this.apiService.syncApplication(manifest); + + if (syncResult.success) { + logger.success('✓ Synced'); + } else { + logger.error( + `✗ Sync failed: ${JSON.stringify(syncResult.error, null, 2)}`, + ); + } + } catch (error) { + logger.error(`✗ Sync failed: ${JSON.stringify(error)}`); + } finally { + this.isSyncing = false; + } + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/file/file-uploader.ts b/packages/twenty-sdk/src/cli/utilities/file/file-uploader.ts index ea31b860b4..ba2a67ed91 100644 --- a/packages/twenty-sdk/src/cli/utilities/file/file-uploader.ts +++ b/packages/twenty-sdk/src/cli/utilities/file/file-uploader.ts @@ -1,15 +1,12 @@ import { ApiService } from '@/cli/utilities/api/api-service'; -import path from 'path'; +import path, { relative } from 'path'; +import { type FileFolder } from 'twenty-shared/types'; import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants'; -import { FileFolder } from 'twenty-shared/types'; -import { createLogger } from '@/cli/utilities/build/common/logger'; -import { type ApplicationManifest } from 'twenty-shared/application'; export class FileUploader { private apiService = new ApiService(); private applicationUniversalIdentifier: string; private appPath: string; - private logger = createLogger('file-upload'); constructor(options: { applicationUniversalIdentifier: string; @@ -27,38 +24,13 @@ export class FileUploader { builtPath: string; fileFolder: FileFolder; }) { - const uploadResult = await this.apiService.uploadFile({ - filePath: path.join(this.appPath, OUTPUT_DIR, builtPath), - builtHandlerPath: builtPath, + const builtHandlerPath = relative(OUTPUT_DIR, builtPath); + + return await this.apiService.uploadFile({ + filePath: path.join(this.appPath, builtPath), + builtHandlerPath, fileFolder, applicationUniversalIdentifier: this.applicationUniversalIdentifier, }); - - if (uploadResult.success) { - this.logger.success(`☁️ Uploaded ${builtPath}`); - } else { - this.logger.error( - `Failed to upload ${builtPath} -- ${uploadResult.error}`, - ); - } - } - - async uploadManifestBuiltFiles(manifest: ApplicationManifest) { - const uploadPromises = [ - ...manifest.functions.map((builtFile) => - this.uploadFile({ - builtPath: builtFile.builtHandlerPath, - fileFolder: FileFolder.BuiltFunction, - }), - ), - ...manifest.frontComponents.map((builtFile) => - this.uploadFile({ - builtPath: builtFile.builtComponentPath, - fileFolder: FileFolder.BuiltFrontComponent, - }), - ), - ]; - - await Promise.all(uploadPromises); } } diff --git a/packages/twenty-sdk/vite.config.ts b/packages/twenty-sdk/vite.config.ts index 26194f56fb..b853591dca 100644 --- a/packages/twenty-sdk/vite.config.ts +++ b/packages/twenty-sdk/vite.config.ts @@ -94,6 +94,7 @@ export default defineConfig(() => { 'stream', 'util', 'os', + 'module', ], output: [ { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index d0e8318e52..da6968b838 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -948,6 +948,7 @@ export class ApplicationSyncService { code, timeoutSeconds: serverlessFunctionToSync.timeoutSeconds, sourceHandlerPath: serverlessFunctionToSync.sourceHandlerPath, + builtHandlerPath: serverlessFunctionToSync.builtHandlerPath, handlerName: serverlessFunctionToSync.handlerName, toolInputSchema: serverlessFunctionToSync.toolInputSchema, isTool: serverlessFunctionToSync.isTool, @@ -993,6 +994,7 @@ export class ApplicationSyncService { timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds, sourceHandlerPath: serverlessFunctionToCreate.sourceHandlerPath, handlerName: serverlessFunctionToCreate.handlerName, + builtHandlerPath: serverlessFunctionToCreate.builtHandlerPath, applicationId, serverlessFunctionLayerId, toolInputSchema: serverlessFunctionToCreate.toolInputSchema, diff --git a/packages/twenty-shared/src/application/applicationManifestType.ts b/packages/twenty-shared/src/application/applicationManifestType.ts index 43d15c2137..125f86cbea 100644 --- a/packages/twenty-shared/src/application/applicationManifestType.ts +++ b/packages/twenty-shared/src/application/applicationManifestType.ts @@ -18,4 +18,5 @@ export type ApplicationManifest = { roles?: RoleManifest[]; sources: Sources; packageJson: PackageJson; + yarnLock: string; };