More improvements on SDK watch (#17314)
Refactor manifest build and remove src folder assumptions
- Unified entity builder interface: All entity builders now return
EntityBuildResult<T> with both manifests and filePaths
- Always return build result: runManifestBuild now always returns {
manifest, filePaths } instead of null on failure
- Return all entity paths: EntityFilePaths includes paths for all entity
types (application, objects, functions, etc.)
- Remove src folder assumptions: Applications can now have entities at
root level - removed hasSrcFolder checks
- Simplify watchers: Removed entries caching, compute lazily with map()
- Stabilize tests: Replaced flaky console output snapshots with key
message assertions; replaced inline snapshots with array comparisons
-
This commit is contained in:
+11
-7
@@ -1,15 +1,19 @@
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
const APP_PATH = join(__dirname, '..');
|
||||
const MANIFEST_OUTPUT_PATH = join(APP_PATH, '.twenty/output/manifest.json');
|
||||
|
||||
describe('invalid-app manifest', () => {
|
||||
it('should fail to build manifest due to duplicate universalIdentifier', async () => {
|
||||
const manifest = await runManifestBuild(APP_PATH, {
|
||||
display: false,
|
||||
writeOutput: false,
|
||||
});
|
||||
const result = await runAppDev({ appPath: APP_PATH, timeout: 10000 });
|
||||
|
||||
expect(manifest).toBeNull();
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.output).toContain('Duplicate universalIdentifier');
|
||||
|
||||
const manifestExists = await fs.pathExists(MANIFEST_OUTPUT_PATH);
|
||||
|
||||
expect(manifestExists).toBe(false);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
+1
@@ -5,4 +5,5 @@ export default defineApp({
|
||||
displayName: 'Invalid App',
|
||||
description: 'An app with duplicate IDs for testing validation',
|
||||
icon: 'IconAlertTriangle',
|
||||
functionRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
|
||||
});
|
||||
@@ -12,6 +12,6 @@
|
||||
"@/*": ["../../../../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["**/*"],
|
||||
"exclude": ["node_modules", "dist", ".twenty"]
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`rich-app app:dev > console output > should match expected output 1`] = `
|
||||
"👩💻 Workspace - default
|
||||
🚀 Starting Twenty Application Development Mode
|
||||
📁 App Path: <APP_PATH>/root.function.ts)
|
||||
- greeting-function (src/functions/greeting.function.ts)
|
||||
- test-function-2 (src/utils/test-function-2.util.ts)
|
||||
- test-function (src/functions/test-function.function.ts)
|
||||
✓ Found 4 front component(s)
|
||||
📍 Front component entry points:
|
||||
- root-component (src/root.front-component.tsx)
|
||||
- card-component (src/components/card.front-component.tsx)
|
||||
- greeting-component (src/components/greeting.front-component.tsx)
|
||||
- test-component (src/components/test.front-component.tsx)
|
||||
✓ Found 2 role(s)
|
||||
✓ Manifest written to <APP_PATH>/.twenty/output/manifest.json
|
||||
📂 Manifest watcher started
|
||||
📦 Building functions...
|
||||
🎨 Building front components...
|
||||
✓ Functions built
|
||||
|
||||
👀 Watching for changes... (Press Ctrl+C to stop)
|
||||
✓ Front components built"
|
||||
`;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
|
||||
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
|
||||
import { join } from 'path';
|
||||
|
||||
import { defineConsoleOutputTests } from './tests/console-output.tests';
|
||||
import { defineFrontComponentsTests } from './tests/front-components.tests';
|
||||
import { defineFunctionsTests } from './tests/functions.tests';
|
||||
import { defineManifestTests } from './tests/manifest.tests';
|
||||
|
||||
const APP_PATH = join(__dirname, '../..');
|
||||
|
||||
describe('rich-app app:dev', () => {
|
||||
let result: RunCliCommandResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runAppDev({ appPath: APP_PATH });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
defineConsoleOutputTests(() => result);
|
||||
defineManifestTests(APP_PATH);
|
||||
defineFunctionsTests(APP_PATH);
|
||||
defineFrontComponentsTests(APP_PATH);
|
||||
});
|
||||
+24
-2
@@ -24,7 +24,7 @@
|
||||
},
|
||||
{
|
||||
"componentName": "CardDisplay",
|
||||
"componentPath": "src/utils/card-display.component.tsx",
|
||||
"componentPath": "src/components/card.front-component.tsx",
|
||||
"description": "A component using an external component file",
|
||||
"name": "card-component",
|
||||
"universalIdentifier": "i0i1i2i3-i4i5-4000-8000-000000000001"
|
||||
@@ -189,7 +189,29 @@
|
||||
}
|
||||
],
|
||||
"packageJson": {
|
||||
"name": "rich-app"
|
||||
"name": "rich-app",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"create-entity": "twenty app add",
|
||||
"dev": "twenty app dev",
|
||||
"generate": "twenty app generate",
|
||||
"sync": "twenty app sync",
|
||||
"uninstall": "twenty app uninstall",
|
||||
"auth": "twenty auth login"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
|
||||
import { sanitizeOutput } from '@/cli/__tests__/integration/utils/sanitize-output.util';
|
||||
|
||||
export const defineConsoleOutputTests = (
|
||||
getResult: () => RunCliCommandResult,
|
||||
): void => {
|
||||
describe('console output', () => {
|
||||
it('should match expected output', () => {
|
||||
const result = getResult();
|
||||
|
||||
expect(sanitizeOutput(result.output)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
export const defineFrontComponentsTests = (appPath: string): void => {
|
||||
describe('front-components', () => {
|
||||
it('should have built front components preserving source path structure', async () => {
|
||||
const frontComponentsDir = join(appPath, '.twenty/output/front-components');
|
||||
const files = await fs.readdir(frontComponentsDir, { recursive: true });
|
||||
const sortedFiles = files.map((f) => f.toString()).sort();
|
||||
|
||||
expect(sortedFiles).toEqual([
|
||||
'src',
|
||||
'src/components',
|
||||
'src/components/card.front-component.mjs',
|
||||
'src/components/card.front-component.mjs.map',
|
||||
'src/components/greeting.front-component.mjs',
|
||||
'src/components/greeting.front-component.mjs.map',
|
||||
'src/components/test.front-component.mjs',
|
||||
'src/components/test.front-component.mjs.map',
|
||||
'src/root.front-component.mjs',
|
||||
'src/root.front-component.mjs.map',
|
||||
]);
|
||||
});
|
||||
});
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
export const defineFunctionsTests = (appPath: string): void => {
|
||||
describe('functions', () => {
|
||||
it('should have built functions preserving source path structure', async () => {
|
||||
const functionsDir = join(appPath, '.twenty/output/functions');
|
||||
const files = await fs.readdir(functionsDir, { recursive: true });
|
||||
const sortedFiles = files.map((f) => f.toString()).sort();
|
||||
|
||||
expect(sortedFiles).toEqual([
|
||||
'src',
|
||||
'src/functions',
|
||||
'src/functions/greeting.function.mjs',
|
||||
'src/functions/greeting.function.mjs.map',
|
||||
'src/functions/test-function-2.function.mjs',
|
||||
'src/functions/test-function-2.function.mjs.map',
|
||||
'src/functions/test-function.function.mjs',
|
||||
'src/functions/test-function.function.mjs.map',
|
||||
'src/root.function.mjs',
|
||||
'src/root.function.mjs.map',
|
||||
]);
|
||||
});
|
||||
});
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
import expectedManifest from '../manifest.expected.json';
|
||||
|
||||
export const defineManifestTests = (appPath: string): void => {
|
||||
const manifestOutputPath = join(appPath, '.twenty/output/manifest.json');
|
||||
|
||||
describe('manifest', () => {
|
||||
it('should build manifest matching expected JSON', async () => {
|
||||
const manifest = await fs.readJson(manifestOutputPath);
|
||||
|
||||
expect(manifest).not.toBeNull();
|
||||
|
||||
const { sources: _sources, ...sanitizedManifest } = manifest;
|
||||
|
||||
expect(sanitizedManifest).toEqual(expectedManifest);
|
||||
});
|
||||
|
||||
it('should have correct application config', async () => {
|
||||
const manifest = await fs.readJson(manifestOutputPath);
|
||||
|
||||
expect(manifest?.application.displayName).toBe('Hello World');
|
||||
expect(manifest?.application.description).toBe('A simple hello world app');
|
||||
});
|
||||
|
||||
it('should load all entity types', async () => {
|
||||
const manifest = await fs.readJson(manifestOutputPath);
|
||||
|
||||
expect(manifest?.objects).toHaveLength(2);
|
||||
expect(manifest?.serverlessFunctions).toHaveLength(4);
|
||||
expect(manifest?.frontComponents).toHaveLength(4);
|
||||
expect(manifest?.roles).toHaveLength(2);
|
||||
expect(manifest?.objectExtensions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
};
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { join } from 'path';
|
||||
|
||||
import expectedManifest from './manifest.expected.json';
|
||||
|
||||
const APP_PATH = join(__dirname, '..');
|
||||
|
||||
describe('rich-app manifest', () => {
|
||||
it('should build manifest matching expected JSON', async () => {
|
||||
const manifest = await runManifestBuild(APP_PATH, {
|
||||
display: false,
|
||||
writeOutput: false,
|
||||
});
|
||||
|
||||
expect(manifest).not.toBeNull();
|
||||
|
||||
const { sources: _sources, ...sanitizedManifest } = {
|
||||
...manifest,
|
||||
packageJson: {
|
||||
name: manifest!.packageJson.name,
|
||||
},
|
||||
};
|
||||
|
||||
expect(sanitizedManifest).toEqual(expectedManifest);
|
||||
});
|
||||
|
||||
it('should have correct application config', async () => {
|
||||
const manifest = await runManifestBuild(APP_PATH, {
|
||||
display: false,
|
||||
writeOutput: false,
|
||||
});
|
||||
|
||||
expect(manifest?.application.displayName).toBe('Hello World');
|
||||
expect(manifest?.application.description).toBe('A simple hello world app');
|
||||
});
|
||||
|
||||
it('should load all entity types', async () => {
|
||||
const manifest = await runManifestBuild(APP_PATH, {
|
||||
display: false,
|
||||
writeOutput: false,
|
||||
});
|
||||
|
||||
expect(manifest?.objects).toHaveLength(2);
|
||||
expect(manifest?.serverlessFunctions).toHaveLength(4);
|
||||
expect(manifest?.frontComponents).toHaveLength(4);
|
||||
expect(manifest?.roles).toHaveLength(2);
|
||||
expect(manifest?.objectExtensions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { defineApp } from '@/application/define-app';
|
||||
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role';
|
||||
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './src/roles/default-function.role';
|
||||
|
||||
export default defineApp({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -12,5 +12,6 @@
|
||||
"@/*": ["../../../../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["**/*"],
|
||||
"exclude": ["node_modules", "dist", ".twenty"]
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { runAppDev } from '../../../../integration/utils/run-app-dev.util';
|
||||
import { type RunCliCommandResult } from '../../../../integration/utils/run-cli-command.util';
|
||||
import { defineConsoleOutputTests } from './tests/console-output.tests';
|
||||
import { defineFrontComponentsTests } from './tests/front-components.tests';
|
||||
import { defineFunctionsTests } from './tests/functions.tests';
|
||||
import { defineManifestTests } from './tests/manifest.tests';
|
||||
|
||||
const APP_PATH = join(__dirname, '../..');
|
||||
|
||||
describe('root-app app:dev', () => {
|
||||
let result: RunCliCommandResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runAppDev({ appPath: APP_PATH });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
defineConsoleOutputTests(() => result);
|
||||
defineManifestTests(APP_PATH);
|
||||
defineFunctionsTests(APP_PATH);
|
||||
defineFrontComponentsTests(APP_PATH);
|
||||
});
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"application": {
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000001",
|
||||
"displayName": "Root App",
|
||||
"description": "An app with all entities at root level",
|
||||
"icon": "IconFolder",
|
||||
"functionRoleUniversalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000002"
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000030",
|
||||
"nameSingular": "myNote",
|
||||
"namePlural": "myNotes",
|
||||
"labelSingular": "My note",
|
||||
"labelPlural": "My notes",
|
||||
"description": "A simple root-level object",
|
||||
"icon": "IconNote",
|
||||
"fields": [
|
||||
{
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000031",
|
||||
"type": "TEXT",
|
||||
"label": "Title",
|
||||
"name": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"serverlessFunctions": [
|
||||
{
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000010",
|
||||
"name": "my-function",
|
||||
"timeoutSeconds": 5,
|
||||
"triggers": [
|
||||
{
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000011",
|
||||
"type": "route",
|
||||
"path": "/my-function",
|
||||
"httpMethod": "GET",
|
||||
"isAuthRequired": false
|
||||
}
|
||||
],
|
||||
"handlerName": "myHandler",
|
||||
"handlerPath": "my.function.ts"
|
||||
}
|
||||
],
|
||||
"frontComponents": [
|
||||
{
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000020",
|
||||
"name": "my-component",
|
||||
"description": "A root-level front component",
|
||||
"componentName": "MyComponent",
|
||||
"componentPath": "my.front-component.tsx"
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"universalIdentifier": "e1e2e3e4-e5e6-4000-8000-000000000040",
|
||||
"label": "My role",
|
||||
"description": "A simple root-level role",
|
||||
"canReadAllObjectRecords": true,
|
||||
"canUpdateAllObjectRecords": false,
|
||||
"canSoftDeleteAllObjectRecords": false,
|
||||
"canDestroyAllObjectRecords": false,
|
||||
"canUpdateAllSettings": false,
|
||||
"canBeAssignedToAgents": false,
|
||||
"canBeAssignedToUsers": true,
|
||||
"canBeAssignedToApiKeys": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { type RunCliCommandResult } from '../../../../../integration/utils/run-cli-command.util';
|
||||
|
||||
export const defineConsoleOutputTests = (
|
||||
getResult: () => RunCliCommandResult,
|
||||
): void => {
|
||||
describe('console output', () => {
|
||||
it('should contain key messages', () => {
|
||||
const result = getResult();
|
||||
const output = result.output;
|
||||
|
||||
expect(output).toContain('Starting Twenty Application Development Mode');
|
||||
expect(output).toContain('Building manifest');
|
||||
expect(output).toContain('Loaded "Root App"');
|
||||
expect(output).toContain('Found 1 object(s)');
|
||||
expect(output).toContain('Found 1 function(s)');
|
||||
expect(output).toContain('Found 1 front component(s)');
|
||||
expect(output).toContain('Found 1 role(s)');
|
||||
expect(output).toContain('Manifest written to');
|
||||
expect(output).toContain('Functions built');
|
||||
});
|
||||
});
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
export const defineFrontComponentsTests = (appPath: string): void => {
|
||||
describe('front-components', () => {
|
||||
it('should have built front components at root level', async () => {
|
||||
const frontComponentsDir = join(appPath, '.twenty/output/front-components');
|
||||
const files = await fs.readdir(frontComponentsDir, { recursive: true });
|
||||
const sortedFiles = files.map((f) => f.toString()).sort();
|
||||
|
||||
expect(sortedFiles).toEqual([
|
||||
'my.front-component.mjs',
|
||||
'my.front-component.mjs.map',
|
||||
]);
|
||||
});
|
||||
});
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
export const defineFunctionsTests = (appPath: string): void => {
|
||||
describe('functions', () => {
|
||||
it('should have built functions at root level', async () => {
|
||||
const functionsDir = join(appPath, '.twenty/output/functions');
|
||||
const files = await fs.readdir(functionsDir, { recursive: true });
|
||||
const sortedFiles = files.map((f) => f.toString()).sort();
|
||||
|
||||
expect(sortedFiles).toEqual([
|
||||
'my.function.mjs',
|
||||
'my.function.mjs.map',
|
||||
]);
|
||||
});
|
||||
});
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { type ApplicationManifest } from 'twenty-shared/application';
|
||||
|
||||
export const defineManifestTests = (appPath: string): void => {
|
||||
describe('manifest', () => {
|
||||
it('should have generated manifest.json', async () => {
|
||||
const manifestPath = join(appPath, '.twenty/output/manifest.json');
|
||||
const exists = await fs.pathExists(manifestPath);
|
||||
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct manifest content', async () => {
|
||||
const manifestPath = join(appPath, '.twenty/output/manifest.json');
|
||||
const manifest: ApplicationManifest = await fs.readJSON(manifestPath);
|
||||
const expectedPath = join(appPath, '__integration__/app-dev/manifest.expected.json');
|
||||
const expected: ApplicationManifest = await fs.readJSON(expectedPath);
|
||||
|
||||
expect(manifest.application).toEqual(expected.application);
|
||||
expect(manifest.objects).toEqual(expected.objects);
|
||||
expect(manifest.serverlessFunctions).toEqual(expected.serverlessFunctions);
|
||||
expect(manifest.frontComponents).toEqual(expected.frontComponents);
|
||||
expect(manifest.roles).toEqual(expected.roles);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineApp } from '@/application/define-app';
|
||||
|
||||
export default defineApp({
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
|
||||
displayName: 'Root App',
|
||||
description: 'An app with all entities at root level',
|
||||
icon: 'IconFolder',
|
||||
functionRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineFrontComponent } from '@/application/front-components/define-front-component';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<div style={{ padding: '10px' }}>
|
||||
<h2>My Component</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000020',
|
||||
name: 'my-component',
|
||||
description: 'A root-level front component',
|
||||
component: MyComponent,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineFunction } from '@/application/functions/define-function';
|
||||
|
||||
const myHandler = () => {
|
||||
return 'my-function-result';
|
||||
};
|
||||
|
||||
export default defineFunction({
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000010',
|
||||
name: 'my-function',
|
||||
timeoutSeconds: 5,
|
||||
handler: myHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000011',
|
||||
type: 'route',
|
||||
path: '/my-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { FieldType } from '@/application/fields/field-type';
|
||||
import { defineObject } from '@/application/objects/define-object';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000030',
|
||||
nameSingular: 'myNote',
|
||||
namePlural: 'myNotes',
|
||||
labelSingular: 'My note',
|
||||
labelPlural: 'My notes',
|
||||
description: 'A simple root-level object',
|
||||
icon: 'IconNote',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000031',
|
||||
type: FieldType.TEXT,
|
||||
label: 'Title',
|
||||
name: 'title',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineRole } from '@/application/roles/define-role';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
|
||||
label: 'My role',
|
||||
description: 'A simple root-level role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: true,
|
||||
canBeAssignedToApiKeys: false,
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "root-app",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"create-entity": "twenty app add",
|
||||
"dev": "twenty app dev",
|
||||
"generate": "twenty app generate",
|
||||
"sync": "twenty app sync",
|
||||
"uninstall": "twenty app uninstall",
|
||||
"auth": "twenty auth login"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../../../../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { runCliCommand, type RunCliCommandResult } from './run-cli-command.util';
|
||||
|
||||
export type RunAppDevOptions = {
|
||||
appPath: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const runAppDev = (options: RunAppDevOptions): Promise<RunCliCommandResult> => {
|
||||
const { appPath, timeout = 30000 } = options;
|
||||
|
||||
|
||||
return runCliCommand({
|
||||
command: 'app:dev',
|
||||
args: [appPath],
|
||||
waitForOutput: [
|
||||
'✓ Manifest written to',
|
||||
'✓ Functions built',
|
||||
'✓ Front components built',
|
||||
],
|
||||
timeout,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// CLI path and working directory (twenty-sdk src directory)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const CLI_DIR = path.resolve(__dirname, '../../../..');
|
||||
const CLI_PATH = path.resolve(CLI_DIR, 'cli/cli.ts');
|
||||
|
||||
export type RunCliCommandOptions = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
waitForOutput?: string | string[];
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type RunCliCommandResult = {
|
||||
success: boolean;
|
||||
output: string;
|
||||
};
|
||||
|
||||
export const runCliCommand = (
|
||||
options: RunCliCommandOptions,
|
||||
): Promise<RunCliCommandResult> => {
|
||||
const {
|
||||
command,
|
||||
args = [],
|
||||
waitForOutput,
|
||||
timeout = 30000,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Run from CLI directory to use twenty-sdk's tsconfig paths
|
||||
const child: ChildProcess = spawn(
|
||||
'npx',
|
||||
['tsx', CLI_PATH, command, ...args],
|
||||
{
|
||||
cwd: CLI_DIR,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, FORCE_COLOR: '0' },
|
||||
},
|
||||
);
|
||||
|
||||
let output = '';
|
||||
const timeoutId = setTimeout(() => {
|
||||
child.kill();
|
||||
resolve({ success: false, output });
|
||||
}, timeout);
|
||||
|
||||
const waitForOutputs = Array.isArray(waitForOutput)
|
||||
? waitForOutput
|
||||
: waitForOutput
|
||||
? [waitForOutput]
|
||||
: [];
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
if (
|
||||
waitForOutputs.length > 0 &&
|
||||
waitForOutputs.every((w) => output.includes(w))
|
||||
) {
|
||||
clearTimeout(timeoutId);
|
||||
child.kill();
|
||||
resolve({ success: true, output });
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (waitForOutputs.length === 0) {
|
||||
resolve({ success: code === 0, output });
|
||||
} else {
|
||||
resolve({ success: false, output });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ success: false, output });
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export const sanitizeOutput = (output: string): string => {
|
||||
return output
|
||||
// Remove ANSI color codes
|
||||
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
|
||||
// Normalize file paths (replace any absolute path to a test app)
|
||||
.replace(/\/[^\s]+\/__tests__\/apps\/[^/]+/g, '<APP_PATH>')
|
||||
// Normalize timestamps
|
||||
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, '<TIMESTAMP>')
|
||||
// Normalize durations
|
||||
.replace(/\d+ms/g, '<DURATION>')
|
||||
// Trim trailing whitespace from each line
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -15,7 +15,7 @@ export class AppBuildCommand {
|
||||
console.log(chalk.gray(`📁 App Path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const manifest = await runManifestBuild(appPath);
|
||||
const { manifest } = await runManifestBuild(appPath);
|
||||
|
||||
if (!manifest) {
|
||||
return { success: false, error: 'Build failed' };
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { FrontComponentsWatcher } from '@/cli/utilities/build/front-components/front-component-watcher';
|
||||
import { FunctionsWatcher } from '@/cli/utilities/build/functions/function-watcher';
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { runManifestBuild, type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
import { type ApplicationManifest } from 'twenty-shared/application';
|
||||
|
||||
export type AppDevOptions = {
|
||||
appPath?: string;
|
||||
};
|
||||
|
||||
type AppDevState = {
|
||||
manifest: ApplicationManifest | null;
|
||||
buildResult: ManifestBuildResult | null;
|
||||
};
|
||||
|
||||
export class AppDevCommand {
|
||||
@@ -21,7 +20,7 @@ export class AppDevCommand {
|
||||
|
||||
private appPath: string = '';
|
||||
private state: AppDevState = {
|
||||
manifest: null,
|
||||
buildResult: null,
|
||||
};
|
||||
|
||||
async execute(options: AppDevOptions): Promise<void> {
|
||||
@@ -37,32 +36,32 @@ export class AppDevCommand {
|
||||
}
|
||||
|
||||
private async startWatchers(): Promise<void> {
|
||||
const manifest = await runManifestBuild(this.appPath);
|
||||
const buildResult = await runManifestBuild(this.appPath);
|
||||
|
||||
if (!manifest) {
|
||||
if (!buildResult.manifest) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.manifest = manifest;
|
||||
this.state.buildResult = buildResult;
|
||||
|
||||
await this.startManifestWatcher();
|
||||
await this.startFunctionsWatcher(manifest);
|
||||
await this.startFrontComponentsWatcher(manifest);
|
||||
await this.startFunctionsWatcher(buildResult);
|
||||
await this.startFrontComponentsWatcher(buildResult);
|
||||
}
|
||||
|
||||
private async startManifestWatcher(): Promise<void> {
|
||||
this.manifestWatcher = new ManifestWatcher({
|
||||
appPath: this.appPath,
|
||||
callbacks: {
|
||||
onBuildSuccess: (manifest) => {
|
||||
this.state.manifest = manifest;
|
||||
onBuildSuccess: (result) => {
|
||||
this.state.buildResult = result;
|
||||
|
||||
if (this.functionsWatcher?.shouldRestart(manifest)) {
|
||||
this.functionsWatcher.restart(manifest);
|
||||
if (this.functionsWatcher?.shouldRestart(result)) {
|
||||
this.functionsWatcher.restart(result);
|
||||
}
|
||||
|
||||
if (this.frontComponentsWatcher?.shouldRestart(manifest)) {
|
||||
this.frontComponentsWatcher.restart(manifest);
|
||||
if (this.frontComponentsWatcher?.shouldRestart(result)) {
|
||||
this.frontComponentsWatcher.restart(result);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -71,19 +70,19 @@ export class AppDevCommand {
|
||||
await this.manifestWatcher.start();
|
||||
}
|
||||
|
||||
private async startFunctionsWatcher(manifest: ApplicationManifest): Promise<void> {
|
||||
private async startFunctionsWatcher(buildResult: ManifestBuildResult): Promise<void> {
|
||||
this.functionsWatcher = new FunctionsWatcher({
|
||||
appPath: this.appPath,
|
||||
manifest,
|
||||
buildResult,
|
||||
});
|
||||
|
||||
await this.functionsWatcher.start();
|
||||
}
|
||||
|
||||
private async startFrontComponentsWatcher(manifest: ApplicationManifest): Promise<void> {
|
||||
private async startFrontComponentsWatcher(buildResult: ManifestBuildResult): Promise<void> {
|
||||
this.frontComponentsWatcher = new FrontComponentsWatcher({
|
||||
appPath: this.appPath,
|
||||
manifest,
|
||||
buildResult,
|
||||
});
|
||||
|
||||
await this.frontComponentsWatcher.start();
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AppSyncCommand {
|
||||
console.log(chalk.gray(`📁 App Path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const manifest = await runManifestBuild(appPath, { writeOutput: false });
|
||||
const { manifest } = await runManifestBuild(appPath, { writeOutput: false });
|
||||
|
||||
if (!manifest) {
|
||||
return { success: false, error: 'Build failed' };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
|
||||
import { ApiService } from '@/cli/utilities/api/services/api.service';
|
||||
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export class AppUninstallCommand {
|
||||
private apiService = new ApiService();
|
||||
@@ -25,7 +25,7 @@ export class AppUninstallCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = await runManifestBuild(appPath, { display: false, writeOutput: false });
|
||||
const { manifest } = await runManifestBuild(appPath, { display: false, writeOutput: false });
|
||||
|
||||
if (!manifest) {
|
||||
return { success: false, error: 'Build failed' };
|
||||
|
||||
@@ -30,7 +30,7 @@ export class FunctionExecuteCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = await runManifestBuild(appPath);
|
||||
const { manifest } = await runManifestBuild(appPath);
|
||||
|
||||
if (!manifest) {
|
||||
console.error(chalk.red('Failed to build manifest.'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import chalk from 'chalk';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
|
||||
import { ApiService } from '@/cli/utilities/api/services/api.service';
|
||||
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export class FunctionLogsCommand {
|
||||
private apiService = new ApiService();
|
||||
@@ -16,7 +16,7 @@ export class FunctionLogsCommand {
|
||||
functionName?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const manifest = await runManifestBuild(appPath, { display: false, writeOutput: false });
|
||||
const { manifest } = await runManifestBuild(appPath, { display: false, writeOutput: false });
|
||||
|
||||
if (!manifest) {
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { type ApplicationManifest } from 'twenty-shared/application';
|
||||
|
||||
import { type ManifestBuildResult } from '../manifest/manifest-build';
|
||||
|
||||
export interface RestartableWatcher {
|
||||
restart(manifest: ApplicationManifest): Promise<void>;
|
||||
restart(result: ManifestBuildResult): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
shouldRestart(
|
||||
oldManifest: ApplicationManifest | null,
|
||||
newManifest: ApplicationManifest,
|
||||
): boolean;
|
||||
shouldRestart(result: ManifestBuildResult): boolean;
|
||||
}
|
||||
|
||||
export type RestartableWatcherOptions = {
|
||||
appPath: string;
|
||||
manifest: ApplicationManifest | null;
|
||||
buildResult: ManifestBuildResult | null;
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const computeFrontComponentOutputPath = (componentPath: string): string => {
|
||||
const normalizedPath = componentPath.replace(/\\/g, '/');
|
||||
|
||||
let relativePath = normalizedPath;
|
||||
if (relativePath.startsWith('src/')) {
|
||||
relativePath = relativePath.slice('src/'.length);
|
||||
}
|
||||
|
||||
return relativePath.replace(/\.tsx?$/, '.js');
|
||||
};
|
||||
+24
-52
@@ -1,7 +1,6 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import type { ApplicationManifest } from 'twenty-shared/application';
|
||||
import { build, type InlineConfig, type Rollup } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { OUTPUT_DIR } from '../common/constants';
|
||||
@@ -10,68 +9,42 @@ import {
|
||||
type RestartableWatcher,
|
||||
type RestartableWatcherOptions,
|
||||
} from '../common/restartable-watcher.interface';
|
||||
import { type ManifestBuildResult } from '../manifest/manifest-build';
|
||||
import { FRONT_COMPONENTS_DIR } from './constants';
|
||||
import { computeFrontComponentOutputPath } from './front-component-paths';
|
||||
|
||||
const buildFrontComponentEntries = (
|
||||
appPath: string,
|
||||
componentPaths: Array<{ componentPath: string }>,
|
||||
): Record<string, string> => {
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const component of componentPaths) {
|
||||
const relativePath = computeFrontComponentOutputPath(component.componentPath);
|
||||
const chunkName = relativePath.replace(/\.js$/, '');
|
||||
entries[chunkName] = path.join(appPath, component.componentPath);
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
/^twenty-sdk/,
|
||||
/^twenty-shared/,
|
||||
/^@\//,
|
||||
];
|
||||
|
||||
export class FrontComponentsWatcher implements RestartableWatcher {
|
||||
private appPath: string;
|
||||
private entries: Record<string, string>;
|
||||
private componentPaths: string[];
|
||||
private innerWatcher: Rollup.RollupWatcher | null = null;
|
||||
private isRestarting = false;
|
||||
|
||||
constructor(options: RestartableWatcherOptions) {
|
||||
this.appPath = options.appPath;
|
||||
this.entries = buildFrontComponentEntries(
|
||||
options.appPath,
|
||||
options.manifest?.frontComponents ?? [],
|
||||
);
|
||||
this.componentPaths = options.buildResult?.filePaths.frontComponents ?? [];
|
||||
}
|
||||
|
||||
shouldRestart(manifest: ApplicationManifest): boolean {
|
||||
const newEntries = buildFrontComponentEntries(this.appPath, manifest.frontComponents ?? []);
|
||||
const currentKeys = Object.keys(this.entries).sort();
|
||||
const newKeys = Object.keys(newEntries).sort();
|
||||
shouldRestart(result: ManifestBuildResult): boolean {
|
||||
const currentPaths = this.componentPaths.sort().join(',');
|
||||
const newPaths = result.filePaths.frontComponents.sort().join(',');
|
||||
|
||||
if (currentKeys.length !== newKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentKeys.length; i++) {
|
||||
if (currentKeys[i] !== newKeys[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return currentPaths !== newPaths;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
if (this.hasEntries()) {
|
||||
if (this.componentPaths.length > 0) {
|
||||
console.log(chalk.blue(' 🎨 Building front components...'));
|
||||
this.innerWatcher = await this.createWatcher();
|
||||
} else {
|
||||
@@ -84,7 +57,7 @@ export class FrontComponentsWatcher implements RestartableWatcher {
|
||||
await this.innerWatcher?.close();
|
||||
}
|
||||
|
||||
async restart(manifest: ApplicationManifest): Promise<void> {
|
||||
async restart(result: ManifestBuildResult): Promise<void> {
|
||||
if (this.isRestarting) {
|
||||
return;
|
||||
}
|
||||
@@ -96,9 +69,9 @@ export class FrontComponentsWatcher implements RestartableWatcher {
|
||||
await this.innerWatcher?.close();
|
||||
this.innerWatcher = null;
|
||||
|
||||
this.entries = buildFrontComponentEntries(this.appPath, manifest.frontComponents ?? []);
|
||||
this.componentPaths = result.filePaths.frontComponents;
|
||||
|
||||
if (this.hasEntries()) {
|
||||
if (this.componentPaths.length > 0) {
|
||||
console.log(chalk.blue(' 🎨 Building front components...'));
|
||||
this.innerWatcher = await this.createWatcher();
|
||||
} else {
|
||||
@@ -112,10 +85,6 @@ export class FrontComponentsWatcher implements RestartableWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private hasEntries(): boolean {
|
||||
return Object.keys(this.entries).length > 0;
|
||||
}
|
||||
|
||||
private async createWatcher(): Promise<Rollup.RollupWatcher> {
|
||||
const config = this.createConfig();
|
||||
const watcher = await build(config) as Rollup.RollupWatcher;
|
||||
@@ -135,6 +104,13 @@ export class FrontComponentsWatcher implements RestartableWatcher {
|
||||
private createConfig(): InlineConfig {
|
||||
const frontComponentsOutputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR);
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
this.componentPaths.map((filePath) => [
|
||||
filePath.replace(/\.tsx?$/, ''),
|
||||
path.join(this.appPath, filePath),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
root: this.appPath,
|
||||
plugins: [
|
||||
@@ -147,21 +123,17 @@ export class FrontComponentsWatcher implements RestartableWatcher {
|
||||
outDir: frontComponentsOutputDir,
|
||||
emptyOutDir: false,
|
||||
watch: {
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'],
|
||||
include: ['**/*.ts', '**/*.tsx', '**/*.json'],
|
||||
exclude: ['node_modules/**', '.twenty/**', 'dist/**'],
|
||||
},
|
||||
lib: {
|
||||
entry: this.entries,
|
||||
entry: entries,
|
||||
formats: ['es'],
|
||||
fileName: (_, entryName) => `${entryName}.js`,
|
||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: FRONT_COMPONENT_EXTERNAL_MODULES,
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: false,
|
||||
exports: 'named',
|
||||
},
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import { computeFunctionOutputPath } from '../function-paths';
|
||||
|
||||
describe('computeFunctionOutputPath', () => {
|
||||
it('should handle function in src/ root', () => {
|
||||
const result = computeFunctionOutputPath('src/hello.function.ts');
|
||||
|
||||
expect(result).toBe('hello.function.js');
|
||||
});
|
||||
|
||||
it('should handle function in subdirectory', () => {
|
||||
const result = computeFunctionOutputPath('src/utils/greet.function.ts');
|
||||
|
||||
expect(result).toBe('utils/greet.function.js');
|
||||
});
|
||||
|
||||
it('should handle deeply nested function', () => {
|
||||
const result = computeFunctionOutputPath(
|
||||
'src/modules/auth/handlers/login.function.ts',
|
||||
);
|
||||
|
||||
expect(result).toBe('modules/auth/handlers/login.function.js');
|
||||
});
|
||||
|
||||
it('should handle path without src/ prefix', () => {
|
||||
const result = computeFunctionOutputPath('handlers/webhook.function.ts');
|
||||
|
||||
expect(result).toBe('handlers/webhook.function.js');
|
||||
});
|
||||
|
||||
it('should normalize Windows path separators', () => {
|
||||
const result = computeFunctionOutputPath('src\\utils\\greet.function.ts');
|
||||
|
||||
expect(result).toBe('utils/greet.function.js');
|
||||
});
|
||||
|
||||
it('should change .ts extension to .js', () => {
|
||||
const result = computeFunctionOutputPath('src/test.function.ts');
|
||||
|
||||
expect(result.endsWith('.js')).toBe(true);
|
||||
expect(result.endsWith('.ts')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export const computeFunctionOutputPath = (handlerPath: string): string => {
|
||||
const normalizedPath = handlerPath.replace(/\\/g, '/');
|
||||
|
||||
let relativePath = normalizedPath;
|
||||
if (relativePath.startsWith('src/')) {
|
||||
relativePath = relativePath.slice('src/'.length);
|
||||
}
|
||||
|
||||
return relativePath.replace(/\.ts$/, '.js');
|
||||
};
|
||||
@@ -1,32 +1,16 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import type { ApplicationManifest } from 'twenty-shared/application';
|
||||
import { build, type InlineConfig, type Rollup } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { GENERATED_DIR, OUTPUT_DIR } from '../common/constants';
|
||||
import { OUTPUT_DIR } from '../common/constants';
|
||||
import { printWatchingMessage } from '../common/display';
|
||||
import {
|
||||
type RestartableWatcher,
|
||||
type RestartableWatcherOptions,
|
||||
} from '../common/restartable-watcher.interface';
|
||||
import { type ManifestBuildResult } from '../manifest/manifest-build';
|
||||
import { FUNCTIONS_DIR } from './constants';
|
||||
import { computeFunctionOutputPath } from './function-paths';
|
||||
|
||||
const buildFunctionEntries = (
|
||||
appPath: string,
|
||||
handlerPaths: Array<{ handlerPath: string }>,
|
||||
): Record<string, string> => {
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const fn of handlerPaths) {
|
||||
const relativePath = computeFunctionOutputPath(fn.handlerPath);
|
||||
const chunkName = relativePath.replace(/\.js$/, '');
|
||||
entries[chunkName] = path.join(appPath, fn.handlerPath);
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const FUNCTION_EXTERNAL_MODULES: (string | RegExp)[] = [
|
||||
'path', 'fs', 'crypto', 'stream', 'util', 'os', 'url', 'http', 'https',
|
||||
@@ -37,41 +21,27 @@ export const FUNCTION_EXTERNAL_MODULES: (string | RegExp)[] = [
|
||||
|
||||
export class FunctionsWatcher implements RestartableWatcher {
|
||||
private appPath: string;
|
||||
private entries: Record<string, string>;
|
||||
private functionPaths: string[];
|
||||
private innerWatcher: Rollup.RollupWatcher | null = null;
|
||||
private isRestarting = false;
|
||||
|
||||
constructor(options: RestartableWatcherOptions) {
|
||||
this.appPath = options.appPath;
|
||||
this.entries = buildFunctionEntries(
|
||||
options.appPath,
|
||||
options.manifest?.serverlessFunctions ?? [],
|
||||
);
|
||||
this.functionPaths = options.buildResult?.filePaths.functions ?? [];
|
||||
}
|
||||
|
||||
shouldRestart(manifest: ApplicationManifest): boolean {
|
||||
const newEntries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions ?? []);
|
||||
const currentKeys = Object.keys(this.entries).sort();
|
||||
const newKeys = Object.keys(newEntries).sort();
|
||||
shouldRestart(result: ManifestBuildResult): boolean {
|
||||
const currentPaths = this.functionPaths.sort().join(',');
|
||||
const newPaths = result.filePaths.functions.sort().join(',');
|
||||
|
||||
if (currentKeys.length !== newKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentKeys.length; i++) {
|
||||
if (currentKeys[i] !== newKeys[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return currentPaths !== newPaths;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
if (this.hasEntries()) {
|
||||
if (this.functionPaths.length > 0) {
|
||||
console.log(chalk.blue(' 📦 Building functions...'));
|
||||
this.innerWatcher = await this.createWatcher();
|
||||
} else {
|
||||
@@ -84,7 +54,7 @@ export class FunctionsWatcher implements RestartableWatcher {
|
||||
await this.innerWatcher?.close();
|
||||
}
|
||||
|
||||
async restart(manifest: ApplicationManifest): Promise<void> {
|
||||
async restart(result: ManifestBuildResult): Promise<void> {
|
||||
if (this.isRestarting) {
|
||||
return;
|
||||
}
|
||||
@@ -96,9 +66,9 @@ export class FunctionsWatcher implements RestartableWatcher {
|
||||
await this.innerWatcher?.close();
|
||||
this.innerWatcher = null;
|
||||
|
||||
this.entries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions ?? []);
|
||||
this.functionPaths = result.filePaths.functions;
|
||||
|
||||
if (this.hasEntries()) {
|
||||
if (this.functionPaths.length > 0) {
|
||||
console.log(chalk.blue(' 📦 Building functions...'));
|
||||
this.innerWatcher = await this.createWatcher();
|
||||
} else {
|
||||
@@ -112,10 +82,6 @@ export class FunctionsWatcher implements RestartableWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private hasEntries(): boolean {
|
||||
return Object.keys(this.entries).length > 0;
|
||||
}
|
||||
|
||||
private async createWatcher(): Promise<Rollup.RollupWatcher> {
|
||||
const config = this.createConfig();
|
||||
const watcher = await build(config) as Rollup.RollupWatcher;
|
||||
@@ -135,6 +101,13 @@ export class FunctionsWatcher implements RestartableWatcher {
|
||||
private createConfig(): InlineConfig {
|
||||
const functionsOutputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR);
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
this.functionPaths.map((filePath) => [
|
||||
filePath.replace(/\.tsx?$/, ''),
|
||||
path.join(this.appPath, filePath),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
root: this.appPath,
|
||||
plugins: [
|
||||
@@ -144,27 +117,17 @@ export class FunctionsWatcher implements RestartableWatcher {
|
||||
outDir: functionsOutputDir,
|
||||
emptyOutDir: false,
|
||||
watch: {
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'],
|
||||
include: ['**/*.ts', '**/*.tsx', '**/*.json'],
|
||||
exclude: ['node_modules/**', '.twenty/**', 'dist/**'],
|
||||
},
|
||||
lib: {
|
||||
entry: this.entries,
|
||||
entry: entries,
|
||||
formats: ['es'],
|
||||
fileName: (_, entryName) => `${entryName}.js`,
|
||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: FUNCTION_EXTERNAL_MODULES,
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: false,
|
||||
exports: 'named',
|
||||
paths: (id: string) => {
|
||||
if (/(?:^|\/)generated(?:\/|$)/.test(id)) {
|
||||
return `../${GENERATED_DIR}/index.js`;
|
||||
}
|
||||
return id;
|
||||
},
|
||||
},
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { type Application } from 'twenty-shared/application';
|
||||
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
|
||||
import { type ValidationError } from '../manifest.types';
|
||||
import {
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
type EntityBuildResult,
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
} from './entity.interface';
|
||||
|
||||
const findApplicationConfigPath = async (appPath: string): Promise<string> => {
|
||||
const configFile = path.join(appPath, 'application.config.ts');
|
||||
|
||||
if (await fs.pathExists(configFile)) {
|
||||
return configFile;
|
||||
}
|
||||
|
||||
throw new Error('Missing application.config.ts in your app root');
|
||||
};
|
||||
|
||||
export class ApplicationEntityBuilder
|
||||
implements ManifestEntityBuilder<Application>
|
||||
{
|
||||
async build(appPath: string): Promise<Application> {
|
||||
const applicationConfigPath = path.join(appPath, 'src', 'application.config.ts');
|
||||
async build(appPath: string): Promise<EntityBuildResult<Application>> {
|
||||
const applicationConfigPath = await findApplicationConfigPath(appPath);
|
||||
const application =
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<Application>(
|
||||
applicationConfigPath,
|
||||
);
|
||||
const relativePath = path.relative(appPath, applicationConfigPath);
|
||||
|
||||
return manifestExtractFromFileServer.extractManifestFromFile<Application>(applicationConfigPath);
|
||||
return { manifests: [application], filePaths: [relativePath] };
|
||||
}
|
||||
|
||||
validate(application: Application, errors: ValidationError[]): void {
|
||||
validate(applications: Application[], errors: ValidationError[]): void {
|
||||
const application = applications[0];
|
||||
|
||||
if (!application) {
|
||||
errors.push({
|
||||
path: 'application',
|
||||
@@ -35,8 +54,9 @@ export class ApplicationEntityBuilder
|
||||
}
|
||||
}
|
||||
|
||||
display(application: Application): void {
|
||||
const appName = application.displayName ?? 'Application';
|
||||
display(applications: Application[]): void {
|
||||
const application = applications[0];
|
||||
const appName = application?.displayName ?? 'Application';
|
||||
console.log(chalk.green(` ✓ Loaded "${appName}"`));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,14 @@ export type ManifestWithoutSources = Omit<
|
||||
'sources' | 'packageJson'
|
||||
>;
|
||||
|
||||
export type EntityBuildResult<TManifest> = {
|
||||
manifests: TManifest[];
|
||||
filePaths: string[];
|
||||
};
|
||||
|
||||
export type ManifestEntityBuilder<EntityManifest> = {
|
||||
build(appPath: string): Promise<EntityManifest>;
|
||||
validate(data: EntityManifest, errors: ValidationError[]): void;
|
||||
display(data: EntityManifest): void;
|
||||
build(appPath: string): Promise<EntityBuildResult<EntityManifest>>;
|
||||
validate(data: EntityManifest[], errors: ValidationError[]): void;
|
||||
display(data: EntityManifest[]): void;
|
||||
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[];
|
||||
};
|
||||
|
||||
@@ -1,44 +1,53 @@
|
||||
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
|
||||
import chalk from 'chalk';
|
||||
import { glob } from 'fast-glob';
|
||||
import { type FrontComponentManifest } from 'twenty-shared/application';
|
||||
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
|
||||
import { type ValidationError } from '../manifest.types';
|
||||
import {
|
||||
type EntityBuildResult,
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
} from './entity.interface';
|
||||
|
||||
type FrontComponentConfig = Omit<FrontComponentManifest, 'componentPath' | 'componentName'> & {
|
||||
component: { name: string };
|
||||
};
|
||||
|
||||
export class FrontComponentEntityBuilder
|
||||
implements ManifestEntityBuilder<FrontComponentManifest[]>
|
||||
implements ManifestEntityBuilder<FrontComponentManifest>
|
||||
{
|
||||
async build(appPath: string): Promise<FrontComponentManifest[]> {
|
||||
const componentFiles = await glob(['src/**/*.front-component.tsx'], {
|
||||
async build(appPath: string): Promise<EntityBuildResult<FrontComponentManifest>> {
|
||||
const componentFiles = await glob(['**/*.front-component.tsx'], {
|
||||
cwd: appPath,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
|
||||
});
|
||||
|
||||
const frontComponentManifests: FrontComponentManifest[] = [];
|
||||
const manifests: FrontComponentManifest[] = [];
|
||||
|
||||
for (const filepath of componentFiles) {
|
||||
for (const filePath of componentFiles) {
|
||||
try {
|
||||
frontComponentManifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<FrontComponentManifest>(
|
||||
filepath,
|
||||
{ entryProperty: 'component' },
|
||||
),
|
||||
);
|
||||
const absolutePath = `${appPath}/${filePath}`;
|
||||
const config =
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<FrontComponentConfig>(
|
||||
absolutePath,
|
||||
);
|
||||
|
||||
const { component, ...rest } = config;
|
||||
|
||||
manifests.push({
|
||||
...rest,
|
||||
componentName: component.name,
|
||||
componentPath: filePath,
|
||||
});
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
`Failed to load front component from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to load front component from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return frontComponentManifests;
|
||||
return { manifests, filePaths: componentFiles };
|
||||
}
|
||||
|
||||
validate(
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
|
||||
import chalk from 'chalk';
|
||||
import { glob } from 'fast-glob';
|
||||
import { type ServerlessFunctionManifest } from 'twenty-shared/application';
|
||||
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
|
||||
import { type ValidationError } from '../manifest.types';
|
||||
import {
|
||||
type EntityBuildResult,
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
} from './entity.interface';
|
||||
|
||||
export class FunctionEntityBuilder
|
||||
implements ManifestEntityBuilder<ServerlessFunctionManifest[]>
|
||||
implements ManifestEntityBuilder<ServerlessFunctionManifest>
|
||||
{
|
||||
async build(appPath: string): Promise<ServerlessFunctionManifest[]> {
|
||||
const functionFiles = await glob(['src/**/*.function.ts'], {
|
||||
async build(appPath: string): Promise<EntityBuildResult<ServerlessFunctionManifest>> {
|
||||
const functionFiles = await glob(['**/*.function.ts'], {
|
||||
cwd: appPath,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
|
||||
});
|
||||
|
||||
const functionManifests: ServerlessFunctionManifest[] = [];
|
||||
const manifests: ServerlessFunctionManifest[] = [];
|
||||
|
||||
for (const filepath of functionFiles) {
|
||||
for (const filePath of functionFiles) {
|
||||
try {
|
||||
functionManifests.push(
|
||||
const absolutePath = `${appPath}/${filePath}`;
|
||||
|
||||
manifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<ServerlessFunctionManifest>(
|
||||
filepath,
|
||||
absolutePath,
|
||||
{ entryProperty: 'handler' },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
`Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to load function from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return functionManifests;
|
||||
return { manifests, filePaths: functionFiles };
|
||||
}
|
||||
|
||||
validate(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
|
||||
import { glob } from 'fast-glob';
|
||||
import { type ObjectExtensionManifest } from 'twenty-shared/application';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
@@ -6,37 +5,40 @@ import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
|
||||
import { type ValidationError } from '../manifest.types';
|
||||
import {
|
||||
type EntityBuildResult,
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
} from './entity.interface';
|
||||
|
||||
export class ObjectExtensionEntityBuilder
|
||||
implements ManifestEntityBuilder<ObjectExtensionManifest[]>
|
||||
implements ManifestEntityBuilder<ObjectExtensionManifest>
|
||||
{
|
||||
async build(appPath: string): Promise<ObjectExtensionManifest[]> {
|
||||
const extensionFiles = await glob(['src/**/*.object-extension.ts'], {
|
||||
async build(appPath: string): Promise<EntityBuildResult<ObjectExtensionManifest>> {
|
||||
const extensionFiles = await glob(['**/*.object-extension.ts'], {
|
||||
cwd: appPath,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
|
||||
});
|
||||
|
||||
const objectExtensionManifests: ObjectExtensionManifest[] = [];
|
||||
const manifests: ObjectExtensionManifest[] = [];
|
||||
|
||||
for (const filepath of extensionFiles) {
|
||||
for (const filePath of extensionFiles) {
|
||||
try {
|
||||
objectExtensionManifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<ObjectExtensionManifest>(filepath),
|
||||
const absolutePath = `${appPath}/${filePath}`;
|
||||
|
||||
manifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<ObjectExtensionManifest>(
|
||||
absolutePath,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
`Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to load object extension from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return objectExtensionManifests;
|
||||
return { manifests, filePaths: extensionFiles };
|
||||
}
|
||||
|
||||
validate(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
|
||||
import chalk from 'chalk';
|
||||
import { glob } from 'fast-glob';
|
||||
import { type ObjectManifest } from 'twenty-shared/application';
|
||||
@@ -7,37 +6,38 @@ import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
|
||||
import { type ValidationError } from '../manifest.types';
|
||||
import {
|
||||
type EntityBuildResult,
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
} from './entity.interface';
|
||||
|
||||
export class ObjectEntityBuilder
|
||||
implements ManifestEntityBuilder<ObjectManifest[]>
|
||||
implements ManifestEntityBuilder<ObjectManifest>
|
||||
{
|
||||
async build(appPath: string): Promise<ObjectManifest[]> {
|
||||
const objectFiles = await glob(['src/**/*.object.ts'], {
|
||||
async build(appPath: string): Promise<EntityBuildResult<ObjectManifest>> {
|
||||
const objectFiles = await glob(['**/*.object.ts'], {
|
||||
cwd: appPath,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
|
||||
});
|
||||
|
||||
const objectManifests: ObjectManifest[] = [];
|
||||
const manifests: ObjectManifest[] = [];
|
||||
|
||||
for (const filepath of objectFiles) {
|
||||
for (const filePath of objectFiles) {
|
||||
try {
|
||||
objectManifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<ObjectManifest>(filepath),
|
||||
const absolutePath = `${appPath}/${filePath}`;
|
||||
|
||||
manifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<ObjectManifest>(absolutePath),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
`Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to load object from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return objectManifests;
|
||||
return { manifests, filePaths: objectFiles };
|
||||
}
|
||||
|
||||
validate(objects: ObjectManifest[], errors: ValidationError[]): void {
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
|
||||
import chalk from 'chalk';
|
||||
import { glob } from 'fast-glob';
|
||||
import { type RoleManifest } from 'twenty-shared/application';
|
||||
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
|
||||
import { type ValidationError } from '../manifest.types';
|
||||
import {
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
type EntityBuildResult,
|
||||
type EntityIdWithLocation,
|
||||
type ManifestEntityBuilder,
|
||||
type ManifestWithoutSources,
|
||||
} from './entity.interface';
|
||||
|
||||
export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest[]> {
|
||||
async build(appPath: string): Promise<RoleManifest[]> {
|
||||
const roleFiles = await glob(['src/**/*.role.ts'], {
|
||||
export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest> {
|
||||
async build(appPath: string): Promise<EntityBuildResult<RoleManifest>> {
|
||||
const roleFiles = await glob(['**/*.role.ts'], {
|
||||
cwd: appPath,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
|
||||
});
|
||||
|
||||
const roleManifests: RoleManifest[] = [];
|
||||
const manifests: RoleManifest[] = [];
|
||||
|
||||
for (const filepath of roleFiles) {
|
||||
for (const filePath of roleFiles) {
|
||||
try {
|
||||
roleManifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<RoleManifest>(filepath),
|
||||
const absolutePath = `${appPath}/${filePath}`;
|
||||
|
||||
manifests.push(
|
||||
await manifestExtractFromFileServer.extractManifestFromFile<RoleManifest>(absolutePath),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
`Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to load role from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return roleManifests;
|
||||
return { manifests, filePaths: roleFiles };
|
||||
}
|
||||
|
||||
validate(roles: RoleManifest[], errors: ValidationError[]): void {
|
||||
|
||||
@@ -18,28 +18,22 @@ import { manifestExtractFromFileServer } from './manifest-extract-from-file-serv
|
||||
import { validateManifest } from './manifest-validate';
|
||||
import { ManifestValidationError } from './manifest.types';
|
||||
|
||||
const validateFolderStructure = async (appPath: string): Promise<void> => {
|
||||
const srcFolder = path.join(appPath, 'src');
|
||||
|
||||
if (!(await fs.pathExists(srcFolder))) {
|
||||
throw new Error(
|
||||
`Missing src/ folder in ${appPath}.\n` + 'Create it with: mkdir -p src',
|
||||
);
|
||||
}
|
||||
|
||||
const configFile = path.join(appPath, 'src', 'application.config.ts');
|
||||
if (!(await fs.pathExists(configFile))) {
|
||||
throw new Error('Missing src/application.config.ts');
|
||||
}
|
||||
export type EntityFilePaths = {
|
||||
application: string[];
|
||||
objects: string[];
|
||||
objectExtensions: string[];
|
||||
functions: string[];
|
||||
frontComponents: string[];
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
const loadSources = async (appPath: string): Promise<Sources> => {
|
||||
const sources: Sources = {};
|
||||
|
||||
const tsFiles = await glob(['src/**/*.ts', 'src/**/*.tsx', 'generated/**/*.ts'], {
|
||||
const tsFiles = await glob(['**/*.ts', '**/*.tsx'], {
|
||||
cwd: appPath,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
|
||||
});
|
||||
|
||||
for (const filepath of tsFiles) {
|
||||
@@ -87,10 +81,24 @@ export type RunManifestBuildOptions = {
|
||||
writeOutput?: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FILE_PATHS: EntityFilePaths = {
|
||||
application: [],
|
||||
objects: [],
|
||||
objectExtensions: [],
|
||||
functions: [],
|
||||
frontComponents: [],
|
||||
roles: [],
|
||||
};
|
||||
|
||||
export type ManifestBuildResult = {
|
||||
manifest: ApplicationManifest | null;
|
||||
filePaths: EntityFilePaths;
|
||||
};
|
||||
|
||||
export const runManifestBuild = async (
|
||||
appPath: string,
|
||||
options: RunManifestBuildOptions = {},
|
||||
): Promise<ApplicationManifest | null> => {
|
||||
): Promise<ManifestBuildResult> => {
|
||||
const { display = true, writeOutput = true } = options;
|
||||
|
||||
if (display) {
|
||||
@@ -98,7 +106,6 @@ export const runManifestBuild = async (
|
||||
}
|
||||
|
||||
try {
|
||||
await validateFolderStructure(appPath);
|
||||
manifestExtractFromFileServer.init(appPath);
|
||||
|
||||
const packageJson = await parseJsoncFile(
|
||||
@@ -106,12 +113,12 @@ export const runManifestBuild = async (
|
||||
);
|
||||
|
||||
const [
|
||||
application,
|
||||
objectManifests,
|
||||
objectExtensionManifests,
|
||||
functionManifests,
|
||||
frontComponentManifests,
|
||||
roleManifests,
|
||||
applicationBuildResult,
|
||||
objectBuildResult,
|
||||
objectExtensionBuildResult,
|
||||
functionBuildResult,
|
||||
frontComponentBuildResult,
|
||||
roleBuildResult,
|
||||
sources,
|
||||
] = await Promise.all([
|
||||
applicationEntityBuilder.build(appPath),
|
||||
@@ -123,6 +130,22 @@ export const runManifestBuild = async (
|
||||
loadSources(appPath),
|
||||
]);
|
||||
|
||||
const application = applicationBuildResult.manifests[0];
|
||||
const objectManifests = objectBuildResult.manifests;
|
||||
const objectExtensionManifests = objectExtensionBuildResult.manifests;
|
||||
const functionManifests = functionBuildResult.manifests;
|
||||
const frontComponentManifests = frontComponentBuildResult.manifests;
|
||||
const roleManifests = roleBuildResult.manifests;
|
||||
|
||||
const filePaths: EntityFilePaths = {
|
||||
application: applicationBuildResult.filePaths,
|
||||
objects: objectBuildResult.filePaths,
|
||||
objectExtensions: objectExtensionBuildResult.filePaths,
|
||||
functions: functionBuildResult.filePaths,
|
||||
frontComponents: frontComponentBuildResult.filePaths,
|
||||
roles: roleBuildResult.filePaths,
|
||||
};
|
||||
|
||||
const manifest: ApplicationManifest = {
|
||||
application,
|
||||
objects: objectManifests,
|
||||
@@ -160,7 +183,7 @@ export const runManifestBuild = async (
|
||||
await writeManifestToOutput(appPath, manifest);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
return { manifest, filePaths };
|
||||
} catch (error) {
|
||||
if (display) {
|
||||
if (error instanceof ManifestValidationError) {
|
||||
@@ -172,6 +195,6 @@ export const runManifestBuild = async (
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return { manifest: null, filePaths: EMPTY_FILE_PATHS };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,9 +8,11 @@ import { roleEntityBuilder } from './entities/role';
|
||||
import { type ManifestValidationError, type ValidationWarning } from './manifest.types';
|
||||
|
||||
export const displayEntitySummary = (manifest: ApplicationManifest): void => {
|
||||
applicationEntityBuilder.display(manifest.application);
|
||||
objectEntityBuilder.display(manifest.objects);
|
||||
functionEntityBuilder.display(manifest.serverlessFunctions);
|
||||
applicationEntityBuilder.display(
|
||||
manifest.application ? [manifest.application] : [],
|
||||
);
|
||||
objectEntityBuilder.display(manifest.objects ?? []);
|
||||
functionEntityBuilder.display(manifest.serverlessFunctions ?? []);
|
||||
frontComponentEntityBuilder.display(manifest.frontComponents ?? []);
|
||||
roleEntityBuilder.display(manifest.roles ?? []);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,10 @@ export const validateManifest = (
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
applicationEntityBuilder.validate(manifest.application, errors);
|
||||
applicationEntityBuilder.validate(
|
||||
manifest.application ? [manifest.application] : [],
|
||||
errors,
|
||||
);
|
||||
objectEntityBuilder.validate(manifest.objects ?? [], errors);
|
||||
objectExtensionEntityBuilder.validate(manifest.objectExtensions ?? [], errors);
|
||||
functionEntityBuilder.validate(manifest.serverlessFunctions ?? [], errors);
|
||||
@@ -51,13 +54,13 @@ export const validateManifest = (
|
||||
|
||||
if (!isNonEmptyArray(manifest.objects)) {
|
||||
warnings.push({
|
||||
message: 'No objects defined in src/',
|
||||
message: 'No objects defined',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isNonEmptyArray(manifest.serverlessFunctions)) {
|
||||
warnings.push({
|
||||
message: 'No functions defined in src/',
|
||||
message: 'No functions defined',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import chalk from 'chalk';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import path from 'path';
|
||||
import { type ApplicationManifest } from 'twenty-shared/application';
|
||||
import { printWatchingMessage } from '../common/display';
|
||||
import { runManifestBuild } from './manifest-build';
|
||||
import { runManifestBuild, type ManifestBuildResult } from './manifest-build';
|
||||
|
||||
export type ManifestWatcherCallbacks = {
|
||||
onBuildSuccess?: (manifest: ApplicationManifest) => void;
|
||||
onBuildSuccess?: (result: ManifestBuildResult) => void;
|
||||
};
|
||||
|
||||
export type ManifestWatcherOptions = {
|
||||
@@ -25,9 +24,7 @@ export class ManifestWatcher {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const srcPath = path.join(this.appPath, 'src');
|
||||
|
||||
this.watcher = chokidar.watch(srcPath, {
|
||||
this.watcher = chokidar.watch(this.appPath, {
|
||||
ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'],
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
@@ -43,11 +40,11 @@ export class ManifestWatcher {
|
||||
|
||||
console.log(chalk.gray(` File ${event}: ${path.relative(this.appPath, filePath)}`));
|
||||
|
||||
const manifest = await runManifestBuild(this.appPath);
|
||||
const result = await runManifestBuild(this.appPath);
|
||||
|
||||
if (manifest) {
|
||||
if (result.manifest) {
|
||||
printWatchingMessage();
|
||||
this.callbacks.onBuildSuccess?.(manifest);
|
||||
this.callbacks.onBuildSuccess?.(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user