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:
+2
-1
@@ -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 () => {
|
||||
|
||||
+7
-6
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
-25
@@ -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);
|
||||
});
|
||||
-52
@@ -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
-5
@@ -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);
|
||||
});
|
||||
|
||||
+16
-18
@@ -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,
|
||||
|
||||
-372
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
-21
@@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+19
-6
@@ -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([]);
|
||||
});
|
||||
-28
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
};
|
||||
+2
-2
@@ -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
-5
@@ -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';
|
||||
};
|
||||
|
||||
-25
@@ -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);
|
||||
});
|
||||
-52
@@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
+119
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
-74
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
-21
@@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+7
-7
@@ -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',
|
||||
]);
|
||||
|
||||
+7
-4
@@ -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']);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+8
-11
@@ -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 = {
|
||||
|
||||
+12
-5
@@ -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';
|
||||
-176
@@ -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}`);
|
||||
}
|
||||
};
|
||||
+60
-132
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user