Add sdk build (#17335)

## Summary

Adds `app:build` command as a one-shot version of `app:dev` - builds
manifest, functions, and front components once then exits (no watching).

**Changes:**
- Added `watch` option to `FunctionsWatcher` and
`FrontComponentsWatcher` to support both watch and one-shot modes
- Created `AppBuildCommand` reusing the same build logic as `app:dev`
with `watch: false`
- Added integration tests for `app:build` on both `rich-app` and
`root-app`
- Updated manifest types: `handlerPath` → `sourceHandlerPath` +
`builtHandlerPath`, `componentPath` → `sourceComponentPath` +
`builtComponentPath`
- Updated test app `package.json` scripts to match `create-twenty-app`
template
This commit is contained in:
Charles Bochet
2026-01-22 14:36:40 +01:00
committed by GitHub
parent d6f088f720
commit d537d941a7
25 changed files with 1050 additions and 86 deletions
@@ -1,6 +1,6 @@
{
"name": "invalid-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -9,13 +9,32 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty app dev",
"sync": "twenty app sync"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,25 @@
import { runAppBuild } from '@/cli/__tests__/integration/utils/run-app-build.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { join } from 'path';
import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests';
import { defineFunctionsTests } from '../app-dev/tests/functions.tests';
import { defineManifestTests } from '../app-dev/tests/manifest.tests';
import { defineConsoleOutputTests } from './tests/console-output.tests';
const APP_PATH = join(__dirname, '../..');
describe('rich-app app:build', () => {
let result: RunCliCommandResult;
beforeAll(async () => {
result = await runAppBuild({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineFunctionsTests(APP_PATH);
defineFrontComponentsTests(APP_PATH);
});
@@ -0,0 +1,49 @@
import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = getOutputByPrefix(getResult().output, 'init');
expect(output).toContain('[init] 🚀 Building Twenty Application');
expect(output).toContain('[init] 📁 App Path:');
expect(output).toContain('[init] ✅ Build completed successfully');
});
it('should contain manifest-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'manifest-watch');
expect(output).toContain('[manifest-watch] 🔄 Building...');
expect(output).toContain('[manifest-watch] ✓ Loaded "Hello World"');
expect(output).toContain('[manifest-watch] ✓ Found 2 object(s)');
expect(output).toContain('[manifest-watch] ✓ Found 4 function(s)');
expect(output).toContain('[manifest-watch] ✓ Found 4 front component(s)');
expect(output).toContain('[manifest-watch] ✓ Found 2 role(s)');
expect(output).toContain('[manifest-watch] ✓ Written to');
});
it('should contain functions-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'functions-watch');
expect(output).toContain('[functions-watch] 📦 Building...');
expect(output).toContain('[functions-watch] ✓ Built');
});
it('should contain front-components-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'front-components-watch');
expect(output).toContain('[front-components-watch] 🎨 Building...');
expect(output).toContain('[front-components-watch] ✓ Built');
});
it('should not contain watching messages', () => {
const output = getResult().output;
expect(output).not.toContain('👀 Watching for changes...');
expect(output).not.toContain('📂 Watcher started');
});
});
};
@@ -16,31 +16,35 @@
},
"frontComponents": [
{
"builtComponentPath": "front-components/src/root.front-component.mjs",
"componentName": "RootComponent",
"componentPath": "src/root.front-component.tsx",
"description": "A root-level front component",
"name": "root-component",
"sourceComponentPath": "src/root.front-component.tsx",
"universalIdentifier": "a0a1a2a3-a4a5-4000-8000-000000000001"
},
{
"builtComponentPath": "front-components/src/components/card.front-component.mjs",
"componentName": "CardDisplay",
"componentPath": "src/components/card.front-component.tsx",
"description": "A component using an external component file",
"name": "card-component",
"sourceComponentPath": "src/components/card.front-component.tsx",
"universalIdentifier": "i0i1i2i3-i4i5-4000-8000-000000000001"
},
{
"builtComponentPath": "front-components/src/components/greeting.front-component.mjs",
"componentName": "GreetingComponent",
"componentPath": "src/components/greeting.front-component.tsx",
"description": "A component that uses greeting utility",
"name": "greeting-component",
"sourceComponentPath": "src/components/greeting.front-component.tsx",
"universalIdentifier": "h0h1h2h3-h4h5-4000-8000-000000000001"
},
{
"builtComponentPath": "front-components/src/components/test.front-component.mjs",
"componentName": "TestComponent",
"componentPath": "src/components/test.front-component.tsx",
"description": "A test front component",
"name": "test-component",
"sourceComponentPath": "src/components/test.front-component.tsx",
"universalIdentifier": "f1234567-abcd-4000-8000-000000000001"
}
],
@@ -190,7 +194,7 @@
],
"packageJson": {
"name": "rich-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -199,18 +203,33 @@
},
"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"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
},
"roles": [
@@ -263,9 +282,10 @@
],
"serverlessFunctions": [
{
"builtHandlerPath": "functions/src/root.function.mjs",
"handlerName": "rootHandler",
"handlerPath": "src/root.function.ts",
"name": "root-function",
"sourceHandlerPath": "src/root.function.ts",
"timeoutSeconds": 5,
"triggers": [
{
@@ -279,9 +299,10 @@
"universalIdentifier": "f0f1f2f3-f4f5-4000-8000-000000000001"
},
{
"builtHandlerPath": "functions/src/functions/greeting.function.mjs",
"handlerName": "greetingHandler",
"handlerPath": "src/functions/greeting.function.ts",
"name": "greeting-function",
"sourceHandlerPath": "src/functions/greeting.function.ts",
"timeoutSeconds": 5,
"triggers": [
{
@@ -295,9 +316,10 @@
"universalIdentifier": "g0g1g2g3-g4g5-4000-8000-000000000001"
},
{
"builtHandlerPath": "functions/src/utils/test-function-2.util.mjs",
"handlerName": "testFunction2",
"handlerPath": "src/utils/test-function-2.util.ts",
"name": "test-function-2",
"sourceHandlerPath": "src/utils/test-function-2.util.ts",
"timeoutSeconds": 2,
"triggers": [
{
@@ -309,9 +331,10 @@
"universalIdentifier": "eb3ffc98-88ec-45d4-9b4a-56833b219ccb"
},
{
"builtHandlerPath": "functions/src/functions/test-function.function.mjs",
"handlerName": "handler",
"handlerPath": "src/functions/test-function.function.ts",
"name": "test-function",
"sourceHandlerPath": "src/functions/test-function.function.ts",
"timeoutSeconds": 2,
"triggers": [
{
@@ -1,6 +1,6 @@
{
"name": "rich-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -9,17 +9,32 @@
},
"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"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,25 @@
import { join } from 'path';
import { runAppBuild } from '../../../../integration/utils/run-app-build.util';
import { type RunCliCommandResult } from '../../../../integration/utils/run-cli-command.util';
import { defineConsoleOutputTests } from './tests/console-output.tests';
import { defineFrontComponentsTests } from '../app-dev/tests/front-components.tests';
import { defineFunctionsTests } from '../app-dev/tests/functions.tests';
import { defineManifestTests } from '../app-dev/tests/manifest.tests';
const APP_PATH = join(__dirname, '../..');
describe('root-app app:build', () => {
let result: RunCliCommandResult;
beforeAll(async () => {
result = await runAppBuild({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineFunctionsTests(APP_PATH);
defineFrontComponentsTests(APP_PATH);
});
@@ -0,0 +1,49 @@
import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = getOutputByPrefix(getResult().output, 'init');
expect(output).toContain('[init] 🚀 Building Twenty Application');
expect(output).toContain('[init] 📁 App Path:');
expect(output).toContain('[init] ✅ Build completed successfully');
});
it('should contain manifest-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'manifest-watch');
expect(output).toContain('[manifest-watch] 🔄 Building...');
expect(output).toContain('[manifest-watch] ✓ Loaded "Root App"');
expect(output).toContain('[manifest-watch] ✓ Found 1 object(s)');
expect(output).toContain('[manifest-watch] ✓ Found 1 function(s)');
expect(output).toContain('[manifest-watch] ✓ Found 1 front component(s)');
expect(output).toContain('[manifest-watch] ✓ Found 1 role(s)');
expect(output).toContain('[manifest-watch] ✓ Written to');
});
it('should contain functions-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'functions-watch');
expect(output).toContain('[functions-watch] 📦 Building...');
expect(output).toContain('[functions-watch] ✓ Built');
});
it('should contain front-components-watch messages', () => {
const output = getOutputByPrefix(getResult().output, 'front-components-watch');
expect(output).toContain('[front-components-watch] 🎨 Building...');
expect(output).toContain('[front-components-watch] ✓ Built');
});
it('should not contain watching messages', () => {
const output = getResult().output;
expect(output).not.toContain('👀 Watching for changes...');
expect(output).not.toContain('📂 Watcher started');
});
});
};
@@ -40,7 +40,8 @@
}
],
"handlerName": "myHandler",
"handlerPath": "my.function.ts"
"sourceHandlerPath": "my.function.ts",
"builtHandlerPath": "functions/my.function.mjs"
}
],
"frontComponents": [
@@ -49,7 +50,8 @@
"name": "my-component",
"description": "A root-level front component",
"componentName": "MyComponent",
"componentPath": "my.front-component.tsx"
"sourceComponentPath": "my.front-component.tsx",
"builtComponentPath": "front-components/my.front-component.mjs"
}
],
"roles": [
@@ -1,6 +1,6 @@
{
"name": "root-app",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -9,17 +9,32 @@
},
"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"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:build": "twenty app:build",
"app:sync": "twenty app:sync",
"entity:add": "twenty entity:add",
"app:generate": "twenty app:generate",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"typescript": "^5.9.3",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2",
"react": "^19.0.2",
"eslint": "^9.32.0",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,17 @@
import { runCliCommand, type RunCliCommandResult } from './run-cli-command.util';
export type RunAppBuildOptions = {
appPath: string;
timeout?: number;
};
export const runAppBuild = (options: RunAppBuildOptions): Promise<RunCliCommandResult> => {
const { appPath, timeout = 30000 } = options;
// app:build runs once and exits, so we don't wait for specific output
return runCliCommand({
command: 'app:build',
args: [appPath],
timeout,
});
};
@@ -54,15 +54,21 @@ export const runCliCommand = (
? [waitForOutput]
: [];
let isResolved = false;
child.stdout?.on('data', (data: Buffer) => {
output += data.toString();
if (
!isResolved &&
waitForOutputs.length > 0 &&
waitForOutputs.every((w) => output.includes(w))
) {
isResolved = true;
clearTimeout(timeoutId);
child.kill();
resolve({ success: true, output });
// Wait a bit before killing to allow any in-progress file writes to complete
setTimeout(() => {
child.kill();
resolve({ success: true, output });
}, 500);
}
});