Implement sync in dev mode (#17405)

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
```
This commit is contained in:
martmull
2026-01-26 20:32:13 +01:00
committed by GitHub
parent 570f83ea76
commit 41d470e687
64 changed files with 946 additions and 2009 deletions
@@ -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 () => {
@@ -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);
});
});
@@ -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);
});
@@ -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');
});
});
};
@@ -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);
});
@@ -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,
@@ -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"
}
]
}
@@ -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');
});
});
};
@@ -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([]);
});
@@ -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',
]);
});
});
};
@@ -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',
@@ -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';
};
@@ -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);
});
@@ -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');
});
});
};
@@ -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,
},
],
};
@@ -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
}
]
}
@@ -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');
});
});
};
@@ -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',
]);
@@ -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']);
});
});
};
@@ -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);
});
});
};
@@ -1,5 +1,6 @@
export type LogPrefix =
| 'init'
| 'dev-mode'
| 'manifest-watch'
| 'functions-watch'
| 'front-components-watch';
@@ -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<RunCliCommandResult> => {
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,
});
};
@@ -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,
});
};
@@ -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',
},
},
);
@@ -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')
@@ -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<ApiResponse<ManifestBuildResult>> {
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<ManifestBuildResult | null> {
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<void> {
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<void> {
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<void> {
await this.functionsBuilder?.close();
await this.frontComponentsBuilder?.close();
await manifestExtractFromFileServer.closeViteServer();
}
}
@@ -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<string, FileStatus>;
frontComponents: Map<string, FileStatus>;
};
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<void> {
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<void> {
const buildResult = await runManifestBuild(this.appPath);
if (!buildResult.manifest) {
private async checkServer(): Promise<void> {
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<void> {
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<void> {
await Promise.all([
this.startFunctionsWatcher(functions),
this.startFrontComponentsWatcher(frontComponents),
]);
}
private async startFunctionsWatcher(sourcePaths: string[]): Promise<void> {
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<void> {
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<void> {
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());
}
}
@@ -1,8 +0,0 @@
// Placeholder for app publish command
// TODO: Implement application publishing functionality
export class AppPublishCommand {
async execute(): Promise<void> {
throw new Error('Not implemented');
}
}
@@ -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<ApiResponse<any>> {
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;
}
}
@@ -1,8 +0,0 @@
// Placeholder for app test command
// TODO: Implement application testing functionality
export class AppTestCommand {
async execute(): Promise<void> {
throw new Error('Not implemented');
}
}
@@ -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' };
@@ -16,10 +16,7 @@ export class FunctionLogsCommand {
functionName?: string;
}): Promise<void> {
try {
const { manifest } = await runManifestBuild(appPath, {
display: false,
writeOutput: false,
});
const { manifest } = await runManifestBuild(appPath);
if (!manifest) {
process.exit(1);
@@ -1,8 +0,0 @@
// Placeholder for instance run command
// TODO: Implement instance run functionality
export class InstanceRunCommand {
async execute(): Promise<void> {
throw new Error('Not implemented');
}
}
@@ -1,8 +0,0 @@
// Placeholder for instance setup command
// TODO: Implement instance setup functionality
export class InstanceSetupCommand {
async execute(): Promise<void> {
throw new Error('Not implemented');
}
}
@@ -1,8 +0,0 @@
// Placeholder for instance upgrade command
// TODO: Implement instance upgrade functionality
export class InstanceUpgradeCommand {
async execute(): Promise<void> {
throw new Error('Not implemented');
}
}
@@ -88,13 +88,7 @@ export class ApiService {
}
}
async syncApplication({
manifest,
yarnLock,
}: {
manifest: ApplicationManifest;
yarnLock: string;
}): Promise<ApiResponse> {
async syncApplication(manifest: ApplicationManifest): Promise<ApiResponse> {
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(
@@ -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}`));
}
};
@@ -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<string, string>;
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<ProcessEsbuildResultOutput> => {
}: 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 };
};
@@ -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<string, string> = new Map();
private onFileBuilt?: OnFileBuiltCallback;
private onBuildError?: OnBuildErrorCallback;
private buildCompletePromise: Promise<void> = 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<void> {
if (this.sourcePaths.length > 0) {
await this.createContext();
}
}
async close(): Promise<void> {
await this.esBuildContext?.dispose();
this.esBuildContext = null;
}
async restart(sourcePaths: string[]): Promise<void> {
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<void> {
const outputDir = path.join(this.appPath, OUTPUT_DIR);
const entryPoints: Record<string, string> = {};
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<void>((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',
},
});
@@ -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<LoggerContext, LoggerConfig> = {
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 = {
@@ -1,3 +1,5 @@
import { type FileFolder } from 'twenty-shared/types';
export interface RestartableWatcher {
restart(sourcePaths: string[]): Promise<void>;
start(): Promise<void>;
@@ -5,14 +7,19 @@ export interface RestartableWatcher {
shouldRestart(sourcePaths: string[]): boolean;
}
export type OnFileBuiltCallback = (
builtPath: string,
checksum: string,
) => void | Promise<void>;
export type OnFileBuiltCallback = (options: {
fileFolder: FileFolder;
builtPath: string;
filePath: string;
checksum: string;
}) => void | Promise<void>;
export type OnBuildErrorCallback = (errors: string[]) => void | Promise<void>;
export type RestartableWatcherOptions = {
appPath: string;
sourcePaths: string[];
watch?: boolean;
onFileBuilt?: OnFileBuiltCallback;
handleFileBuilt: OnFileBuiltCallback;
handleBuildError: OnBuildErrorCallback;
};
@@ -1 +0,0 @@
export const FRONT_COMPONENTS_DIR = 'front-components';
@@ -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<string, string> = new Map();
private onFileBuilt?: OnFileBuiltCallback;
private buildCompletePromise: Promise<void> = 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<void> {
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<void> {
await this.esBuildContext?.dispose();
this.esBuildContext = null;
}
async restart(sourcePaths: string[]): Promise<void> {
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<void> {
const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR);
const entryPoints: Record<string, string> = {};
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<void>((resolve) => {
this.resolveBuildComplete = resolve;
});
await this.esBuildContext.rebuild();
await this.buildCompletePromise;
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
@@ -1 +0,0 @@
export const FUNCTIONS_DIR = 'functions';
@@ -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<string, string> = new Map();
private onFileBuilt?: OnFileBuiltCallback;
private buildCompletePromise: Promise<void> = 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<void> {
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<void> {
await this.esBuildContext?.dispose();
this.esBuildContext = null;
}
async restart(sourcePaths: string[]): Promise<void> {
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<void> {
const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR);
const entryPoints: Record<string, string> = {};
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<void>((resolve) => {
this.resolveBuildComplete = resolve;
});
await this.esBuildContext.rebuild();
await this.buildCompletePromise;
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
@@ -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<string> => {
const files = await glob('**/application.config.ts', {
@@ -40,7 +40,7 @@ export class ApplicationEntityBuilder
{
async build(appPath: string): Promise<EntityBuildResult<Application>> {
const applicationConfigPath = await findApplicationConfigPath(appPath);
const application =
const { manifest: application } =
await manifestExtractFromFileServer.extractManifestFromFile<Application>(
applicationConfigPath,
);
@@ -8,7 +8,7 @@ export type EntityIdWithLocation = {
export type ManifestWithoutSources = Omit<
ApplicationManifest,
'sources' | 'packageJson'
'sources' | 'packageJson' | 'yarnLock'
>;
export type EntityBuildResult<TManifest> = {
@@ -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<FrontComponentConfig>(
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(
@@ -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<ExtractedFunctionManifest>(
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(
@@ -33,11 +33,12 @@ export class ObjectExtensionEntityBuilder
try {
const absolutePath = `${appPath}/${filePath}`;
manifests.push(
const { manifest } =
await manifestExtractFromFileServer.extractManifestFromFile<ObjectExtensionManifest>(
absolutePath,
),
);
);
manifests.push(manifest);
} catch (error) {
throw new Error(
`Failed to load object extension from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
@@ -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<ObjectManifest>
@@ -34,11 +34,12 @@ export class ObjectEntityBuilder
try {
const absolutePath = `${appPath}/${filePath}`;
manifests.push(
const { manifest } =
await manifestExtractFromFileServer.extractManifestFromFile<ObjectManifest>(
absolutePath,
),
);
);
manifests.push(manifest);
} catch (error) {
throw new Error(
`Failed to load object from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
@@ -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<RoleManifest> {
async build(appPath: string): Promise<EntityBuildResult<RoleManifest>> {
@@ -30,11 +30,12 @@ export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest> {
try {
const absolutePath = `${appPath}/${filePath}`;
manifests.push(
const { manifest } =
await manifestExtractFromFileServer.extractManifestFromFile<RoleManifest>(
absolutePath,
),
);
);
manifests.push(manifest);
} catch (error) {
throw new Error(
`Failed to load role from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
@@ -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<Sources> => {
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<ManifestBuildResult> => {
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}`,
};
}
};
@@ -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}`);
}
};
@@ -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<TManifest> = {
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<TManifest>(
filepath: string,
options: ExtractManifestOptions = {},
): Promise<TManifest> {
): Promise<ExtractedManifest<TManifest>> {
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<Record<string, unknown>>(
module,
entryProperty,
);
const result = this.extractConfigFromModule<TManifest>(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<void> {
if (this.server) {
await this.server.close();
this.server = null;
}
}
private async getServer(): Promise<ViteDevServer> {
private async loadModule(filepath: string): Promise<Record<string, unknown>> {
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<string, unknown>;
} finally {
await fs.remove(tempDir);
}
}
private extractConfigFromModule<T>(
module: Record<string, unknown>,
entryProperty?: string,
): T | undefined {
const hasValidEntry = (value: unknown): boolean =>
isPlainObject(value) &&
typeof (value as Record<string, unknown>)[entryProperty!] === 'function';
if (
isDefined(module.default) &&
(!entryProperty || hasValidEntry(module.default))
) {
return module.default as T;
): ExtractedManifest<T> | 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<string | null> {
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 =
@@ -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';
}
}
@@ -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<void> {
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<void> {
@@ -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<void>;
};
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<Promise<void>>();
private syncTimer: NodeJS.Timeout | null = null;
private isSyncing = false;
private handleManifestBuilt: (
result: ManifestBuildResult,
) => void | Promise<void>;
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<void> {
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;
}
}
}
@@ -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);
}
}
+1
View File
@@ -94,6 +94,7 @@ export default defineConfig(() => {
'stream',
'util',
'os',
'module',
],
output: [
{
@@ -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,
@@ -18,4 +18,5 @@ export type ApplicationManifest = {
roles?: RoleManifest[];
sources: Sources;
packageJson: PackageJson;
yarnLock: string;
};