Update manifest structure (#17547)

Move all sync entities in an `entities` key. Rename functions to
logicFunctions

```json
{
  application: {
    ...
  },
  entities: {
    objects: [],
    logicFunctions: [],
    ...
  }
}
```
This commit is contained in:
martmull
2026-01-30 16:26:45 +01:00
committed by GitHub
parent bc022f82cb
commit f46da3eefd
162 changed files with 2555 additions and 3778 deletions
@@ -1,9 +1,9 @@
import { defineApp } from '@/application/define-app';
import { defineApplication } from '@/sdk';
export default defineApp({
export default defineApplication({
universalIdentifier: 'invalid-app-0000-0000-0000-000000000001',
displayName: 'Invalid App',
description: 'An app with duplicate IDs for testing validation',
icon: 'IconAlertTriangle',
functionRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
});
@@ -1,5 +1,4 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
import { defineObject, FieldType } from '@/sdk';
const DUPLICATE_ID = 'duplicate-id-0000-0000-000000000001';
@@ -1,5 +1,4 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
import { defineObject, FieldType } from '@/sdk';
const DUPLICATE_ID = 'duplicate-id-0000-0000-000000000001';
@@ -1,8 +1,8 @@
import { FieldType } from '@/application';
import type { ApplicationManifest } from 'twenty-shared/application';
import { FieldType } from '@/sdk';
import type { Manifest } from 'twenty-shared/application';
import { PermissionFlagType } from 'twenty-shared/constants';
export const EXPECTED_MANIFEST: ApplicationManifest = {
export const EXPECTED_MANIFEST: Manifest = {
sources: {},
publicAssets: [
{
@@ -24,7 +24,7 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
},
description: 'A simple hello world app',
displayName: 'Hello World',
functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
icon: 'IconWorld',
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
},
@@ -66,47 +66,43 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
universalIdentifier: 'f1234567-abcd-4000-8000-000000000001',
},
],
objectExtensions: [
fields: [
{
fields: [
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
description: 'Priority level for the post card (1-10)',
label: 'Priority',
name: 'priority',
type: FieldType.NUMBER,
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
},
{
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
description: 'Post card category',
label: 'Category',
name: 'category',
options: [
{
description: 'Priority level for the post card (1-10)',
label: 'Priority',
name: 'priority',
type: FieldType.NUMBER,
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
color: 'blue',
label: 'Personal',
position: 0,
value: 'PERSONAL',
},
{
description: 'Post card category',
label: 'Category',
name: 'category',
options: [
{
color: 'blue',
label: 'Personal',
position: 0,
value: 'PERSONAL',
},
{
color: 'green',
label: 'Business',
position: 1,
value: 'BUSINESS',
},
{
color: 'orange',
label: 'Promotional',
position: 2,
value: 'PROMOTIONAL',
},
],
type: FieldType.SELECT,
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
color: 'green',
label: 'Business',
position: 1,
value: 'BUSINESS',
},
{
color: 'orange',
label: 'Promotional',
position: 2,
value: 'PROMOTIONAL',
},
],
targetObject: {
nameSingular: 'postCard',
},
type: FieldType.SELECT,
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
},
],
objects: [
@@ -210,44 +206,6 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
},
],
packageJson: {
name: 'rich-app',
version: '0.1.0',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'auth:login': 'twenty auth:login',
'auth:logout': 'twenty auth:logout',
'auth:status': 'twenty auth:status',
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'entity:add': 'twenty entity:add',
'app:generate': 'twenty app:generate',
'function:logs': 'twenty function:logs',
'function:execute': 'twenty function:execute',
'app:uninstall': 'twenty app:uninstall',
help: 'twenty help',
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {
'twenty-sdk': 'latest',
},
devDependencies: {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^19.0.2',
react: '^19.0.2',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
},
roles: [
{
canBeAssignedToAgents: false,
@@ -276,8 +234,8 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
{
canReadFieldValue: false,
canUpdateFieldValue: false,
fieldName: 'content',
objectNameSingular: 'postCard',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
},
],
label: 'Default function role',
@@ -287,14 +245,14 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
canReadObjectRecords: true,
canSoftDeleteObjectRecords: false,
canUpdateObjectRecords: true,
objectNameSingular: 'postCard',
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
},
],
permissionFlags: [PermissionFlagType.APPLICATIONS],
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
},
],
functions: [
logicFunctions: [
{
builtHandlerChecksum: '[checksum]',
builtHandlerPath: 'src/root.function.mjs',
@@ -377,4 +335,42 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
},
],
packageJson: {
name: 'rich-app',
version: '0.1.0',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'auth:login': 'twenty auth:login',
'auth:logout': 'twenty auth:logout',
'auth:status': 'twenty auth:status',
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'entity:add': 'twenty entity:add',
'app:generate': 'twenty app:generate',
'function:logs': 'twenty function:logs',
'function:execute': 'twenty function:execute',
'app:uninstall': 'twenty app:uninstall',
help: 'twenty help',
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {
'twenty-sdk': 'latest',
},
devDependencies: {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^19.0.2',
react: '^19.0.2',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
},
};
@@ -4,8 +4,8 @@ import { OUTPUT_DIR } from 'twenty-shared/application';
export const defineEntitiesTests = (appPath: string): void => {
const outputDir = join(appPath, OUTPUT_DIR);
describe('functions', () => {
it('should have built functions preserving source path structure', async () => {
describe('logicFunctions', () => {
it('should have built logicFunctions preserving source path structure', async () => {
const files = await fs.readdir(outputDir, { recursive: true });
const sortedFiles = files.map((f) => f.toString()).sort();
@@ -19,13 +19,13 @@ export const defineManifestTests = (appPath: string): void => {
normalizeManifestForComparison(EXPECTED_MANIFEST),
);
for (const fn of manifest.functions) {
for (const fn of manifest.entities.logicFunctions) {
expect(fn.builtHandlerChecksum).toBeDefined();
expect(fn.builtHandlerChecksum).not.toBeNull();
expect(typeof fn.builtHandlerChecksum).toBe('string');
}
for (const component of manifest.frontComponents ?? []) {
for (const component of manifest.entities.frontComponents ?? []) {
expect(component.builtComponentChecksum).toBeDefined();
expect(component.builtComponentChecksum).not.toBeNull();
expect(typeof component.builtComponentChecksum).toBe('string');
@@ -44,11 +44,11 @@ export const defineManifestTests = (appPath: string): void => {
it('should load all entity types', async () => {
const manifest = await fs.readJson(manifestOutputPath);
expect(manifest?.objects).toHaveLength(2);
expect(manifest?.functions).toHaveLength(4);
expect(manifest?.frontComponents).toHaveLength(4);
expect(manifest?.roles).toHaveLength(2);
expect(manifest?.objectExtensions).toHaveLength(1);
expect(manifest?.entities.objects).toHaveLength(2);
expect(manifest?.entities.logicFunctions).toHaveLength(4);
expect(manifest?.entities.frontComponents).toHaveLength(4);
expect(manifest?.entities.roles).toHaveLength(2);
expect(manifest?.entities.objectExtensions).toHaveLength(1);
});
});
};
@@ -1,7 +1,7 @@
import { defineApp } from '@/application/define-app';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './src/roles/default-function.role';
import { defineApplication } from '@/sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './src/roles/default-function.role';
export default defineApp({
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
@@ -14,5 +14,5 @@ export default defineApp({
isSecret: false,
},
},
functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { defineFrontComponent } from '@/sdk';
import { CardDisplay } from '../utils/card-display.component';
export default defineFrontComponent({
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { defineFrontComponent } from '@/sdk';
import { DEFAULT_NAME, formatGreeting } from '../utils/greeting.util';
const GreetingComponent = () => {
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { defineFrontComponent } from '@/sdk';
export const TestComponent = () => {
return (
@@ -0,0 +1,22 @@
import { defineField, FieldType } from '@/sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/postCard.object';
import { POST_CARD_EXTENSION_CATEGORY_FIELD_ID } from '@/cli/__tests__/apps/rich-app/src/fields/postCardNumber.field';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
type: FieldType.SELECT,
name: 'category',
label: 'Category',
description: 'Post card category',
options: [
{ value: 'PERSONAL', label: 'Personal', color: 'blue', position: 0 },
{ value: 'BUSINESS', label: 'Business', color: 'green', position: 1 },
{
value: 'PROMOTIONAL',
label: 'Promotional',
color: 'orange',
position: 2,
},
],
});
@@ -0,0 +1,17 @@
import { defineField, FieldType } from '@/sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/postCard.object';
export const POST_CARD_EXTENSION_PRIORITY_FIELD_ID =
'7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d';
export const POST_CARD_EXTENSION_CATEGORY_FIELD_ID =
'8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
description: 'Priority level for the post card (1-10)',
});
@@ -1,11 +1,11 @@
import { defineFunction } from '@/application/functions/define-function';
import { defineLogicFunction } from '@/sdk';
import { DEFAULT_NAME, formatGreeting } from '../utils/greeting.util';
const greetingHandler = () => {
return formatGreeting(DEFAULT_NAME);
};
export default defineFunction({
export default defineLogicFunction({
universalIdentifier: '9d412d9e-2caf-487c-8b66-d1585883dd4e',
name: 'greeting-function',
timeoutSeconds: 5,
@@ -1,7 +1,7 @@
import { defineFunction } from '@/application/functions/define-function';
import { defineLogicFunction } from '@/sdk';
import { testFunction2 } from '../utils/test-function-2.util';
export const config = defineFunction({
export const config = defineLogicFunction({
universalIdentifier: 'eb3ffc98-88ec-45d4-9b4a-56833b219ccb',
name: 'test-function-2',
timeoutSeconds: 2,
@@ -1,11 +1,11 @@
import { defineFunction } from '@/application/functions/define-function';
import { defineLogicFunction } from '@/sdk';
import { formatFarewell } from '../utils/greeting.util';
const handler = () => {
return formatFarewell('test');
};
export default defineFunction({
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'test-function',
timeoutSeconds: 2,
@@ -1,40 +0,0 @@
import { extendObject } from '@/application/objects/extend-object';
import { FieldType } from '@/application/fields/field-type';
export const POST_CARD_EXTENSION_PRIORITY_FIELD_ID =
'7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d';
export const POST_CARD_EXTENSION_CATEGORY_FIELD_ID =
'8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e';
export default extendObject({
targetObject: {
nameSingular: 'postCard',
},
fields: [
{
universalIdentifier: POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
description: 'Priority level for the post card (1-10)',
},
{
universalIdentifier: POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
type: FieldType.SELECT,
name: 'category',
label: 'Category',
description: 'Post card category',
options: [
{ value: 'PERSONAL', label: 'Personal', color: 'blue', position: 0 },
{ value: 'BUSINESS', label: 'Business', color: 'green', position: 1 },
{
value: 'PROMOTIONAL',
label: 'Promotional',
color: 'orange',
position: 2,
},
],
},
],
});
@@ -1,5 +1,4 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
import { defineObject, FieldType } from '@/sdk';
enum PostCardStatus {
DRAFT = 'DRAFT',
@@ -8,8 +7,11 @@ enum PostCardStatus {
RETURNED = 'RETURNED',
}
export const POST_CARD_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
universalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
@@ -1,11 +1,10 @@
import { PermissionFlag } from '@/application/permission-flag-type';
import { defineRole } from '@/application/roles/define-role';
import { PermissionFlag, defineRole } from '@/sdk';
export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineRole({
universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: false,
@@ -18,7 +17,7 @@ export default defineRole({
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectNameSingular: 'postCard',
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -27,8 +26,8 @@ export default defineRole({
],
fieldPermissions: [
{
objectNameSingular: 'postCard',
fieldName: 'content',
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { defineFrontComponent } from '@/sdk';
export const RootComponent = () => {
return (
@@ -1,10 +1,10 @@
import { defineFunction } from '@/application/functions/define-function';
import { defineLogicFunction } from '@/sdk';
const rootHandler = () => {
return 'root-function-result';
};
export default defineFunction({
export default defineLogicFunction({
universalIdentifier: 'f0f1f2f3-f4f5-4000-8000-000000000001',
name: 'root-function',
timeoutSeconds: 5,
@@ -1,5 +1,4 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
import { defineObject, FieldType } from '@/sdk';
export default defineObject({
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000001',
@@ -1,4 +1,4 @@
import { defineRole } from '@/application/roles/define-role';
import { defineRole } from '@/sdk';
export default defineRole({
universalIdentifier: 'c0c1c2c3-c4c5-4000-8000-000000000001',
@@ -4,7 +4,7 @@ import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
import { type RunCliCommandResult } from '@/cli/__tests__/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 { defineLogicFunctionsTests } from './tests/logic-functions.tests';
import { defineManifestTests } from './tests/manifest.tests';
const APP_PATH = join(__dirname, '../..');
@@ -22,6 +22,6 @@ describe('root-app app:dev', () => {
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineFunctionsTests(APP_PATH);
defineLogicFunctionsTests(APP_PATH);
defineFrontComponentsTests(APP_PATH);
});
@@ -1,7 +1,7 @@
import { FieldType } from '@/application';
import type { ApplicationManifest } from 'twenty-shared/application';
import { FieldType } from '@/sdk';
import type { Manifest } from 'twenty-shared/application';
export const EXPECTED_MANIFEST: ApplicationManifest = {
export const EXPECTED_MANIFEST: Manifest = {
sources: {},
yarnLock: '',
packageJson: {
@@ -47,8 +47,10 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
displayName: 'Root App',
description: 'An app with all entities at root level',
icon: 'IconFolder',
functionRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
},
publicAssets: [],
fields: [],
objects: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000030',
@@ -68,7 +70,7 @@ export const EXPECTED_MANIFEST: ApplicationManifest = {
],
},
],
functions: [
logicFunctions: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000010',
name: 'my-function',
@@ -1,9 +1,9 @@
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 () => {
export const defineLogicFunctionsTests = (appPath: string): void => {
describe('logicFunctions', () => {
it('should have built logicFunctions at root level', async () => {
const outputDir = join(appPath, '.twenty/output');
const files = await fs.readdir(outputDir, { recursive: true });
const functionFiles = files
@@ -1,6 +1,6 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
import { type Manifest } from 'twenty-shared/application';
import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util';
import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest';
@@ -16,21 +16,22 @@ export const defineManifestTests = (appPath: string): void => {
it('should have correct manifest content', async () => {
const manifestPath = join(appPath, '.twenty/output/manifest.json');
const manifest: ApplicationManifest = await fs.readJSON(manifestPath);
const manifest: Manifest = await fs.readJSON(manifestPath);
expect(manifest.application).toEqual(EXPECTED_MANIFEST.application);
expect(manifest.objects).toEqual(EXPECTED_MANIFEST.objects);
expect(
normalizeManifestForComparison({ functions: manifest.functions })
.functions,
normalizeManifestForComparison({
logicFunctions: manifest.logicFunctions,
}).logicFunctions,
).toEqual(
normalizeManifestForComparison({
functions: EXPECTED_MANIFEST.functions,
}).functions,
logicFunctions: EXPECTED_MANIFEST.logicFunctions,
}).logicFunctions,
);
for (const fn of manifest.functions) {
for (const fn of manifest.logicFunctions) {
expect(fn.builtHandlerChecksum).toBeDefined();
expect(fn.builtHandlerChecksum).not.toBeNull();
expect(typeof fn.builtHandlerChecksum).toBe('string');
@@ -1,9 +1,9 @@
import { defineApp } from '@/application/define-app';
import { defineApplication } from '@/sdk';
export default defineApp({
export default defineApplication({
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',
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
});
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { defineFrontComponent } from '@/sdk';
export const MyComponent = () => {
return (
@@ -1,10 +1,10 @@
import { defineFunction } from '@/application/functions/define-function';
import { defineLogicFunction } from '@/sdk';
const myHandler = () => {
return 'my-function-result';
};
export default defineFunction({
export default defineLogicFunction({
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000010',
name: 'my-function',
timeoutSeconds: 5,
@@ -1,5 +1,4 @@
import { FieldType } from '@/application/fields/field-type';
import { defineObject } from '@/application/objects/define-object';
import { FieldType, defineObject } from '@/sdk';
export default defineObject({
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000030',
@@ -1,4 +1,4 @@
import { defineRole } from '@/application/roles/define-role';
import { defineRole } from '@/sdk';
export default defineRole({
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
@@ -2,7 +2,7 @@ export type LogPrefix =
| 'init'
| 'dev-mode'
| 'manifest-watch'
| 'functions-watch'
| 'logicFunctions-watch'
| 'front-components-watch';
export const getOutputByPrefix = (
@@ -1,6 +1,6 @@
// Loose type for JSON manifest imports where enum values are inferred as strings
type JsonManifestInput = {
functions?: Array<{
logicFunctions?: Array<{
builtHandlerChecksum?: string | null;
[key: string]: unknown;
}>;
@@ -16,7 +16,7 @@ export const normalizeManifestForComparison = <T extends JsonManifestInput>(
manifest: T,
): T => ({
...manifest,
functions: manifest.functions?.map((fn) => ({
logicFunctions: manifest.logicFunctions?.map((fn) => ({
...fn,
builtHandlerChecksum: fn.builtHandlerChecksum ? '[checksum]' : null,
})),
@@ -8,14 +8,11 @@ import { AuthListCommand } from './auth/auth-list';
import { AuthLoginCommand } from './auth/auth-login';
import { AuthLogoutCommand } from './auth/auth-logout';
import { AuthStatusCommand } from './auth/auth-status';
import { FunctionExecuteCommand } from './function/function-execute';
import { FunctionLogsCommand } from './function/function-logs';
import { LogicFunctionExecuteCommand } from './logic-function/logic-function-execute';
import { LogicFunctionLogsCommand } from './logic-function/logic-function-logs';
import { AuthSwitchCommand } from './auth/auth-switch';
import {
EntityAddCommand,
isSyncableEntity,
SyncableEntity,
} from './entity/entity-add';
import { EntityAddCommand } from './entity/entity-add';
import { SyncableEntity } from 'twenty-shared/application';
export const registerCommands = (program: Command): void => {
// Auth commands
@@ -67,8 +64,8 @@ export const registerCommands = (program: Command): void => {
const uninstallCommand = new AppUninstallCommand();
const addCommand = new EntityAddCommand();
const generateCommand = new AppGenerateCommand();
const logsCommand = new FunctionLogsCommand();
const executeCommand = new FunctionExecuteCommand();
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('app:dev [appPath]')
@@ -101,14 +98,6 @@ export const registerCommands = (program: Command): void => {
`Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`,
)
.action(async (entityType?: string, options?: { path?: string }) => {
if (entityType && !isSyncableEntity(entityType)) {
console.error(
chalk.red(
`Invalid entity type "${entityType}". Must be one of: ${Object.values(SyncableEntity).join('|')}`,
),
);
process.exit(1);
}
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
@@ -1,10 +1,10 @@
import { AssetWatcher } from '@/cli/utilities/build/common/asset-watcher';
import {
createFrontComponentsWatcher,
createFunctionsWatcher,
createLogicFunctionsWatcher,
type EsbuildWatcher,
} from '@/cli/utilities/build/common/esbuild-watcher';
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-build';
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/update-manifest-checksums';
import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { DevModeOrchestrator } from '@/cli/utilities/dev/dev-mode-orchestrator';
@@ -22,7 +22,7 @@ export class AppDevCommand {
private appPath = '';
private orchestrator: DevModeOrchestrator | null = null;
private manifestWatcher: ManifestWatcher | null = null;
private functionsWatcher: EsbuildWatcher | null = null;
private logicFunctionsWatcher: EsbuildWatcher | null = null;
private frontComponentsWatcher: EsbuildWatcher | null = null;
private assetWatcher: AssetWatcher | null = null;
private watchersStarted = false;
@@ -71,16 +71,16 @@ export class AppDevCommand {
}
private async handleWatcherRestarts(result: ManifestBuildResult) {
const { functions, frontComponents } = result.filePaths;
const { logicFunctions, frontComponents } = result.filePaths;
if (!this.watchersStarted) {
this.watchersStarted = true;
await this.startFileWatchers(functions, frontComponents);
await this.startFileWatchers(logicFunctions, frontComponents);
return;
}
if (this.functionsWatcher?.shouldRestart(functions)) {
await this.functionsWatcher.restart(functions);
if (this.logicFunctionsWatcher?.shouldRestart(logicFunctions)) {
await this.logicFunctionsWatcher.restart(logicFunctions);
}
if (this.frontComponentsWatcher?.shouldRestart(frontComponents)) {
@@ -89,18 +89,20 @@ export class AppDevCommand {
}
private async startFileWatchers(
functions: string[],
logicFunctions: string[],
frontComponents: string[],
): Promise<void> {
await Promise.all([
this.startFunctionsWatcher(functions),
this.startLogicFunctionsWatcher(logicFunctions),
this.startFrontComponentsWatcher(frontComponents),
this.startAssetWatcher(),
]);
}
private async startFunctionsWatcher(sourcePaths: string[]): Promise<void> {
this.functionsWatcher = createFunctionsWatcher({
private async startLogicFunctionsWatcher(
sourcePaths: string[],
): Promise<void> {
this.logicFunctionsWatcher = createLogicFunctionsWatcher({
appPath: this.appPath,
sourcePaths,
handleBuildError: this.orchestrator!.handleFileBuildError.bind(
@@ -111,7 +113,7 @@ export class AppDevCommand {
),
});
await this.functionsWatcher.start();
await this.logicFunctionsWatcher.start();
}
private async startFrontComponentsWatcher(
@@ -148,7 +150,7 @@ export class AppDevCommand {
await Promise.all([
this.manifestWatcher?.close(),
this.functionsWatcher?.close(),
this.logicFunctionsWatcher?.close(),
this.frontComponentsWatcher?.close(),
this.assetWatcher?.close(),
]);
@@ -1,9 +1,9 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
export class AppUninstallCommand {
private apiService = new ApiService();
@@ -25,7 +25,7 @@ export class AppUninstallCommand {
process.exit(1);
}
const { manifest } = await runManifestBuild(appPath);
const { manifest } = await buildManifest(appPath);
if (!manifest) {
return { success: false, error: 'Build failed' };
@@ -1,132 +1,51 @@
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-component-template';
import { getFunctionBaseFile } from '@/cli/utilities/entity/entity-function-template';
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import camelcase from 'lodash.camelcase';
import kebabcase from 'lodash.kebabcase';
import { join } from 'path';
import { join, relative } from 'path';
import { SyncableEntity } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template';
const APP_FOLDER = 'src';
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
FUNCTION = 'function',
FRONT_COMPONENT = 'front-component',
ROLE = 'role',
}
export const isSyncableEntity = (value: string): value is SyncableEntity => {
return Object.values(SyncableEntity).includes(value as SyncableEntity);
};
export class EntityAddCommand {
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
// Default to src/ folder, allow override with path parameter
const entity = entityType ?? (await this.getEntity());
const entityName = this.getFolderName(entity);
const appPath = path
? join(CURRENT_EXECUTION_DIRECTORY, path)
: join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER);
: join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER, entityName);
await fs.ensureDir(appPath);
const entity = entityType ?? (await this.getEntity());
const { name, file } = await this.getEntityData(entity);
if (entity === SyncableEntity.OBJECT) {
const entityData = await this.getObjectData();
const filePath = join(appPath, this.getFileName(name, entity));
const name = entityData.nameSingular;
// Use *.object.ts naming convention
const objectFileName = `${camelcase(name)}.object.ts`;
const decoratedObject = getObjectBaseFile({
data: entityData,
name,
});
const filePath = join(appPath, objectFileName);
await fs.writeFile(filePath, decoratedObject);
console.log(
chalk.green(`✓ Created object:`),
chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')),
);
return;
if (await fs.pathExists(filePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
if (entity === SyncableEntity.FUNCTION) {
const entityName = await this.getEntityName(entity);
await fs.writeFile(filePath, file);
// Use *.function.ts naming convention
const functionFileName = `${kebabcase(entityName)}.function.ts`;
const decoratedLogicFunction = getFunctionBaseFile({
name: entityName,
});
const filePath = join(appPath, functionFileName);
await fs.writeFile(filePath, decoratedLogicFunction);
console.log(
chalk.green(`✓ Created function:`),
chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')),
);
return;
}
if (entity === SyncableEntity.FRONT_COMPONENT) {
const entityName = await this.getEntityName(entity);
// Use *.front-component.tsx naming convention
const frontComponentFileName = `${kebabcase(entityName)}.front-component.tsx`;
const decoratedFrontComponent = getFrontComponentBaseFile({
name: entityName,
});
const filePath = join(appPath, frontComponentFileName);
await fs.writeFile(filePath, decoratedFrontComponent);
console.log(
chalk.green(`✓ Created front component:`),
chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')),
);
return;
}
if (entity === SyncableEntity.ROLE) {
const entityName = await this.getEntityName(entity);
// Use *.role.ts naming convention
const roleFileName = `${kebabcase(entityName)}.role.ts`;
const roleFileContent = getRoleBaseFile({
name: entityName,
});
const filePath = join(appPath, roleFileName);
await fs.writeFile(filePath, roleFileContent);
console.log(
chalk.green(`✓ Created role:`),
chalk.cyan(filePath.replace(CURRENT_EXECUTION_DIRECTORY + '/', '')),
);
return;
}
console.log(
chalk.green(`✓ Created ${entityName}:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, filePath)),
);
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
@@ -136,6 +55,69 @@ export class EntityAddCommand {
}
}
private async getEntityData(entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.Object: {
const entityData = await this.getObjectData();
const name = entityData.nameSingular;
const file = getObjectBaseFile({
data: entityData,
name,
});
return { name, file };
}
case SyncableEntity.Field: {
const entityData = await this.getFieldData();
const name = entityData.name;
const file = getFieldBaseFile({
data: entityData,
name,
});
return { name, file };
}
case SyncableEntity.LogicFunction: {
const name = await this.getEntityName(entity);
const file = getLogicFunctionBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.FrontComponent: {
const name = await this.getEntityName(entity);
const file = getFrontComponentBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Role: {
const name = await this.getEntityName(entity);
const file = getRoleBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
}
private async getEntity() {
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
@@ -143,18 +125,24 @@ export class EntityAddCommand {
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [
SyncableEntity.FUNCTION,
SyncableEntity.FRONT_COMPONENT,
SyncableEntity.OBJECT,
SyncableEntity.ROLE,
],
choices: Object.values(SyncableEntity),
},
]);
return entity;
}
private async handleFileExist() {
return await inquirer.prompt<{ overwrite: boolean }>([
{
type: 'confirm',
name: 'overwrite',
message: `File already exists. Do you want to overwrite it?`,
default: false,
},
]);
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt<{ name: string }>([
{
@@ -167,10 +155,6 @@ export class EntityAddCommand {
return `${entity} name is required`;
}
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Name must contain only lowercase letters, numbers, and hyphens';
}
return true;
},
},
@@ -179,8 +163,76 @@ export class EntityAddCommand {
return name;
}
private async getFieldData() {
return inquirer.prompt<{
name: string;
label: string;
type: FieldMetadataType;
objectUniversalIdentifier: string;
description: string;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your field:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'label',
message: 'Enter a label for your field:',
default: (answers: any) => {
return convertToLabel(answers.name);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'select',
name: 'type',
message: 'Select the field type:',
choices: Object.values(FieldMetadataType),
default: FieldMetadataType.TEXT,
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this field belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'description',
message: 'Enter a description for your field (optional):',
default: '',
},
]);
}
private async getObjectData() {
return inquirer.prompt([
return inquirer.prompt<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
}>([
{
type: 'input',
name: 'nameSingular',
@@ -238,4 +290,19 @@ export class EntityAddCommand {
},
]);
}
getFolderName(entity: SyncableEntity) {
return `${kebabcase(entity)}s`;
}
getFileName(name: string, entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.FrontComponent: {
return `${kebabcase(name)}.tsx`;
}
default: {
return `${kebabcase(name)}.ts`;
}
}
}
}
@@ -1,11 +1,11 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { type ApplicationManifest } from 'twenty-shared/application';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
export class FunctionExecuteCommand {
export class LogicFunctionExecuteCommand {
private apiService = new ApiService();
async execute({
@@ -30,7 +30,7 @@ export class FunctionExecuteCommand {
process.exit(1);
}
const { manifest } = await runManifestBuild(appPath);
const { manifest } = await buildManifest(appPath);
if (!manifest) {
console.error(chalk.red('Failed to build manifest.'));
@@ -162,9 +162,9 @@ export class FunctionExecuteCommand {
private belongsToApplication(
fn: { universalIdentifier: string; applicationId: string | null },
manifest: ApplicationManifest,
manifest: Manifest,
): boolean {
return manifest.functions.some(
return manifest.logicFunctions.some(
(manifestFn) => manifestFn.universalIdentifier === fn.universalIdentifier,
);
}
@@ -1,9 +1,9 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
export class FunctionLogsCommand {
export class LogicFunctionLogsCommand {
private apiService = new ApiService();
async execute({
@@ -16,7 +16,7 @@ export class FunctionLogsCommand {
functionName?: string;
}): Promise<void> {
try {
const { manifest } = await runManifestBuild(appPath);
const { manifest } = await buildManifest(appPath);
if (!manifest) {
process.exit(1);
@@ -9,7 +9,7 @@ import {
printSchema,
} from 'graphql/index';
import * as path from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
import { type Manifest } from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { pascalCase } from 'twenty-shared/utils';
@@ -99,7 +99,7 @@ export class ApiService {
}
}
async syncApplication(manifest: ApplicationManifest): Promise<ApiResponse> {
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) {
@@ -13,7 +13,7 @@ import path from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
export const FUNCTION_EXTERNAL_MODULES: string[] = [
export const LOGIC_FUNCTION_EXTERNAL_MODULES: string[] = [
'path',
'fs',
'crypto',
@@ -204,14 +204,14 @@ const externalPatternsPlugin: esbuild.Plugin = {
},
};
export const createFunctionsWatcher = (
export const createLogicFunctionsWatcher = (
options: RestartableWatcherOptions,
): EsbuildWatcher =>
new EsbuildWatcher({
...options,
config: {
externalModules: FUNCTION_EXTERNAL_MODULES,
fileFolder: FileFolder.BuiltFunction,
externalModules: LOGIC_FUNCTION_EXTERNAL_MODULES,
fileFolder: FileFolder.BuiltLogicFunction,
platform: 'node',
extraPlugins: [externalPatternsPlugin],
},
@@ -0,0 +1,84 @@
import { extractDefineEntity } from '@/cli/utilities/build/manifest/manifest-extract-config';
describe('extractDefineEntity', () => {
it('should detect defineApplication in default export', () => {
const fileContent = `
import { defineApplication } from 'twenty-sdk';
export default defineApplication({ name: 'MyApp' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineApplication');
});
it('should detect defineField in default export', () => {
const fileContent = `
import { defineField } from 'twenty-sdk';
export default defineField({ name: 'myField' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineField');
});
it('should detect defineLogicFunction in default export', () => {
const fileContent = `
import { defineLogicFunction } from 'twenty-sdk';
export default defineLogicFunction({ name: 'myFunction' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineLogicFunction');
});
it('should detect defineObject in default export', () => {
const fileContent = `
import { defineObject } from 'twenty-sdk';
export default defineObject({ name: 'myObject' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineObject');
});
it('should detect defineRole in default export', () => {
const fileContent = `
import { defineRole } from 'twenty-sdk';
export default defineRole({ name: 'myRole' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineRole');
});
it('should detect defineFrontComponent in default export', () => {
const fileContent = `
import { defineFrontComponent } from 'twenty-sdk';
export default defineFrontComponent({ name: 'myComponent' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineFrontComponent');
});
it('should not detect non-target function in default export', () => {
const fileContent = `
import { someOtherFunction } from 'twenty-sdk';
export default someOtherFunction({ name: 'myFunction' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBeUndefined();
});
it('should handle files without default export', () => {
const fileContent = `
import { defineApplication } from 'twenty-sdk';
const app = defineApplication({ name: 'MyApp' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBeUndefined();
});
it('should handle files with non-call default export', () => {
const fileContent = `
import { defineApplication } from 'twenty-sdk';
export default { name: 'MyApp' };
`;
const result = extractDefineEntity(fileContent);
expect(result).toBeUndefined();
});
});
@@ -1,40 +1,46 @@
import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate';
import {
type Application,
type ObjectExtensionManifest,
type ApplicationManifest,
type Manifest,
type PackageJson,
type FieldManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { validateManifest } from '@/cli/utilities/build/manifest/validate-manifest';
const validApplication: ApplicationManifest = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Test App',
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
const validField: FieldManifest = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
};
const validManifest: Manifest = {
application: validApplication,
objects: [],
frontComponents: [],
fields: [],
logicFunctions: [],
roles: [],
publicAssets: [],
sources: {},
packageJson: {} as PackageJson,
yarnLock: '',
};
describe('validateManifest - objectExtensions', () => {
const validApplication: Application = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Test App',
functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
const validObjectExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
},
],
};
describe('valid object extensions', () => {
it('should pass validation with valid object extension by nameSingular', () => {
const result = validateManifest({
application: validApplication,
objects: [],
frontComponents: [],
objectExtensions: [validObjectExtension],
functions: [],
roles: [],
...validManifest,
fields: [validField],
});
expect(result.isValid).toBe(true);
@@ -42,27 +48,17 @@ describe('validateManifest - objectExtensions', () => {
});
it('should pass validation with valid object extension by universalIdentifier', () => {
const extensionByUuid: ObjectExtensionManifest = {
targetObject: {
universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440002',
type: FieldMetadataType.TEXT,
name: 'customNote',
label: 'Custom Note',
},
],
const extensionByUuid: FieldManifest = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440002',
type: FieldMetadataType.TEXT,
name: 'customNote',
label: 'Custom Note',
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [extensionByUuid],
functions: [],
frontComponents: [],
roles: [],
...validManifest,
fields: [extensionByUuid],
});
expect(result.isValid).toBe(true);
@@ -70,27 +66,17 @@ describe('validateManifest - objectExtensions', () => {
});
it('should pass validation with multiple object extensions', () => {
const anotherExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'person',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440003',
type: FieldMetadataType.TEXT,
name: 'nickname',
label: 'Nickname',
},
],
const anotherExtension: FieldManifest = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440003',
type: FieldMetadataType.TEXT,
name: 'nickname',
label: 'Nickname',
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [validObjectExtension, anotherExtension],
functions: [],
frontComponents: [],
roles: [],
...validManifest,
fields: [validField, anotherExtension],
});
expect(result.isValid).toBe(true);
@@ -98,36 +84,26 @@ describe('validateManifest - objectExtensions', () => {
});
it('should pass validation with SELECT field having options', () => {
const extensionWithSelect: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
const extensionWithSelect: FieldManifest = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440004',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
options: [
{ value: 'active', label: 'Active', color: 'green', position: 0 },
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440004',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
options: [
{ value: 'active', label: 'Active', color: 'green', position: 0 },
{
value: 'inactive',
label: 'Inactive',
color: 'red',
position: 1,
},
],
value: 'inactive',
label: 'Inactive',
color: 'red',
position: 1,
},
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [extensionWithSelect],
functions: [],
frontComponents: [],
roles: [],
...validManifest,
fields: [extensionWithSelect],
});
expect(result.isValid).toBe(true);
@@ -135,312 +111,46 @@ describe('validateManifest - objectExtensions', () => {
});
});
describe('targetObject validation', () => {
it('should fail when targetObject is missing', () => {
const invalidExtension = {
fields: validObjectExtension.fields,
} as ObjectExtensionManifest;
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Object extension must have a targetObject',
}),
);
});
it('should fail when targetObject has neither nameSingular nor universalIdentifier', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {} as any,
fields: validObjectExtension.fields,
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message:
'Object extension targetObject must have either nameSingular or universalIdentifier',
}),
);
});
it('should fail when targetObject has both nameSingular and universalIdentifier', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
} as any,
fields: validObjectExtension.fields,
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message:
'Object extension targetObject cannot have both nameSingular and universalIdentifier',
}),
);
});
});
describe('fields validation', () => {
it('should fail when fields array is empty', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Object extension must have at least one field',
}),
);
});
it('should fail when field is missing universalIdentifier', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Field must have a universalIdentifier',
}),
);
});
it('should fail when field is missing type', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
name: 'healthScore',
label: 'Health Score',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Field must have a type',
}),
);
});
it('should fail when field is missing label', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Field must have a label',
}),
);
});
it('should fail when SELECT field has no options', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'SELECT/MULTI_SELECT field must have options',
}),
);
});
it('should fail when MULTI_SELECT field has empty options', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
label: 'Tags',
options: [],
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'SELECT/MULTI_SELECT field must have options',
}),
);
});
});
describe('duplicate universalIdentifier detection', () => {
it('should fail when extension field has duplicate universalIdentifier', () => {
const duplicateId = '550e8400-e29b-41d4-a716-446655440001';
const extensionWithDuplicates: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
const fieldsWithDuplicates: FieldManifest[] = [
{
objectUniversalIdentifier: '91c5848c-36dc-4e7e-b9ee-aa78caeff5a8',
universalIdentifier: duplicateId,
type: FieldMetadataType.NUMBER,
name: 'field1',
label: 'Field 1',
},
fields: [
{
universalIdentifier: duplicateId,
type: FieldMetadataType.NUMBER,
name: 'field1',
label: 'Field 1',
},
{
universalIdentifier: duplicateId, // Same ID!
type: FieldMetadataType.TEXT,
name: 'field2',
label: 'Field 2',
},
],
};
{
objectUniversalIdentifier: '97931020-123c-435b-ad97-9e19a5b38f1f',
universalIdentifier: duplicateId,
type: FieldMetadataType.TEXT,
name: 'field2',
label: 'Field 2',
},
];
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [extensionWithDuplicates],
functions: [],
frontComponents: [],
roles: [],
...validManifest,
fields: fieldsWithDuplicates,
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: expect.stringContaining('Duplicate universalIdentifier'),
}),
expect(result.errors).toContain(
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
);
expect(result.warnings).toContain('No object defined');
expect(result.warnings).toContain('No logic function defined');
expect(result.warnings).toContain('No front component defined');
});
it('should fail when extension field ID conflicts with object field ID', () => {
const sharedId = '550e8400-e29b-41d4-a716-446655440001';
const result = validateManifest({
application: validApplication,
...validManifest,
objects: [
{
universalIdentifier: 'obj-uuid',
@@ -458,30 +168,24 @@ describe('validateManifest - objectExtensions', () => {
],
},
],
objectExtensions: [
fields: [
{
targetObject: { nameSingular: 'company' },
fields: [
{
universalIdentifier: sharedId, // Same as object field!
type: FieldMetadataType.NUMBER,
name: 'extensionField',
label: 'Extension Field',
},
],
objectUniversalIdentifier: '91c5848c-36dc-4e7e-b9ee-aa78caeff5a8',
universalIdentifier: sharedId,
type: FieldMetadataType.NUMBER,
name: 'extensionField',
label: 'Extension Field',
},
],
functions: [],
frontComponents: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: expect.stringContaining('Duplicate universalIdentifier'),
}),
expect(result.errors).toContain(
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
);
expect(result.warnings).not.toContain('No object defined');
expect(result.warnings).toContain('No logic function defined');
expect(result.warnings).toContain('No front component defined');
});
});
});
@@ -1,94 +0,0 @@
import { glob } from 'fast-glob';
import path from 'path';
import {
type Application,
type ApplicationVariables,
} from 'twenty-shared/application';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import {
type EntityBuildResult,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
const findApplicationConfigPath = async (appPath: string): Promise<string> => {
const files = await glob('**/application.config.ts', {
cwd: appPath,
ignore: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'],
});
if (files.length === 0) {
throw new Error('Missing application.config.ts in your app');
}
if (files.length > 1) {
throw new Error(
`Multiple application.config.ts files found: ${files.join(', ')}. Only one is allowed.`,
);
}
return path.join(appPath, files[0]);
};
export class ApplicationEntityBuilder
implements ManifestEntityBuilder<Application>
{
async build(appPath: string): Promise<EntityBuildResult<Application>> {
const applicationConfigPath = await findApplicationConfigPath(appPath);
const { manifest: application } =
await manifestExtractFromFileServer.extractManifestFromFile<Application>(
applicationConfigPath,
);
const relativePath = path.relative(appPath, applicationConfigPath);
return { manifests: [application], filePaths: [relativePath] };
}
validate(applications: Application[], errors: ValidationError[]): void {
const application = applications[0];
if (!application) {
errors.push({
path: 'application',
message: 'Application config is required',
});
return;
}
if (!application.universalIdentifier) {
errors.push({
path: 'application',
message: 'Application must have a universalIdentifier',
});
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const application = manifest.application;
if (application?.universalIdentifier) {
seen.set(application.universalIdentifier, ['application']);
}
if (application?.applicationVariables) {
for (const [name, variable] of Object.entries(
application.applicationVariables,
) as [string, ApplicationVariables[string]][]) {
if (variable.universalIdentifier) {
const locations = seen.get(variable.universalIdentifier) ?? [];
locations.push(`application.variables.${name}`);
seen.set(variable.universalIdentifier, locations);
}
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const applicationEntityBuilder = new ApplicationEntityBuilder();
@@ -1,25 +0,0 @@
import { glob } from 'fast-glob';
import path from 'path';
import { type AssetManifest, ASSETS_DIR } from 'twenty-shared/application';
import { type EntityBuildResult } from '@/cli/utilities/build/manifest/entities/entity-interface';
export class AssetEntityBuilder {
async build(appPath: string): Promise<EntityBuildResult<AssetManifest>> {
const assetFiles = await glob([`${ASSETS_DIR}/**/*`], {
cwd: appPath,
onlyFiles: true,
});
const manifests: AssetManifest[] = assetFiles.map((filePath) => ({
filePath,
fileName: path.basename(filePath),
fileType: path.extname(filePath).replace(/^\./, ''),
checksum: null,
}));
return { manifests, filePaths: assetFiles };
}
}
export const assetEntityBuilder = new AssetEntityBuilder();
@@ -1,23 +0,0 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
export type EntityIdWithLocation = {
id: string;
locations: string[];
};
export type ManifestWithoutSources = Omit<
ApplicationManifest,
'sources' | 'packageJson' | 'yarnLock'
>;
export type EntityBuildResult<TManifest> = {
manifests: TManifest[];
filePaths: string[];
};
export type ManifestEntityBuilder<EntityManifest> = {
build(appPath: string): Promise<EntityBuildResult<EntityManifest>>;
validate(data: EntityManifest[], errors: ValidationError[]): void;
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[];
};
@@ -1,108 +0,0 @@
import { glob } from 'fast-glob';
import { type FrontComponentManifest } from 'twenty-shared/application';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
import {
type EntityBuildResult,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
type FrontComponentConfig = Omit<
FrontComponentManifest,
| 'sourceComponentPath'
| 'builtComponentPath'
| 'builtComponentChecksum'
| 'componentName'
> & {
component: { name: string };
};
export class FrontComponentEntityBuilder
implements ManifestEntityBuilder<FrontComponentManifest>
{
async build(
appPath: string,
): Promise<EntityBuildResult<FrontComponentManifest>> {
const componentFiles = await glob(['**/*.front-component.tsx'], {
cwd: appPath,
ignore: [
'**/node_modules/**',
'**/*.d.ts',
'**/dist/**',
'**/.twenty/**',
],
});
const manifests: FrontComponentManifest[] = [];
for (const filePath of componentFiles) {
try {
const absolutePath = `${appPath}/${filePath}`;
const { manifest: config } =
await manifestExtractFromFileServer.extractManifestFromFile<FrontComponentConfig>(
absolutePath,
);
const { component, ...rest } = config;
const builtComponentPath = this.computeBuiltComponentPath(filePath);
manifests.push({
...rest,
componentName: component.name,
sourceComponentPath: filePath,
builtComponentPath,
builtComponentChecksum: null,
});
} catch (error) {
throw new Error(
`Failed to load front component from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { manifests, filePaths: componentFiles };
}
private computeBuiltComponentPath(sourceComponentPath: string): string {
return sourceComponentPath.replace(/\.tsx?$/, '.mjs');
}
validate(
components: FrontComponentManifest[],
errors: ValidationError[],
): void {
for (const component of components) {
const componentPath = `front-components/${component.name ?? component.componentName ?? 'unknown'}`;
if (!component.universalIdentifier) {
errors.push({
path: componentPath,
message: 'Front component must have a universalIdentifier',
});
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const components = manifest.frontComponents ?? [];
for (const component of components) {
if (component.universalIdentifier) {
const location = `front-components/${component.name ?? component.componentName}`;
const locations = seen.get(component.universalIdentifier) ?? [];
locations.push(location);
seen.set(component.universalIdentifier, locations);
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const frontComponentEntityBuilder = new FrontComponentEntityBuilder();
@@ -1,175 +0,0 @@
import { glob } from 'fast-glob';
import { type LogicFunctionManifest } from 'twenty-shared/application';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
import {
type EntityBuildResult,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
type ExtractedFunctionManifest = Omit<
LogicFunctionManifest,
'sourceHandlerPath' | 'builtHandlerPath' | 'builtHandlerChecksum'
> & {
handler: string;
};
export class FunctionEntityBuilder
implements ManifestEntityBuilder<LogicFunctionManifest>
{
async build(
appPath: string,
): Promise<EntityBuildResult<LogicFunctionManifest>> {
const functionFiles = await glob(['**/*.function.ts'], {
cwd: appPath,
ignore: [
'**/node_modules/**',
'**/*.d.ts',
'**/dist/**',
'**/.twenty/**',
],
});
const manifests: LogicFunctionManifest[] = [];
for (const filePath of functionFiles) {
try {
const absolutePath = `${appPath}/${filePath}`;
const { manifest, exportName } =
await manifestExtractFromFileServer.extractManifestFromFile<ExtractedFunctionManifest>(
absolutePath,
);
const { handler: _, ...rest } = manifest;
// builtHandlerPath is computed from filePath (the .function.ts file)
// since that's what esbuild actually builds, not handlerPath
const builtHandlerPath = this.computeBuiltHandlerPath(filePath);
// For default exports, use 'default.handler'
// For named exports like 'export const anyName = ...', use 'anyName.handler'
const handlerName =
exportName !== null ? `${exportName}.handler` : 'default.handler';
manifests.push({
...rest,
handlerName,
sourceHandlerPath: filePath,
builtHandlerPath,
builtHandlerChecksum: null,
});
} catch (error) {
throw new Error(
`Failed to load function from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { manifests, filePaths: functionFiles };
}
private computeBuiltHandlerPath(sourceHandlerPath: string): string {
return sourceHandlerPath.replace(/\.tsx?$/, '.mjs');
}
validate(
functions: LogicFunctionManifest[],
errors: ValidationError[],
): void {
for (const fn of functions) {
const fnPath = `functions/${fn.name ?? fn.handlerName ?? 'unknown'}`;
if (!fn.universalIdentifier) {
errors.push({
path: fnPath,
message: 'Function must have a universalIdentifier',
});
}
for (const trigger of fn.triggers ?? []) {
const triggerPath = `${fnPath}.triggers.${trigger.type ?? 'unknown'}`;
if (!trigger.universalIdentifier) {
errors.push({
path: triggerPath,
message: 'Trigger must have a universalIdentifier',
});
}
if (!trigger.type) {
errors.push({
path: triggerPath,
message: 'Trigger must have a type',
});
continue;
}
switch (trigger.type) {
case 'route':
if (!trigger.path) {
errors.push({
path: triggerPath,
message: 'Route trigger must have a path',
});
}
if (!trigger.httpMethod) {
errors.push({
path: triggerPath,
message: 'Route trigger must have an httpMethod',
});
}
break;
case 'cron':
if (!trigger.pattern) {
errors.push({
path: triggerPath,
message: 'Cron trigger must have a pattern',
});
}
break;
case 'databaseEvent':
if (!trigger.eventName) {
errors.push({
path: triggerPath,
message: 'Database event trigger must have an eventName',
});
}
break;
}
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const functions = manifest.functions ?? [];
for (const fn of functions) {
if (fn.universalIdentifier) {
const location = `functions/${fn.name ?? fn.handlerName}`;
const locations = seen.get(fn.universalIdentifier) ?? [];
locations.push(location);
seen.set(fn.universalIdentifier, locations);
}
for (const trigger of fn.triggers ?? []) {
if (trigger.universalIdentifier) {
const location = `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`;
const locations = seen.get(trigger.universalIdentifier) ?? [];
locations.push(location);
seen.set(trigger.universalIdentifier, locations);
}
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const functionEntityBuilder = new FunctionEntityBuilder();
@@ -1,182 +0,0 @@
import { glob } from 'fast-glob';
import { type ObjectExtensionManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
import {
type EntityBuildResult,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
export class ObjectExtensionEntityBuilder
implements ManifestEntityBuilder<ObjectExtensionManifest>
{
async build(
appPath: string,
): Promise<EntityBuildResult<ObjectExtensionManifest>> {
const extensionFiles = await glob(['**/*.object-extension.ts'], {
cwd: appPath,
ignore: [
'**/node_modules/**',
'**/*.d.ts',
'**/dist/**',
'**/.twenty/**',
],
});
const manifests: ObjectExtensionManifest[] = [];
for (const filePath of extensionFiles) {
try {
const absolutePath = `${appPath}/${filePath}`;
const { manifest } =
await manifestExtractFromFileServer.extractManifestFromFile<ObjectExtensionManifest>(
absolutePath,
);
manifests.push(manifest);
} catch (error) {
throw new Error(
`Failed to load object extension from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { manifests, filePaths: extensionFiles };
}
validate(
extensions: ObjectExtensionManifest[],
errors: ValidationError[],
): void {
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
const extPath = `object-extensions/${targetName}`;
if (!ext.targetObject) {
errors.push({
path: extPath,
message: 'Object extension must have a targetObject',
});
continue;
}
const { nameSingular, universalIdentifier } = ext.targetObject;
if (!nameSingular && !universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject must have either nameSingular or universalIdentifier',
});
}
if (nameSingular && universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject cannot have both nameSingular and universalIdentifier',
});
}
if (!isNonEmptyArray(ext.fields)) {
errors.push({
path: extPath,
message: 'Object extension must have at least one field',
});
}
for (const field of ext.fields ?? []) {
const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`;
if (!field.universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
});
}
if (!field.type) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
});
}
if (!field.label) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
});
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
!isNonEmptyArray(field.options)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const extensions = manifest.objectExtensions ?? [];
const objects = manifest.objects ?? [];
const objectFieldIds = new Map<string, string>();
for (const obj of objects) {
for (const field of obj.fields ?? []) {
if (field.universalIdentifier) {
const location = `objects/${obj.nameSingular}.fields.${field.label}`;
objectFieldIds.set(field.universalIdentifier, location);
}
}
}
const extensionFieldLocations = new Map<string, string[]>();
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
for (const field of ext.fields ?? []) {
if (field.universalIdentifier) {
const location = `object-extensions/${targetName}.fields.${field.label}`;
const locations =
extensionFieldLocations.get(field.universalIdentifier) ?? [];
locations.push(location);
extensionFieldLocations.set(field.universalIdentifier, locations);
}
}
}
const duplicates: EntityIdWithLocation[] = [];
for (const [id, extLocations] of extensionFieldLocations.entries()) {
const objectLocation = objectFieldIds.get(id);
if (extLocations.length > 1 || objectLocation) {
const allLocations = objectLocation
? [objectLocation, ...extLocations]
: extLocations;
duplicates.push({ id, locations: allLocations });
}
}
return duplicates;
}
}
export const objectExtensionEntityBuilder = new ObjectExtensionEntityBuilder();
@@ -1,140 +0,0 @@
import { glob } from 'fast-glob';
import { type ObjectManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
import {
type EntityBuildResult,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
export class ObjectEntityBuilder
implements ManifestEntityBuilder<ObjectManifest>
{
async build(appPath: string): Promise<EntityBuildResult<ObjectManifest>> {
const objectFiles = await glob(['**/*.object.ts'], {
cwd: appPath,
ignore: [
'**/node_modules/**',
'**/*.d.ts',
'**/dist/**',
'**/.twenty/**',
],
});
const manifests: ObjectManifest[] = [];
for (const filePath of objectFiles) {
try {
const absolutePath = `${appPath}/${filePath}`;
const { manifest } =
await manifestExtractFromFileServer.extractManifestFromFile<ObjectManifest>(
absolutePath,
);
manifests.push(manifest);
} catch (error) {
throw new Error(
`Failed to load object from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { manifests, filePaths: objectFiles };
}
validate(objects: ObjectManifest[], errors: ValidationError[]): void {
for (const obj of objects) {
const objPath = `objects/${obj.nameSingular ?? 'unknown'}`;
if (!obj.universalIdentifier) {
errors.push({
path: objPath,
message: 'Object must have a universalIdentifier',
});
}
if (!obj.nameSingular) {
errors.push({
path: objPath,
message: 'Object must have a nameSingular',
});
}
if (!obj.namePlural) {
errors.push({
path: objPath,
message: 'Object must have a namePlural',
});
}
for (const field of obj.fields ?? []) {
const fieldPath = `${objPath}.fields.${field.label ?? 'unknown'}`;
if (!field.universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
});
}
if (!field.type) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
});
}
if (!field.label) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
});
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
!isNonEmptyArray(field.options)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const objects = manifest.objects ?? [];
for (const obj of objects) {
if (obj.universalIdentifier) {
const location = `objects/${obj.nameSingular}`;
const locations = seen.get(obj.universalIdentifier) ?? [];
locations.push(location);
seen.set(obj.universalIdentifier, locations);
}
for (const field of obj.fields ?? []) {
if (field.universalIdentifier) {
const location = `objects/${obj.nameSingular}.fields.${field.label}`;
const locations = seen.get(field.universalIdentifier) ?? [];
locations.push(location);
seen.set(field.universalIdentifier, locations);
}
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const objectEntityBuilder = new ObjectEntityBuilder();
@@ -1,85 +0,0 @@
import { glob } from 'fast-glob';
import { type RoleManifest } from 'twenty-shared/application';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
import {
type EntityBuildResult,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest> {
async build(appPath: string): Promise<EntityBuildResult<RoleManifest>> {
const roleFiles = await glob(['**/*.role.ts'], {
cwd: appPath,
ignore: [
'**/node_modules/**',
'**/*.d.ts',
'**/dist/**',
'**/.twenty/**',
],
});
const manifests: RoleManifest[] = [];
for (const filePath of roleFiles) {
try {
const absolutePath = `${appPath}/${filePath}`;
const { manifest } =
await manifestExtractFromFileServer.extractManifestFromFile<RoleManifest>(
absolutePath,
);
manifests.push(manifest);
} catch (error) {
throw new Error(
`Failed to load role from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { manifests, filePaths: roleFiles };
}
validate(roles: RoleManifest[], errors: ValidationError[]): void {
for (const role of roles) {
const rolePath = `roles/${role.label ?? 'unknown'}`;
if (!role.universalIdentifier) {
errors.push({
path: rolePath,
message: 'Role must have a universalIdentifier',
});
}
if (!role.label) {
errors.push({
path: rolePath,
message: 'Role must have a label',
});
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const roles = manifest.roles ?? [];
for (const role of roles) {
if (role.universalIdentifier) {
const location = `roles/${role.label}`;
const locations = seen.get(role.universalIdentifier) ?? [];
locations.push(location);
seen.set(role.universalIdentifier, locations);
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const roleEntityBuilder = new RoleEntityBuilder();
@@ -1,44 +1,54 @@
import { findPathFile } from '@/cli/utilities/file/file-find';
import { parseJsoncFile } from '@/cli/utilities/file/file-jsonc';
import { glob } from 'fast-glob';
import * as fs from 'fs-extra';
import { basename, extname, relative, sep } from 'path';
import { readFile } from 'fs-extra';
import { relative, sep } from 'path';
import { glob } from 'fast-glob';
import {
type EntityFilePaths,
extractDefineEntity,
ManifestEntityKey,
TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING,
} from '@/cli/utilities/build/manifest/manifest-extract-config';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import {
type ApplicationManifest,
OUTPUT_DIR,
type Manifest,
type AssetManifest,
ASSETS_DIR,
type FieldManifest,
type FrontComponentManifest,
type LogicFunctionManifest,
type ObjectManifest,
type RoleManifest,
} from 'twenty-shared/application';
import { FileFolder, type Sources } from 'twenty-shared/types';
import { applicationEntityBuilder } from '@/cli/utilities/build/manifest/entities/application';
import { assetEntityBuilder } from '@/cli/utilities/build/manifest/entities/asset';
import { frontComponentEntityBuilder } from '@/cli/utilities/build/manifest/entities/front-component';
import { functionEntityBuilder } from '@/cli/utilities/build/manifest/entities/function';
import { objectEntityBuilder } from '@/cli/utilities/build/manifest/entities/object';
import { objectExtensionEntityBuilder } from '@/cli/utilities/build/manifest/entities/object-extension';
import { roleEntityBuilder } from '@/cli/utilities/build/manifest/entities/role';
import { parseJsoncFile } from '@/cli/utilities/file/file-jsonc';
import { findPathFile } from '@/cli/utilities/file/file-find';
import { assertUnreachable } from 'twenty-shared/utils';
import { type FrontComponentConfig, type LogicFunctionConfig } from '@/sdk';
import type { Sources } from 'twenty-shared/types';
import * as fs from 'fs-extra';
import { manifestExtractFromFileServer } from './manifest-extract-from-file-server';
export type EntityFilePaths = {
application: string[];
objects: string[];
objectExtensions: string[];
functions: string[];
frontComponents: string[];
roles: string[];
assets: string[];
};
const loadSources = async (appPath: string): Promise<Sources> => {
const sources: Sources = {};
const tsFiles = await glob(['**/*.ts', '**/*.tsx'], {
const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
onlyFiles: true,
});
};
for (const filepath of tsFiles) {
const loadAssets = async (appPath: string) => {
return await glob([`${ASSETS_DIR}/**/*`], {
cwd: appPath,
onlyFiles: true,
});
};
const computeSources = async (
appPath: string,
sourceFilePaths: string[],
): Promise<Sources> => {
const sources: Sources = {};
for (const filepath of sourceFilePaths) {
const relPath = relative(appPath, filepath);
const parts = relPath.split(sep);
const content = await fs.readFile(filepath, 'utf8');
@@ -58,165 +68,190 @@ const loadSources = async (appPath: string): Promise<Sources> => {
return sources;
};
export const EMPTY_FILE_PATHS: EntityFilePaths = {
application: [],
objects: [],
objectExtensions: [],
functions: [],
frontComponents: [],
roles: [],
assets: [],
};
export type ManifestBuildResult = {
manifest: ApplicationManifest | null;
export const buildManifest = async (
appPath: string,
): Promise<{
manifest: Manifest | null;
filePaths: EntityFilePaths;
error?: string;
};
errors: string[];
}> => {
const filePaths = await loadSources(appPath);
const errors: string[] = [];
export type UpdateManifestChecksumParams = {
manifest: ApplicationManifest;
builtFileInfos: Map<
string,
{ checksum: string; builtPath: string; fileFolder: FileFolder }
>;
};
let application: ApplicationManifest | undefined;
const objects: ObjectManifest[] = [];
const fields: FieldManifest[] = [];
const roles: RoleManifest[] = [];
const logicFunctions: LogicFunctionManifest[] = [];
const frontComponents: FrontComponentManifest[] = [];
const publicAssets: AssetManifest[] = [];
export const updateManifestChecksum = ({
manifest,
builtFileInfos,
}: UpdateManifestChecksumParams): ApplicationManifest => {
let result = structuredClone(manifest);
for (const [
builtPath,
{ fileFolder, checksum },
] of builtFileInfos.entries()) {
const rootBuiltPath = relative(OUTPUT_DIR, builtPath);
if (fileFolder === FileFolder.BuiltFunction) {
const functions = result.functions ?? [];
const fnIndex = functions.findIndex(
(f) => f.builtHandlerPath === rootBuiltPath,
);
if (fnIndex === -1) {
continue;
}
result = {
...result,
functions: functions.map((fn, index) =>
index === fnIndex ? { ...fn, builtHandlerChecksum: checksum } : fn,
),
};
}
const applicationFilePaths: string[] = [];
const objectsFilePaths: string[] = [];
const fieldsFilePaths: string[] = [];
const rolesFilePaths: string[] = [];
const logicFunctionsFilePaths: string[] = [];
const frontComponentsFilePaths: string[] = [];
const publicAssetsFilePaths: string[] = [];
if (fileFolder === FileFolder.PublicAsset) {
const assets = result.publicAssets ?? [];
const assetIndex = assets.findIndex((a) => a.filePath === rootBuiltPath);
if (assetIndex === -1) {
continue;
}
result = {
...result,
publicAssets: assets.map((asset, index) =>
index === assetIndex ? { ...asset, checksum } : asset,
),
};
for (const filePath of filePaths) {
const fileContent = await readFile(filePath, 'utf-8');
const relativePath = relative(appPath, filePath);
const targetFunctionName = extractDefineEntity(fileContent);
if (!targetFunctionName) {
continue;
}
if (fileFolder === FileFolder.BuiltFrontComponent) {
const frontComponents = result.frontComponents ?? [];
const componentIndex =
frontComponents.findIndex(
(c) => c.builtComponentPath === rootBuiltPath,
) ?? -1;
if (componentIndex === -1) {
continue;
const entity = TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING[targetFunctionName];
switch (entity) {
case ManifestEntityKey.Application: {
const extract = await extractManifestFromFile<ApplicationManifest>({
appPath,
filePath,
});
application = extract.config;
errors.push(...extract.errors);
applicationFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Objects: {
const extract = await extractManifestFromFile<ObjectManifest>({
appPath,
filePath,
});
objects.push(extract.config);
errors.push(...extract.errors);
objectsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Fields: {
const extract = await extractManifestFromFile<FieldManifest>({
appPath,
filePath,
});
fields.push(extract.config);
errors.push(...extract.errors);
fieldsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Roles: {
const extract = await extractManifestFromFile<RoleManifest>({
appPath,
filePath,
});
roles.push(extract.config);
errors.push(...extract.errors);
rolesFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.LogicFunctions: {
const extract = await extractManifestFromFile<LogicFunctionConfig>({
appPath,
filePath,
});
errors.push(...extract.errors);
const { handler: _, ...rest } = extract.config;
const config: LogicFunctionManifest = {
...rest,
handlerName: 'default.handler',
sourceHandlerPath: filePath,
builtHandlerPath: filePath.replace(/\.tsx?$/, '.mjs'),
builtHandlerChecksum: null,
};
logicFunctions.push(config);
logicFunctionsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.FrontComponents: {
const extract = await extractManifestFromFile<FrontComponentConfig>({
appPath,
filePath,
});
errors.push(...extract.errors);
const { component, ...rest } = extract.config;
const config: FrontComponentManifest = {
...rest,
componentName: component.name,
sourceComponentPath: filePath,
builtComponentPath: filePath.replace(/\.tsx?$/, '.mjs'),
builtComponentChecksum: null,
};
frontComponents.push(config);
frontComponentsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PublicAssets: {
// Public assets are handled below
break;
}
default: {
assertUnreachable(entity);
}
result = {
...result,
frontComponents: frontComponents.map((component, index) =>
index === componentIndex
? { ...component, builtComponentChecksum: checksum }
: component,
),
};
}
}
return result;
};
export const runManifestBuild = async (
appPath: string,
): Promise<ManifestBuildResult> => {
try {
manifestExtractFromFileServer.init(appPath);
const assetFiles = await loadAssets(appPath);
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
const yarnLock = await readFile(
await findPathFile(appPath, 'yarn.lock'),
'utf8',
);
const [
applicationBuildResult,
objectBuildResult,
objectExtensionBuildResult,
functionBuildResult,
frontComponentBuildResult,
roleBuildResult,
assetBuildResult,
sources,
] = await Promise.all([
applicationEntityBuilder.build(appPath),
objectEntityBuilder.build(appPath),
objectExtensionEntityBuilder.build(appPath),
functionEntityBuilder.build(appPath),
frontComponentEntityBuilder.build(appPath),
roleEntityBuilder.build(appPath),
assetEntityBuilder.build(appPath),
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 assetManifests = assetBuildResult.manifests;
const filePaths: EntityFilePaths = {
application: applicationBuildResult.filePaths,
objects: objectBuildResult.filePaths,
objectExtensions: objectExtensionBuildResult.filePaths,
functions: functionBuildResult.filePaths,
frontComponents: frontComponentBuildResult.filePaths,
roles: roleBuildResult.filePaths,
assets: assetBuildResult.filePaths,
};
const manifest: ApplicationManifest = {
application,
objects: objectManifests,
objectExtensions: objectExtensionManifests,
functions: functionManifests,
frontComponents: frontComponentManifests,
roles: roleManifests,
publicAssets: assetManifests,
sources,
packageJson,
yarnLock,
};
return { manifest, filePaths };
} catch (error) {
return {
manifest: null,
filePaths: EMPTY_FILE_PATHS,
error: error instanceof Error ? error.message : `${error}`,
};
for (const assetFile of assetFiles) {
publicAssets.push({
filePath: assetFile,
fileName: basename(assetFile),
fileType: extname(assetFile).replace(/^\./, ''),
checksum: null,
});
publicAssetsFilePaths.push(relative(appPath, assetFile));
}
if (!application) {
errors.push(
'Cannot build application, please export default defineApplication() to define an application',
);
}
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
const yarnLock = await readFile(
await findPathFile(appPath, 'yarn.lock'),
'utf8',
);
const manifest = !application
? null
: {
application,
objects,
fields,
roles,
logicFunctions,
frontComponents,
publicAssets,
sources: await computeSources(appPath, filePaths),
packageJson,
yarnLock,
};
const entityFilePaths: EntityFilePaths = {
application: applicationFilePaths,
objects: objectsFilePaths,
fields: fieldsFilePaths,
roles: rolesFilePaths,
logicFunctions: logicFunctionsFilePaths,
frontComponents: frontComponentsFilePaths,
publicAssets: publicAssetsFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors };
};
@@ -0,0 +1,84 @@
import path from 'path';
import * as fs from 'fs-extra';
import { createRequire } from 'module';
import * as esbuild from 'esbuild';
import os from 'os';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import { type ValidationResult } from '@/sdk';
export const extractManifestFromFile = async <T>({
filePath,
appPath,
}: {
filePath: string;
appPath: string;
}): Promise<ValidationResult<T>> => {
const module = await loadModule({ filePath, appPath });
return extractDefaultConfigFromModuleOrThrow<T>(module, filePath);
};
const loadModule = async ({
filePath,
appPath,
}: {
filePath: string;
appPath: string;
}): Promise<Record<string, unknown>> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
const hasTsconfig = await fs.pathExists(tsconfigPath);
// Resolve react from the app's node_modules for the alias
const appRequire = createRequire(path.join(appPath, 'package.json'));
let reactPath: string | undefined;
let reactDomPath: string | undefined;
try {
reactPath = path.dirname(appRequire.resolve('react/package.json'));
reactDomPath = path.dirname(appRequire.resolve('react-dom/package.json'));
} catch {
// React not installed in app, will be bundled if used
}
const result = await esbuild.build({
entryPoints: [filePath],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
target: 'node18',
jsx: 'automatic',
tsconfig: hasTsconfig ? tsconfigPath : undefined,
alias: {
...(reactPath && { react: reactPath }),
...(reactDomPath && { 'react-dom': reactDomPath }),
},
logLevel: 'silent',
});
const code = result.outputFiles[0].text;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'twenty-manifest-'));
const tempFile = path.join(tempDir, 'module.cjs');
try {
await fs.writeFile(tempFile, code);
return require(tempFile) as Record<string, unknown>;
} finally {
await fs.remove(tempDir);
}
};
const extractDefaultConfigFromModuleOrThrow = <T>(
module: Record<string, unknown>,
filePath: string,
): ValidationResult<T> => {
if (isDefined(module.default) && isPlainObject(module.default)) {
return module.default as ValidationResult<T>;
}
throw new Error(
`Config file ${filePath} must export a config object default export`,
);
};
@@ -0,0 +1,82 @@
import * as ts from 'typescript';
export enum TargetFunction {
DefineApplication = 'defineApplication',
DefineField = 'defineField',
DefineLogicFunction = 'defineLogicFunction',
DefineObject = 'defineObject',
DefineRole = 'defineRole',
DefineFrontComponent = 'defineFrontComponent',
}
export enum ManifestEntityKey {
Application = 'application',
Fields = 'fields',
LogicFunctions = 'logicFunctions',
Objects = 'objects',
Roles = 'roles',
FrontComponents = 'frontComponents',
PublicAssets = 'publicAssets',
}
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
TargetFunction,
ManifestEntityKey
> = {
[TargetFunction.DefineApplication]: ManifestEntityKey.Application,
[TargetFunction.DefineField]: ManifestEntityKey.Fields,
[TargetFunction.DefineLogicFunction]: ManifestEntityKey.LogicFunctions,
[TargetFunction.DefineObject]: ManifestEntityKey.Objects,
[TargetFunction.DefineRole]: ManifestEntityKey.Roles,
[TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents,
};
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
if (!ts.isCallExpression(node)) {
return undefined;
}
const expression = node.expression;
if (ts.isIdentifier(expression)) {
if ((Object.values(TargetFunction) as string[]).includes(expression.text)) {
return expression.text;
}
}
return undefined;
};
export const extractDefineEntity = (
fileContent: string,
): TargetFunction | undefined => {
const sourceFile = ts.createSourceFile(
'temp.ts',
fileContent,
ts.ScriptTarget.Latest,
true,
);
const children: ts.Node[] = [];
ts.forEachChild(sourceFile, (node) => {
children.push(node);
});
for (const node of children) {
if (ts.isExportAssignment(node)) {
if (node.isExportEquals || !node.expression) {
return;
}
const targetFunction = computeIsTargetFunctionCall(node.expression);
if (targetFunction) {
return targetFunction as TargetFunction;
}
}
}
return;
};
@@ -1,115 +0,0 @@
import * as esbuild from 'esbuild';
import * as fs from 'fs-extra';
import { createRequire } from 'module';
import * as os from 'os';
import path from 'path';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
export type ExtractedManifest<TManifest> = {
manifest: TManifest;
exportName: string | null;
};
export class ManifestExtractFromFileServer {
private appPath: string | null = null;
init(appPath: string): void {
this.appPath = appPath;
}
async extractManifestFromFile<TManifest>(
filepath: string,
): Promise<ExtractedManifest<TManifest>> {
if (!this.appPath) {
throw new Error(
'ManifestExtractFromFileServer not initialized. Call init(appPath) first.',
);
}
const module = await this.loadModule(filepath);
const result = this.extractConfigFromModule<TManifest>(module);
if (!result) {
throw new Error(
`Config file ${filepath} must export a config object (default export or any named object export)`,
);
}
return result;
}
private async loadModule(filepath: string): Promise<Record<string, unknown>> {
if (!this.appPath) {
throw new Error(
'ManifestExtractFromFileServer not initialized. Call init(appPath) first.',
);
}
const tsconfigPath = path.join(this.appPath, 'tsconfig.json');
const hasTsconfig = await fs.pathExists(tsconfigPath);
// Resolve react from the app's node_modules for the alias
const appRequire = createRequire(path.join(this.appPath, 'package.json'));
let reactPath: string | undefined;
let reactDomPath: string | undefined;
try {
reactPath = path.dirname(appRequire.resolve('react/package.json'));
reactDomPath = path.dirname(appRequire.resolve('react-dom/package.json'));
} catch {
// React not installed in app, will be bundled if used
}
const result = await esbuild.build({
entryPoints: [filepath],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
target: 'node18',
jsx: 'automatic',
tsconfig: hasTsconfig ? tsconfigPath : undefined,
// Use alias to resolve react from app's node_modules
alias: {
...(reactPath && { react: reactPath }),
...(reactDomPath && { 'react-dom': reactDomPath }),
},
logLevel: 'silent',
});
const code = result.outputFiles[0].text;
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'twenty-manifest-'),
);
const tempFile = path.join(tempDir, 'module.cjs');
try {
await fs.writeFile(tempFile, code);
return require(tempFile) as Record<string, unknown>;
} finally {
await fs.remove(tempDir);
}
}
private extractConfigFromModule<T>(
module: Record<string, unknown>,
): ExtractedManifest<T> | undefined {
if (isDefined(module.default) && isPlainObject(module.default)) {
return { manifest: module.default as T, exportName: null };
}
for (const [key, value] of Object.entries(module)) {
if (isPlainObject(value)) {
return { manifest: value as T, exportName: key };
}
}
return undefined;
}
}
export const manifestExtractFromFileServer =
new ManifestExtractFromFileServer();
@@ -2,14 +2,3 @@ export type ValidationError = {
path: string;
message: string;
};
export type ValidationWarning = {
path?: string;
message: string;
};
export type ValidationResult = {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
};
@@ -1,75 +0,0 @@
import { isNonEmptyArray } from 'twenty-shared/utils';
import { applicationEntityBuilder } from './entities/application';
import {
type EntityIdWithLocation,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
import { frontComponentEntityBuilder } from './entities/front-component';
import { functionEntityBuilder } from './entities/function';
import { objectEntityBuilder } from './entities/object';
import { objectExtensionEntityBuilder } from './entities/object-extension';
import { roleEntityBuilder } from './entities/role';
import {
type ValidationError,
type ValidationResult,
type ValidationWarning,
} from '@/cli/utilities/build/manifest/manifest-types';
const collectAllDuplicates = (
manifest: ManifestWithoutSources,
): EntityIdWithLocation[] => {
return [
...applicationEntityBuilder.findDuplicates(manifest),
...objectEntityBuilder.findDuplicates(manifest),
...objectExtensionEntityBuilder.findDuplicates(manifest),
...functionEntityBuilder.findDuplicates(manifest),
...roleEntityBuilder.findDuplicates(manifest),
...frontComponentEntityBuilder.findDuplicates(manifest),
];
};
export const validateManifest = (
manifest: ManifestWithoutSources,
): ValidationResult => {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
applicationEntityBuilder.validate(
manifest.application ? [manifest.application] : [],
errors,
);
objectEntityBuilder.validate(manifest.objects ?? [], errors);
objectExtensionEntityBuilder.validate(
manifest.objectExtensions ?? [],
errors,
);
functionEntityBuilder.validate(manifest.functions ?? [], errors);
roleEntityBuilder.validate(manifest.roles ?? [], errors);
frontComponentEntityBuilder.validate(manifest.frontComponents ?? [], errors);
const duplicates = collectAllDuplicates(manifest);
for (const dup of duplicates) {
errors.push({
path: dup.locations.join(', '),
message: `Duplicate universalIdentifier: ${dup.id}`,
});
}
if (!isNonEmptyArray(manifest.objects)) {
warnings.push({
message: 'No objects defined',
});
}
if (!isNonEmptyArray(manifest.functions)) {
warnings.push({
message: 'No functions defined',
});
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
};
@@ -1,13 +1,10 @@
import * as fs from 'fs-extra';
import path from 'path';
import {
type ApplicationManifest,
OUTPUT_DIR,
} from 'twenty-shared/application';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
export const writeManifestToOutput = async (
appPath: string,
manifest: ApplicationManifest,
manifest: Manifest,
): Promise<string> => {
const outputDir = path.join(appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
@@ -0,0 +1,82 @@
import { relative } from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import type { EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
export type ManifestBuildResult = {
manifest: Manifest | null;
filePaths: EntityFilePaths;
error?: string;
};
export type UpdateManifestChecksumParams = {
manifest: Manifest;
builtFileInfos: Map<
string,
{ checksum: string; builtPath: string; fileFolder: FileFolder }
>;
};
export const updateManifestChecksums = ({
manifest,
builtFileInfos,
}: UpdateManifestChecksumParams): Manifest => {
let result = structuredClone(manifest);
for (const [
builtPath,
{ fileFolder, checksum },
] of builtFileInfos.entries()) {
const rootBuiltPath = relative(OUTPUT_DIR, builtPath);
if (fileFolder === FileFolder.BuiltLogicFunction) {
const logicFunctions = result.logicFunctions;
const fnIndex = logicFunctions.findIndex(
(f) => f.builtHandlerPath === rootBuiltPath,
);
if (fnIndex === -1) {
continue;
}
result = {
...result,
logicFunctions: logicFunctions.map((fn, index) =>
index === fnIndex ? { ...fn, builtHandlerChecksum: checksum } : fn,
),
};
}
if (fileFolder === FileFolder.PublicAsset) {
const assets = result.publicAssets;
const assetIndex = assets.findIndex((a) => a.filePath === rootBuiltPath);
if (assetIndex === -1) {
continue;
}
result = {
...result,
publicAssets: assets.map((asset, index) =>
index === assetIndex ? { ...asset, checksum } : asset,
),
};
continue;
}
if (fileFolder === FileFolder.BuiltFrontComponent) {
const frontComponents = result.frontComponents;
const componentIndex =
frontComponents.findIndex(
(c) => c.builtComponentPath === rootBuiltPath,
) ?? -1;
if (componentIndex === -1) {
continue;
}
result = {
...result,
frontComponents: frontComponents.map((component, index) =>
index === componentIndex
? { ...component, builtComponentChecksum: checksum }
: component,
),
};
}
}
return result;
};
@@ -0,0 +1,60 @@
import { type Manifest } from 'twenty-shared/application';
import { isNonEmptyArray } from 'twenty-shared/utils';
const extractDuplicates = (values: string[]): string[] => {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
duplicates.add(value);
} else {
seen.add(value);
}
}
return Array.from(duplicates);
};
const findUniversalIdentifiers = (obj: object): string[] => {
const universalIdentifiers: string[] = [];
if (!obj) {
return [];
}
for (const [key, val] of Object.entries(obj)) {
if (key === 'universalIdentifier' && typeof val === 'string') {
universalIdentifiers.push(val);
}
if (typeof val === 'object') {
universalIdentifiers.push(...findUniversalIdentifiers(val));
}
}
return universalIdentifiers;
};
export const validateManifest = (manifest: Manifest) => {
const errors: string[] = [];
const warnings: string[] = [];
const duplicates = extractDuplicates(findUniversalIdentifiers(manifest));
if (duplicates.length > 0) {
errors.push(`Duplicate universal identifiers: ${duplicates.join(', ')}`);
}
if (!isNonEmptyArray(manifest.objects)) {
warnings.push('No object defined');
}
if (!isNonEmptyArray(manifest.logicFunctions)) {
warnings.push('No logic function defined');
}
if (!isNonEmptyArray(manifest.frontComponents)) {
warnings.push('No front component defined');
}
return { errors, warnings, isValid: errors.length === 0 };
};
@@ -1,16 +1,16 @@
import {
type ManifestBuildResult,
runManifestBuild,
updateManifestChecksum,
} from '@/cli/utilities/build/manifest/manifest-build';
updateManifestChecksums,
} from '@/cli/utilities/build/manifest/update-manifest-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ApiService } from '@/cli/utilities/api/api-service';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { type FileFolder } from 'twenty-shared/types';
import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate';
import type { Location } from 'esbuild';
import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
import { type EventName } from 'chokidar/handler.js';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { validateManifest } from '@/cli/utilities/build/manifest/validate-manifest';
export type DevModeOrchestratorOptions = {
appPath: string;
@@ -232,21 +232,38 @@ export class DevModeOrchestrator {
manifestStatus: 'building',
});
const result = await runManifestBuild(this.appPath);
const result = await buildManifest(this.appPath);
if (result.error || !result.manifest) {
if (result.errors.length > 0 || !result.manifest) {
for (const error of result.errors) {
this.uiStateManager.addEvent({
message: error,
status: 'error',
});
}
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
this.uiStateManager.addEvent({
message: result.error ?? 'Unknown error',
status: 'error',
error: result.errors[result.errors.length - 1],
});
return;
}
const validation = validateManifest(result.manifest);
if (!validation.isValid) {
for (const e of validation.errors) {
this.uiStateManager.addEvent({
message: e,
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
error: e,
});
}
return;
}
this.uiStateManager.updateManifestState({
appName: result.manifest.application.displayName,
});
@@ -255,25 +272,10 @@ export class DevModeOrchestrator {
manifestFilePaths: result.filePaths,
});
if (!validation.isValid) {
for (const e of validation.errors) {
this.uiStateManager.addEvent({
message: `${e.path}: ${e.message}`,
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
}
return;
}
if (validation.warnings.length > 0) {
for (const warning of validation.warnings) {
const path = warning.path ? `${warning.path}: ` : '';
this.uiStateManager.addEvent({
message: `${path}${warning.message}`,
message: `${warning}`,
status: 'warning',
});
}
@@ -304,7 +306,7 @@ export class DevModeOrchestrator {
await Promise.all(this.activeUploads);
}
const manifest = updateManifestChecksum({
const manifest = updateManifestChecksums({
manifest: result.manifest,
builtFileInfos: this.builtFileInfos,
});
@@ -344,7 +346,7 @@ export class DevModeOrchestrator {
});
} else {
this.uiStateManager.addEvent({
message: `Sync failed: ${JSON.stringify(syncResult.error, null, 2)}`,
message: `Sync failed with error ${JSON.stringify(syncResult.error, null, 2)}`,
status: 'error',
});
this.uiStateManager.updateManifestState({
@@ -353,7 +355,7 @@ export class DevModeOrchestrator {
}
} catch (error) {
this.uiStateManager.addEvent({
message: `Sync failed: ${JSON.stringify(error, null, 2)}`,
message: `Sync failed with error ${JSON.stringify(error, null, 2)}`,
status: 'error',
});
this.uiStateManager.updateManifestState({
@@ -6,7 +6,7 @@ import {
type UiEvent,
type DevUiState,
} from '@/cli/utilities/dev/dev-ui-state';
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-build';
import type { EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
const MAX_EVENT_NUMBER = 200;
@@ -92,7 +92,7 @@ export class DevUiStateManager {
...this.state,
...(manifestStatus ? { manifestStatus } : {}),
...(appName ? { appName } : {}),
...(error ? { error } : {}),
...(error ? { error } : { error: undefined }),
};
this.notify();
@@ -104,16 +104,14 @@ export class DevUiStateManager {
switch (entityType) {
case 'objects':
return SyncableEntity.Object;
case 'objectExtensions':
return SyncableEntity.ObjectExtension;
case 'functions':
return SyncableEntity.Function;
case 'fields':
return SyncableEntity.Field;
case 'logicFunctions':
return SyncableEntity.LogicFunction;
case 'frontComponents':
return SyncableEntity.FrontComponent;
case 'roles':
return SyncableEntity.Role;
case 'assets':
return SyncableEntity.PublicAsset;
default:
return;
}
@@ -26,11 +26,10 @@ const STATUS_COLORS: Record<FileStatus, string> = {
const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Object]: 'Objects',
[SyncableEntity.ObjectExtension]: 'Object Extensions',
[SyncableEntity.Function]: 'Functions',
[SyncableEntity.FrontComponent]: 'Front Components',
[SyncableEntity.Field]: 'Fields',
[SyncableEntity.LogicFunction]: 'Logic functions',
[SyncableEntity.FrontComponent]: 'Front components',
[SyncableEntity.Role]: 'Roles',
[SyncableEntity.PublicAsset]: 'Public Assets',
};
const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
@@ -194,7 +193,7 @@ export const renderDevUI = async (
return (
<Text color={config.color}>
{icon ?? ''}
{icon ? `${icon} ` : ''}
{config.text}
{snapshot.error && `: ${snapshot.error}`}
</Text>
@@ -1,14 +1,16 @@
import { getFunctionBaseFile } from '@/cli/utilities/entity/entity-function-template';
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
describe('getFunctionBaseFile', () => {
it('should render proper file using defineFunction', () => {
const result = getFunctionBaseFile({
const result = getLogicFunctionBaseFile({
name: 'my-function',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
});
expect(result).toContain("import { defineFunction } from 'twenty-sdk'");
expect(result).toContain('export default defineFunction({');
expect(result).toContain(
"import { defineLogicFunction } from 'twenty-sdk'",
);
expect(result).toContain('export default defineLogicFunction({');
expect(result).toContain(
"universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4'",
@@ -21,12 +23,12 @@ describe('getFunctionBaseFile', () => {
expect(result).toContain('const handler = async');
expect(result).toContain(
"description: 'Add a description for your function'",
"description: 'Add a description for your logic function'",
);
});
it('should generate unique UUID when not provided', () => {
const result = getFunctionBaseFile({
const result = getLogicFunctionBaseFile({
name: 'auto-uuid-function',
});
@@ -36,7 +38,7 @@ describe('getFunctionBaseFile', () => {
});
it('should use kebab-case for function name', () => {
const result = getFunctionBaseFile({
const result = getLogicFunctionBaseFile({
name: 'my-awesome-function',
});
@@ -45,7 +47,7 @@ describe('getFunctionBaseFile', () => {
});
it('should include trigger examples as comments', () => {
const result = getFunctionBaseFile({
const result = getLogicFunctionBaseFile({
name: 'example-function',
});
@@ -0,0 +1,31 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
export const getFieldBaseFile = ({
data,
}: {
data: {
name: string;
label: string;
type: FieldMetadataType;
objectUniversalIdentifier: string;
description?: string;
};
name: string;
}) => {
const universalIdentifier = v4();
const descriptionLine = data.description
? `\n description: '${data.description}',`
: '';
return `import { defineField, FieldType } from 'twenty-sdk';
export default defineField({
universalIdentifier: '${universalIdentifier}',
name: '${data.name}',
label: '${data.label}',
type: FieldMetadataType.${data.type},
objectUniversalIdentifier: '${data.objectUniversalIdentifier}',${descriptionLine}
});
`;
};
@@ -1,7 +1,7 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getFunctionBaseFile = ({
export const getLogicFunctionBaseFile = ({
name,
universalIdentifier = v4(),
}: {
@@ -11,9 +11,9 @@ export const getFunctionBaseFile = ({
const kebabCaseName = kebabCase(name);
const triggerUniversalIdentifier = v4();
return `import { defineFunction } from 'twenty-sdk';
return `import { defineLogicFunction } from 'twenty-sdk';
// Handler function - rename and implement your logic
// Logic function handler - rename and implement your logic
const handler = async (params: {
a: string;
b: number;
@@ -26,10 +26,10 @@ const handler = async (params: {
return { message };
};
export default defineFunction({
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: '${kebabCaseName}',
description: 'Add a description for your function',
description: 'Add a description for your logic function',
timeoutSeconds: 5,
handler,
triggers: [