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:
@@ -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';
|
||||
|
||||
|
||||
+77
-81
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
|
||||
+7
-7
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { defineFrontComponent } from '@/application/front-components/define-front-component';
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
export const TestComponent = () => {
|
||||
return (
|
||||
|
||||
+22
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
+17
@@ -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)',
|
||||
});
|
||||
+2
-2
@@ -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,
|
||||
+2
-2
@@ -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,
|
||||
+2
-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,
|
||||
-40
@@ -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',
|
||||
|
||||
+6
-7
@@ -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',
|
||||
|
||||
+2
-2
@@ -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);
|
||||
});
|
||||
|
||||
+7
-5
@@ -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',
|
||||
|
||||
+3
-3
@@ -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
|
||||
+8
-7
@@ -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',
|
||||
|
||||
+1
-1
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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,
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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],
|
||||
},
|
||||
|
||||
+84
@@ -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();
|
||||
});
|
||||
});
|
||||
+100
-396
@@ -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 };
|
||||
};
|
||||
|
||||
+84
@@ -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;
|
||||
};
|
||||
-115
@@ -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>
|
||||
|
||||
+10
-8
@@ -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}
|
||||
});
|
||||
`;
|
||||
};
|
||||
+5
-5
@@ -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: [
|
||||
Reference in New Issue
Block a user