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:
Charles Bochet
2026-01-21 21:49:50 +01:00
committed by GitHub
parent 6bc64f78fc
commit 006be18f19
52 changed files with 881 additions and 397 deletions
@@ -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);
});
@@ -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"]
}
@@ -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"
`;
@@ -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,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": [
{
@@ -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();
});
});
};
@@ -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',
]);
});
});
};
@@ -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',
]);
});
});
};
@@ -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);
});
});
};
@@ -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,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"]
}
@@ -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);
});
@@ -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
}
]
}
@@ -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');
});
});
};
@@ -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',
]);
});
});
};
@@ -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',
]);
});
});
};
@@ -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');
};
@@ -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,
@@ -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);
}
});