Improve application ast (#17016)

# Summary

- Introduces a new, flexible folder structure for Twenty SDK
applications using file suffix-based entity detection
- Adds defineApp, defineFunction, defineObject, and defineRole helper
functions with built-in validation
- Refactors manifest loading to use jiti runtime evaluation for
TypeScript config files
- Separates validation logic into dedicated module with comprehensive
error reporting

  # New Application Folder Structure

Applications now use a convention-over-configuration approach where
entities are detected by their file suffix, allowing flexible
organization within the src/app/ folder.

  # Required Structure

    my-app/
    ├── package.json
    ├── yarn.lock
    └── src/
        ├── app/
│ └── application.config.ts # Required - main application configuration
└── utils/ # Optional - handler implementations & utilities

  # Entity Detection by File Suffix

    - *.object.ts - Custom object definitions
    - *.function.ts - Serverless function definitions
    - *.role.ts - Role definitions

  # Supported Folder Organizations

  ## Traditional (by type):
    src/app/
    ├── application.config.ts
    ├── objects/
    │   └── postCard.object.ts
    ├── functions/
    │   └── createPostCard.function.ts
    └── roles/
        └── admin.role.ts

  ## Feature-based:
    src/app/
    ├── application.config.ts
    └── post-card/
        ├── postCard.object.ts
        ├── createPostCard.function.ts
        └── postCardAdmin.role.ts

  ## Flat:
    src/app/
    ├── application.config.ts
    ├── postCard.object.ts
    ├── createPostCard.function.ts
    └── admin.role.ts

  # New Helper Functions

  ## defineApp(config)

    import { defineApp } from 'twenty-sdk';

    export default defineApp({
      universalIdentifier: '4ec0391d-...',
      displayName: 'My App',
      description: 'App description',
      icon: 'IconWorld',
    });

  ## defineObject(config)

    import { defineObject, FieldType } from 'twenty-sdk';

    export default defineObject({
      universalIdentifier: '54b589ca-...',
      nameSingular: 'postCard',
      namePlural: 'postCards',
      labelSingular: 'Post Card',
      labelPlural: 'Post Cards',
      icon: 'IconMail',
      fields: [
        {
          universalIdentifier: '58a0a314-...',
          type: FieldType.TEXT,
          name: 'content',
          label: 'Content',
        },
      ],
    });

  ## defineFunction(config)

    import { defineFunction } from 'twenty-sdk';
    import { myHandler } from '../utils/my-handler';

    export default defineFunction({
      universalIdentifier: 'e56d363b-...',
      name: 'My Function',
      handler: myHandler,
      triggers: [
        {
          universalIdentifier: 'c9f84c8d-...',
          type: 'route',
          path: '/my-route',
          httpMethod: 'POST',
        },
      ],
    });

  ## defineRole(config)

    import { defineRole, PermissionFlag } from 'twenty-sdk';

    export default defineRole({
      universalIdentifier: 'b648f87b-...',
      label: 'App User',
      objectPermissions: [
        {
          objectNameSingular: 'postCard',
          canReadObjectRecords: true,
        },
      ],
      permissionFlags: [PermissionFlag.UPLOAD_FILE],
    });

  # Test plan

    - Verify npx twenty app sync works with new folder structure
    - Verify npx twenty app dev works with new folder structure
    - Verify validation errors display correctly for invalid configs
- Verify all three folder organization styles work (traditional,
feature-based, flat)
    - Run existing E2E tests to ensure backward compatibility
This commit is contained in:
martmull
2026-01-09 13:06:30 +00:00
committed by GitHub
parent defc988d7f
commit 40eef5c464
49 changed files with 3106 additions and 1288 deletions
@@ -1,34 +1,65 @@
import { getFunctionBaseFile } from '@/cli/utils/get-function-base-file';
describe('getFunctionBaseFile', () => {
it('should render proper file', () => {
expect(
getFunctionBaseFile({
name: 'serverless-function-name',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
}),
).toBe(`import { type FunctionConfig } from 'twenty-sdk';
it('should render proper file using defineFunction', () => {
const result = getFunctionBaseFile({
name: 'my-function',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
});
export const main = async (params: {
a: string;
b: number;
}): Promise<{ message: string }> => {
const { a, b } = params;
// Verify it uses defineFunction
expect(result).toContain("import { defineFunction } from 'twenty-sdk'");
expect(result).toContain('export default defineFunction({');
// Rename the parameters and code below with your own logic
// This is just an example
const message = \`Hello, input: \${a} and \${b}\`;
// Verify function properties
expect(result).toContain(
"universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4'",
);
expect(result).toContain("name: 'my-function'");
expect(result).toContain('timeoutSeconds: 5');
expect(result).toContain('handler,');
expect(result).toContain('triggers: [');
return { message };
};
// Verify handler is exported
expect(result).toContain('export const handler = async');
export const config: FunctionConfig = {
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
name: 'serverless-function-name',
timeoutSeconds: 5,
triggers: [],
};
// Verify description is included
expect(result).toContain(
"description: 'Add a description for your function'",
);
});
`);
it('should generate unique UUID when not provided', () => {
const result = getFunctionBaseFile({
name: 'auto-uuid-function',
});
// Verify it has a universalIdentifier (UUID format)
expect(result).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should use kebab-case for function name', () => {
const result = getFunctionBaseFile({
name: 'my-awesome-function',
});
expect(result).toContain("name: 'my-awesome-function'");
expect(result).toContain("path: '/my-awesome-function'");
});
it('should include trigger examples as comments', () => {
const result = getFunctionBaseFile({
name: 'example-function',
});
// Verify trigger examples are included as comments
expect(result).toContain("type: 'route'");
expect(result).toContain("type: 'cron'");
expect(result).toContain("type: 'databaseEvent'");
expect(result).toContain("httpMethod: 'POST'");
expect(result).toContain("pattern: '0 0 * * *'");
expect(result).toContain("eventName: 'objectName.created'");
});
});
@@ -0,0 +1,64 @@
import { getNewObjectFileContent } from '@/cli/utils/get-new-object-file-content';
describe('getNewObjectFileContent', () => {
it('should return proper object file using defineObject', () => {
const result = getNewObjectFileContent({
data: {
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
},
name: 'company',
});
// Verify it uses defineObject
expect(result).toContain("import { defineObject } from 'twenty-sdk'");
expect(result).toContain('export default defineObject({');
// Verify object properties
expect(result).toContain("nameSingular: 'company'");
expect(result).toContain("namePlural: 'companies'");
expect(result).toContain("labelSingular: 'Company'");
expect(result).toContain("labelPlural: 'Companies'");
expect(result).toContain("icon: 'IconBox'");
expect(result).toContain('fields: [');
// Verify it has a universalIdentifier (UUID format)
expect(result).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should generate unique UUIDs for each object', () => {
const result1 = getNewObjectFileContent({
data: {
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
},
name: 'company',
});
const result2 = getNewObjectFileContent({
data: {
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
},
name: 'person',
});
// Extract UUIDs
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const uuid1 = result1.match(uuidRegex)?.[1];
const uuid2 = result2.match(uuidRegex)?.[1];
expect(uuid1).toBeDefined();
expect(uuid2).toBeDefined();
expect(uuid1).not.toBe(uuid2);
});
});
@@ -1,30 +0,0 @@
import { getObjectDecoratedClass } from '@/cli/utils/get-object-decorated-class';
describe('getObjectDecoratedClass', () => {
it('should return proper object file', () => {
expect(
getObjectDecoratedClass({
data: {
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',
nameSingular: 'name',
namePlural: 'names',
labelSingular: 'Name',
labelPlural: 'Names',
},
name: 'MyNewObject',
}),
).toBe(
`import { Object } from 'twenty-sdk';
@Object({
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',
nameSingular: 'name',
namePlural: 'names',
labelSingular: 'Name',
labelPlural: 'Names',
})
export class MyNewObject {}
`,
);
});
});
@@ -0,0 +1,70 @@
import { getRoleBaseFile } from '@/cli/utils/get-role-base-file';
describe('getRoleBaseFile', () => {
it('should render proper file using defineRole', () => {
const result = getRoleBaseFile({
name: 'my-role',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
});
// Verify it uses defineRole
expect(result).toContain("import { defineRole } from 'twenty-sdk'");
expect(result).toContain('export default defineRole({');
// Verify role properties
expect(result).toContain(
"universalIdentifier: MY_ROLE_ROLE_UNIVERSAL_IDENTIFIER",
);
expect(result).toContain(
"'71e45a58-41da-4ae4-8b73-a543c0a9d3d4'",
);
expect(result).toContain("label: 'my-role'");
expect(result).toContain("description: 'Add a description for your role'");
// Verify permission defaults
expect(result).toContain('canReadAllObjectRecords: true');
expect(result).toContain('canUpdateAllObjectRecords: true');
expect(result).toContain('canSoftDeleteAllObjectRecords: true');
expect(result).toContain('canDestroyAllObjectRecords: false');
});
it('should generate unique UUID when not provided', () => {
const result = getRoleBaseFile({
name: 'auto-uuid-role',
});
// Verify it has a universalIdentifier (UUID format)
expect(result).toMatch(
/'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should use kebab-case for role name in label', () => {
const result = getRoleBaseFile({
name: 'my-awesome-role',
});
expect(result).toContain("label: 'my-awesome-role'");
});
it('should export universal identifier constant with correct naming', () => {
const result = getRoleBaseFile({
name: 'admin-access',
});
expect(result).toContain('export const ADMIN_ACCESS_ROLE_UNIVERSAL_IDENTIFIER');
expect(result).toContain(
'universalIdentifier: ADMIN_ACCESS_ROLE_UNIVERSAL_IDENTIFIER',
);
});
it('should handle names with numbers', () => {
const result = getRoleBaseFile({
name: 'role-v2',
});
// kebab-case separates numbers with underscore when converted to constant
expect(result).toContain('export const ROLE_V_2_ROLE_UNIVERSAL_IDENTIFIER');
expect(result).toContain("label: 'role-v2'");
});
});
@@ -1,610 +1,184 @@
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { loadManifest } from '@/cli/utils/load-manifest';
import { type ApplicationConfig } from '@/application';
import { join } from 'path';
import {
loadManifest,
type LoadManifestResult,
} from '@/cli/utils/load-manifest';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
const write = (root: string, file: string, content: string) => {
const abs = join(root, file);
ensureDirSync(resolve(abs, '..'));
writeFileSync(abs, content, 'utf8');
};
const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app');
const tsLibMock = `declare module 'tslib' {
export const __decorate: any;
export const __metadata: any;
export const __param: any;
export const __awaiter: any;
export const __read: any;
export const __spread: any;
export const __spreadArray: any;
export const __assign: any;
}`;
describe('loadManifest with test-app', () => {
let manifest: LoadManifestResult['manifest'];
let packageJson: LoadManifestResult['packageJson'];
let yarnLock: LoadManifestResult['yarnLock'];
let warnings: LoadManifestResult['warnings'];
let shouldGenerate: LoadManifestResult['shouldGenerate'];
const twentySdkTypesMock = `
declare module 'twenty-sdk' {
export type SyncableEntityOptions = { universalIdentifier: string };
beforeAll(async () => {
const result = await loadManifest(TEST_APP_PATH);
type ApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: boolean;
};
export type ApplicationConfig = SyncableEntityOptions & {
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
functionRoleUniversalIdentifier?: string;
};
export type RoleConfig = SyncableEntityOptions & {
label: string;
description?: string;
icon?: string;
canReadAllObjectRecords?: boolean;
canUpdateAllObjectRecords?: boolean;
canSoftDeleteAllObjectRecords?: boolean;
canDestroyAllObjectRecords?: boolean;
objectPermissions?: any[];
fieldPermissions?: any[];
permissionFlags?: any[];
};
export enum PermissionFlag {
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
WORKSPACE = 'WORKSPACE',
WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS',
ROLES = 'ROLES',
DATA_MODEL = 'DATA_MODEL',
SECURITY = 'SECURITY',
WORKFLOWS = 'WORKFLOWS',
IMPERSONATE = 'IMPERSONATE',
SSO_BYPASS = 'SSO_BYPASS',
APPLICATIONS = 'APPLICATIONS',
LAYOUTS = 'LAYOUTS',
BILLING = 'BILLING',
AI_SETTINGS = 'AI_SETTINGS',
// Tool permissions
AI = 'AI',
VIEWS = 'VIEWS',
UPLOAD_FILE = 'UPLOAD_FILE',
DOWNLOAD_FILE = 'DOWNLOAD_FILE',
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
HTTP_REQUEST_TOOL = 'HTTP_REQUEST_TOOL',
IMPORT_CSV = 'IMPORT_CSV',
EXPORT_CSV = 'EXPORT_CSV',
CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS',
PROFILE_INFORMATION = 'PROFILE_INFORMATION',
}
type RouteTrigger = {
type: 'route';
path: string;
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
isAuthRequired: boolean;
};
type CronTrigger = {
type: 'cron';
pattern: string;
};
type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
};
type ServerlessFunctionTrigger = SyncableEntityOptions &
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
export type FunctionConfig = SyncableEntityOptions & {
name?: string;
description?: string;
timeoutSeconds?: number;
triggers?: ServerlessFunctionTrigger[];
};
type ObjectMetadataOptions = SyncableEntityOptions & {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
};
export const Object = (_: ObjectMetadataOptions): ClassDecorator => {
return () => {};
};
export class BaseObjectMetadata {}
export enum FieldType {
TEXT = 'TEXT',
FULL_NAME = 'FULL_NAME',
ADDRESS = 'ADDRESS',
SELECT = 'SELECT',
DATE_TIME = 'DATE_TIME',
}
export const Field: (_: any) => PropertyDecorator;
}
`;
const defaultRoleMock = `
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
export const functionRole: RoleConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'hello-world-role',
description: 'A role to define app permissions',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
objectPermissions: [
{
objectNameSingular: 'postCard',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectNameSingular: 'postCard',
fieldName: 'content',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
}
`;
const serverlessFunctionMock = `
import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: any): Promise<any> => {
return {};
}
export const config: FunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'hello',
timeoutSeconds: 2,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
},
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent',
eventName: 'person.created'
}
]
};`;
const objectMock = `import {
Object,
Field,
FieldType
} from 'twenty-sdk';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
@Object({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: ' A post card object',
icon: 'IconMail',
})
export class PostCard {
@Field({
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
})
content: string;
@Field({
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
label: 'Recipient name',
})
recipientName: string;
@Field({
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
label: 'Recipient address',
})
recipientAddress: string;
@Field({
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
label: 'Status',
defaultValue: \`'\${PostCardStatus.DRAFT}'\`,
options: [
{
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
})
status: 'draft' | 'sent' | 'delivered' | 'returned';
@Field({
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
label: 'Delivered at',
isNullable: true,
defaultValue: null,
})
deliveredAt?: Date;
}
`;
const packageJsonMock = {
name: 'my-app',
version: '0.0.1',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'create-entity': 'twenty app add',
dev: 'twenty app dev',
generate: 'twenty app generate',
sync: 'twenty app sync',
uninstall: 'twenty app uninstall',
auth: 'twenty auth login',
},
dependencies: {
'twenty-sdk': '0.1.0',
},
devDependencies: {
'@types/node': '^24.7.2',
typescript: '^5.9.3',
},
};
const tsConfigJsonMock = {
compileOnSave: false,
compilerOptions: {
sourceMap: true,
declaration: true,
outDir: './dist',
rootDir: '.',
moduleResolution: 'node',
allowSyntheticDefaultImports: true,
emitDecoratorMetadata: true,
experimentalDecorators: true,
importHelpers: true,
allowUnreachableCode: false,
strictNullChecks: true,
alwaysStrict: true,
noImplicitAny: true,
strictBindCallApply: false,
target: 'es2018',
module: 'esnext',
lib: ['es2020', 'dom'],
skipLibCheck: true,
skipDefaultLibCheck: true,
resolveJsonModule: true,
},
exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts'],
};
const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
const applicationMockConfig: ApplicationConfig = {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
icon: 'IconWorld',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6',
description: 'Twenty API Key',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'aa7210a6-75b0-46ca-bcbe-09a5b42a76ec',
description: 'Twenty API Url',
isSecret: false,
},
},
functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = ${JSON.stringify(applicationMockConfig)};
export default config;
`;
describe('loadManifest (integration)', () => {
const appDirectory = join(tmpdir(), 'test-app');
beforeEach(async () => {
await ensureDirSync(appDirectory);
write(appDirectory, 'yarn.lock', yarnLockMock);
write(appDirectory, 'application.config.ts', applicationConfigMock);
write(
appDirectory,
'tsconfig.json',
JSON.stringify(tsConfigJsonMock, null, 2),
);
write(
appDirectory,
'package.json',
JSON.stringify(packageJsonMock, null, 2),
);
write(appDirectory, 'src/Account.ts', objectMock);
write(appDirectory, 'src/hello.ts', serverlessFunctionMock);
write(appDirectory, 'src/defaultRole.ts', defaultRoleMock);
write(
appDirectory,
'src/types/twenty-sdk-application.d.ts',
twentySdkTypesMock,
);
write(
appDirectory,
'src/types/tslib.d.ts',
// minimal + future-proof
tsLibMock,
);
manifest = result.manifest;
packageJson = result.packageJson;
yarnLock = result.yarnLock;
warnings = result.warnings;
shouldGenerate = result.shouldGenerate;
});
afterEach(() => {
removeSync(appDirectory);
});
it('builds a full manifest for a valid workspace', async () => {
const { packageJson, yarnLock, manifest } =
await loadManifest(appDirectory);
expect(packageJson.name).toBe('my-app');
it('should load manifest from test-app directory', async () => {
// Check package.json loaded correctly
expect(packageJson.name).toBe('test-app');
expect(packageJson.version).toBe('0.0.1');
expect(packageJson.license).toBe('MIT');
expect(yarnLock).toContain(
'# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.',
// Check yarn.lock exists (can be empty)
expect(yarnLock).toBeDefined();
// Check no warnings
expect(warnings).toEqual([]);
// Check application config
expect(manifest.application).toBeDefined();
expect(manifest.application.universalIdentifier).toBe(
'4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
);
expect(manifest.application.displayName).toBe('Hello World');
expect(manifest.application.description).toBe('A simple hello world app');
expect(manifest.application.icon).toBe('IconWorld');
// application
expect(manifest.application).toEqual(applicationMockConfig);
expect(manifest.objects).toHaveLength(1);
expect(manifest.objects.length).toBe(1);
const postCard = manifest.objects[0];
expect(postCard.universalIdentifier).toBe(
'54b589ca-eeed-4950-a176-358418b85c05',
);
expect(postCard.nameSingular).toBe('postCard');
expect(postCard.namePlural).toBe('postCards');
expect(postCard.labelSingular).toBe('Post card');
expect(postCard.labelPlural).toBe('Post cards');
expect(postCard.icon).toBe('IconMail');
for (const object of manifest.objects) {
const { universalIdentifier: _, fields, ...otherInfo } = object;
expect(otherInfo).toEqual({
description: ' A post card object',
icon: 'IconMail',
labelPlural: 'Post cards',
labelSingular: 'Post card',
namePlural: 'postCards',
nameSingular: 'postCard',
});
// Check fields
expect(postCard.fields).toHaveLength(5);
expect(Array.isArray(fields)).toBe(true);
const contentField = postCard.fields?.find((f) => f.name === 'content');
expect(contentField).toBeDefined();
expect(contentField?.universalIdentifier).toBe(
'58a0a314-d7ea-4865-9850-7fb84e72f30b',
);
expect(contentField?.type).toBe('TEXT');
expect(contentField?.label).toBe('Content');
expect(fields).toEqual([
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: 'TEXT',
label: 'Content',
description: "Postcard's content",
name: 'content',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: 'FULL_NAME',
label: 'Recipient name',
name: 'recipientName',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: 'ADDRESS',
label: 'Recipient address',
name: 'recipientAddress',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: 'SELECT',
label: 'Status',
defaultValue: "'DRAFT'",
options: [
{ value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
{ value: 'SENT', label: 'Sent', position: 1, color: 'orange' },
{
value: 'DELIVERED',
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: 'RETURNED',
label: 'Returned',
position: 3,
color: 'orange',
},
],
name: 'status',
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: 'DATE_TIME',
label: 'Delivered at',
isNullable: true,
defaultValue: null,
name: 'deliveredAt',
},
]);
}
const statusField = postCard.fields?.find((f) => f.name === 'status');
expect(statusField).toBeDefined();
expect(statusField?.type).toBe('SELECT');
expect(statusField?.options).toHaveLength(4);
// serverless functions
for (const serverlessFunction of manifest.serverlessFunctions) {
const {
universalIdentifier: _,
handlerPath: __,
triggers,
...otherInfo
} = serverlessFunction;
expect(manifest.serverlessFunctions).toHaveLength(2);
expect(otherInfo).toEqual({
handlerName: 'main',
name: 'hello',
timeoutSeconds: 2,
});
const testFunction = manifest.serverlessFunctions[1];
expect(testFunction.universalIdentifier).toBe(
'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
);
expect(testFunction.name).toBe('test-function');
expect(testFunction.timeoutSeconds).toBe(2);
expect(testFunction.handlerName).toBe('handler');
expect(testFunction.handlerPath).toBe('src/app/test-function.function.ts');
for (const trigger of triggers) {
const { universalIdentifier: _, ...otherInfo } = trigger;
switch (trigger.type) {
case 'route':
expect(otherInfo).toEqual({
isAuthRequired: false,
httpMethod: 'GET',
path: '/post-card/create',
type: 'route',
});
break;
case 'cron':
expect(otherInfo).toEqual({
pattern: '0 0 1 1 *',
type: 'cron',
});
break;
case 'databaseEvent':
expect(otherInfo).toEqual({
eventName: 'person.created',
type: 'databaseEvent',
});
break;
}
}
}
// Check triggers
expect(testFunction.triggers).toHaveLength(3);
const routeTrigger = testFunction.triggers.find((t) => t.type === 'route');
expect(routeTrigger).toBeDefined();
expect(routeTrigger?.universalIdentifier).toBe(
'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
);
expect(routeTrigger?.path).toBe('/post-card/create');
expect(routeTrigger?.httpMethod).toBe('GET');
const cronTrigger = testFunction.triggers.find((t) => t.type === 'cron');
expect(cronTrigger).toBeDefined();
expect(cronTrigger?.pattern).toBe('0 0 1 1 *');
const dbEventTrigger = testFunction.triggers.find(
(t) => t.type === 'databaseEvent',
);
expect(dbEventTrigger).toBeDefined();
expect(dbEventTrigger?.eventName).toBe('person.created');
// Second function
const testFunction2 = manifest.serverlessFunctions[0];
expect(testFunction2.universalIdentifier).toBe(
'eb3ffc98-88ec-45d4-9b4a-56833b219ccb',
);
expect(testFunction2.name).toBe('test-function-2');
expect(testFunction2.timeoutSeconds).toBe(2);
expect(testFunction2.handlerName).toBe('testFunction2');
expect(testFunction2.handlerPath).toBe('src/utils/test-function-2.util.ts');
//Role
expect(manifest.roles).toHaveLength(1);
for (const role of manifest.roles ?? []) {
const {
universalIdentifier: _,
objectPermissions,
fieldPermissions,
permissionFlags,
...otherInfo
} = role;
expect(otherInfo).toEqual({
label: 'hello-world-role',
description: 'A role to define app permissions',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
});
const role = manifest.roles![0];
expect(role.universalIdentifier).toBe(
'b648f87b-1d26-4961-b974-0908fd991061',
);
expect(role.label).toBe('Default function role');
expect(role.description).toBe('Default role for function Twenty client');
expect(role.canReadAllObjectRecords).toBe(false);
expect(role.canUpdateAllObjectRecords).toBe(false);
expect(Array.isArray(objectPermissions)).toBe(true);
expect(Array.isArray(fieldPermissions)).toBe(true);
expect(Array.isArray(permissionFlags)).toBe(true);
}
});
// Check object permissions
expect(role.objectPermissions).toHaveLength(1);
expect(role.objectPermissions![0].objectNameSingular).toBe('postCard');
expect(role.objectPermissions![0].canReadObjectRecords).toBe(true);
expect(role.objectPermissions![0].canUpdateObjectRecords).toBe(true);
it('should not define serverless for util file', async () => {
write(
appDirectory,
'src/utils/format.ts',
`
export const format = async (params: any): Promise<any> => {
return {};
}
`,
// Check field permissions
expect(role.fieldPermissions).toHaveLength(1);
expect(role.fieldPermissions![0].objectNameSingular).toBe('postCard');
expect(role.fieldPermissions![0].fieldName).toBe('content');
expect(role.fieldPermissions![0].canReadFieldValue).toBe(false);
expect(manifest.sources).toBeDefined();
expect(manifest.sources['src']).toBeDefined();
// Check that the source files are loaded
const srcSources = manifest.sources['src'] as Record<string, unknown>;
const appSources = srcSources['app'] as Record<string, string>;
expect(appSources['application.config.ts']).toBeDefined();
expect(appSources['postCard.object.ts']).toBeDefined();
expect(appSources['test-function.function.ts']).toBeDefined();
expect(appSources['default-function.role.ts']).toBeDefined();
// Verify source content contains expected code
expect(appSources['application.config.ts']).toContain('defineApp');
expect(appSources['postCard.object.ts']).toContain('defineObject');
expect(appSources['test-function.function.ts']).toContain('defineFunction');
expect(appSources['default-function.role.ts']).toContain('defineRole');
expect(shouldGenerate).toBe(false);
const expectedRoleId = DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER;
expect(manifest.application.functionRoleUniversalIdentifier).toBe(
expectedRoleId,
);
const { manifest } = await loadManifest(appDirectory);
expect(manifest.serverlessFunctions.length).toBe(1);
});
const linkedRole = manifest.roles?.find(
(r) => r.universalIdentifier === expectedRoleId,
);
expect(linkedRole).toBeDefined();
it('manifest should contains typescript sources', async () => {
const { manifest } = await loadManifest(appDirectory);
// the method is already exercised in loadManifest; just assert again:
expect(Object.keys(manifest.sources)).toEqual([
'application.config.ts',
'src',
]);
expect(Object.keys(manifest.sources['src'])).toEqual([
'Account.ts',
'defaultRole.ts',
'hello.ts',
]);
});
expect(manifest.application.applicationVariables).toBeDefined();
it('manifest should contains typescript sources', async () => {
const { shouldGenerate } = await loadManifest(appDirectory);
expect(shouldGenerate).toBe(false);
const defaultRecipient =
manifest.application.applicationVariables?.DEFAULT_RECIPIENT_NAME;
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.universalIdentifier).toBe(
'19e94e59-d4fe-4251-8981-b96d0a9f74de',
);
expect(defaultRecipient?.description).toBe(
'Default recipient name for postcards',
);
expect(defaultRecipient?.value).toBe('Alex Karp');
expect(defaultRecipient?.isSecret).toBe(false);
});
});
@@ -0,0 +1,234 @@
import { createJiti } from 'jiti';
import * as fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
// Create a jiti instance for loading TypeScript config files
const createConfigLoader = () => {
return createJiti(fileURLToPath(import.meta.url), {
moduleCache: false, // Don't cache during dev for hot reload
fsCache: false,
interopDefault: true,
});
};
/**
* Find the first valid config export from a module.
* Priority:
* 1. default export
* 2. first named export that is a plain object (not a function, class, or primitive)
*/
const findConfigExport = <T>(
mod: Record<string, unknown>,
validator?: (value: unknown) => boolean,
): T | undefined => {
// Priority 1: default export
if (mod.default !== undefined) {
if (!validator || validator(mod.default)) {
return mod.default as T;
}
}
// Priority 2: first named export that passes validation (or is a plain object)
for (const [key, value] of Object.entries(mod)) {
if (key === 'default') continue;
if (value === undefined || value === null) continue;
// Skip functions, classes, and primitives - we want config objects
if (typeof value !== 'object') continue;
// Skip arrays
if (Array.isArray(value)) continue;
if (!validator || validator(value)) {
return value as T;
}
}
return undefined;
};
/**
* Load a TypeScript config file using jiti runtime evaluation.
* This allows importing constants and other modules in config files.
*
* Supports multiple export patterns:
* - `export default { ... }`
* - `export const anyName = { ... }` (any named export that is a plain object)
*
* @example
* ```typescript
* const config = await loadConfig<AppDefinition>('/path/to/src/app/application.config.ts');
* ```
*/
export const loadConfig = async <T>(filepath: string): Promise<T> => {
const jiti = createConfigLoader();
try {
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
const config = findConfigExport<T>(mod);
if (!config) {
throw new Error(
`Config file ${filepath} must export a config object (default export or any named object export)`,
);
}
return config;
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to load config from ${filepath}: ${error.message}`,
);
}
throw error;
}
};
/**
* Escape special regex characters in a string.
*/
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
/**
* Extract the import path for a given identifier from source code.
* Returns null if the identifier is not imported (i.e., defined locally).
*/
const extractImportPath = (
source: string,
identifier: string,
filepath: string,
appPath: string,
): string | null => {
// Escape special regex characters in the identifier (e.g., $ in function names like $handler)
const escapedIdentifier = escapeRegExp(identifier);
// Match: import { identifier } from 'path'
// Match: import { original as identifier } from 'path'
// Match: import identifier from 'path' (default import)
const patterns = [
// Named import: import { foo } from 'path' or import { foo, bar } from 'path'
new RegExp(
`import\\s*\\{[^}]*\\b${escapedIdentifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
// Aliased import: import { original as foo } from 'path'
new RegExp(
`import\\s*\\{[^}]*\\w+\\s+as\\s+${escapedIdentifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
// Default import: import foo from 'path'
new RegExp(`import\\s+${escapedIdentifier}\\s+from\\s*['"]([^'"]+)['"]`),
];
for (const pattern of patterns) {
const match = source.match(pattern);
if (match) {
const importPath = match[1];
// Resolve relative to the function file, then make relative to appPath
const fileDir = path.dirname(filepath);
const absolutePath = path.resolve(fileDir, importPath);
const relativePath = path.relative(appPath, absolutePath);
// Add .ts extension if not present
const resultPath = relativePath.endsWith('.ts')
? relativePath
: `${relativePath}.ts`;
return resultPath.replace(/\\/g, '/');
}
}
// Handler is defined locally, not imported
return null;
};
/**
* Load a function module and extract handler info from config.handler property.
*
* The handler can be either:
* 1. Imported from another file:
* ```typescript
* import { myHandler } from '../src/handlers/my-handler';
* export const config = { handler: myHandler, ... };
* ```
*
* 2. Defined locally in the same file:
* ```typescript
* export const myHandler = async () => { ... };
* export const config = { handler: myHandler, ... };
* ```
*
* @example
* ```typescript
* const { config, handlerName, handlerPath } = await loadFunctionModule(
* '/path/to/src/app/functions/my-function.function.ts',
* '/path/to/app'
* );
* ```
*/
export const loadFunctionModule = async (
filepath: string,
appPath: string,
): Promise<{
config: unknown;
handlerName: string;
handlerPath: string;
}> => {
const jiti = createConfigLoader();
try {
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
// Find config with a handler property
const hasHandler = (value: unknown): boolean => {
return (
typeof value === 'object' &&
value !== null &&
'handler' in value &&
typeof (value as { handler: unknown }).handler === 'function'
);
};
const config = findConfigExport<{ handler: Function }>(mod, hasHandler);
if (!config) {
throw new Error(
`Function file ${filepath} must export a config object with a "handler" property`,
);
}
// Get handler name from the function's name property
const handlerName = config.handler.name;
if (!handlerName) {
throw new Error(
`Handler function in ${filepath} must be a named function`,
);
}
// Parse source to find where the handler is imported from
const source = await fs.readFile(filepath, 'utf8');
const importPath = extractImportPath(
source,
handlerName,
filepath,
appPath,
);
// If handler is imported, use the import path; otherwise use the function file itself
const handlerPath =
importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/');
return { config, handlerName, handlerPath };
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to load function module from ${filepath}: ${error.message}`,
);
}
throw error;
}
};
@@ -0,0 +1,14 @@
import chalk from 'chalk';
import { type ApplicationManifest } from 'twenty-shared/application';
export const displayEntitySummary = (manifest: ApplicationManifest): void => {
const appName = manifest.application.displayName ?? 'Application';
console.log(chalk.green(` ✓ Loaded "${appName}"`));
console.log(chalk.green(` ✓ Found ${manifest.objects.length} object(s)`));
console.log(
chalk.green(` ✓ Found ${manifest.serverlessFunctions.length} function(s)`),
);
console.log(
chalk.green(` ✓ Found ${manifest.roles?.length ?? 'no'} role(s)`),
);
};
@@ -0,0 +1,10 @@
import { type ManifestValidationError } from '@/cli/utils/validate-manifest';
import chalk from 'chalk';
export const displayErrors = (error: ManifestValidationError): void => {
console.log(chalk.red('\n ✗ Manifest validation failed:\n'));
for (const err of error.errors) {
console.log(chalk.red(`${err.path}: ${err.message}`));
}
console.log('');
};
@@ -0,0 +1,14 @@
import type { ValidationWarning } from '@/cli/utils/validate-manifest';
import chalk from 'chalk';
export const displayWarnings = (warnings?: ValidationWarning[]): void => {
if (!warnings || warnings.length === 0) {
return;
}
console.log('');
for (const warning of warnings) {
const path = warning.path ? `${warning.path}: ` : '';
console.log(chalk.yellow(`${path}${warning.message}`));
}
};
@@ -9,28 +9,51 @@ export const getFunctionBaseFile = ({
universalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
const triggerUniversalIdentifier = v4();
return `import { type FunctionConfig } from 'twenty-sdk';
return `import { defineFunction } from 'twenty-sdk';
export const main = async (params: {
// Handler function - rename and implement your logic
export const handler = async (params: {
a: string;
b: number;
}): Promise<{ message: string }> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// This is just an example
const message = \`Hello, input: $\{a} and $\{b}\`;
// Replace with your own logic
const message = \`Hello, input: \${a} and \${b}\`;
return { message };
};
export const config: FunctionConfig = {
export default defineFunction({
universalIdentifier: '${universalIdentifier}',
name: '${kebabCaseName}',
description: 'Add a description for your function',
timeoutSeconds: 5,
triggers: [],
};
handler,
triggers: [
// Add your triggers here
// Route trigger example:
// {
// universalIdentifier: '${triggerUniversalIdentifier}',
// type: 'route',
// path: '/${kebabCaseName}',
// httpMethod: 'POST',
// },
// Cron trigger example:
// {
// universalIdentifier: '...',
// type: 'cron',
// pattern: '0 0 * * *', // Daily at midnight
// },
// Database event trigger example:
// {
// universalIdentifier: '...',
// type: 'databaseEvent',
// eventName: 'objectName.created',
// },
],
});
`;
};
@@ -0,0 +1,37 @@
import { v4 } from 'uuid';
export const getNewObjectFileContent = ({
data,
}: {
data: {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
};
name: string;
}) => {
const universalIdentifier = v4();
return `import { defineObject } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '${universalIdentifier}',
nameSingular: '${data.nameSingular}',
namePlural: '${data.namePlural}',
labelSingular: '${data.labelSingular}',
labelPlural: '${data.labelPlural}',
icon: 'IconBox',
fields: [
// Add your fields here using defineField helper
// Example:
// {
// universalIdentifier: '...',
// type: FieldMetadataType.TEXT,
// name: 'description',
// label: 'Description',
// },
],
});
`;
};
@@ -1,25 +0,0 @@
import camelcase from 'lodash.camelcase';
export const getObjectDecoratedClass = ({
data,
name,
}: {
data: object;
name: string;
}) => {
const decoratorOptions = Object.entries(data)
.map(([key, value]) => ` ${key}: '${value}',`)
.join('\n');
const camelCaseName = camelcase(name);
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { Object } from 'twenty-sdk';
@Object({
${decoratorOptions}
})
export class ${className} {}
`;
};
@@ -0,0 +1,28 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getRoleBaseFile = ({
name,
universalIdentifier = v4(),
}: {
name: string;
universalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
return `import { defineRole } from 'twenty-sdk';
export const ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_ROLE_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineRole({
universalIdentifier: ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_ROLE_UNIVERSAL_IDENTIFIER,
label: '${name}',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
`;
};
+239 -506
View File
@@ -1,533 +1,249 @@
import * as fs from 'fs-extra';
import { posix, relative, sep } from 'path';
import {
type Decorator,
type Expression,
type FunctionDeclaration,
type Modifier,
type Node,
type Program,
type SourceFile,
SyntaxKind,
type VariableDeclaration,
forEachChild,
getDecorators,
isArrayLiteralExpression,
isArrowFunction,
isCallExpression,
isClassDeclaration,
isComputedPropertyName,
isExportAssignment,
isFunctionExpression,
isIdentifier,
isImportDeclaration,
isNoSubstitutionTemplateLiteral,
isNumericLiteral,
isObjectLiteralExpression,
isPropertyAccessExpression,
isPropertyAssignment,
isPropertyDeclaration,
isShorthandPropertyAssignment,
isStringLiteralLike,
isTemplateExpression,
isVariableStatement,
} from 'typescript';
import { GENERATED_FOLDER_NAME } from '@/cli/services/generate.service';
import { type Sources } from 'twenty-shared/types';
import { glob } from 'fast-glob';
import path, { posix, relative, sep } from 'path';
import {
type Application,
type PackageJson,
type ApplicationManifest,
type ServerlessFunctionManifest,
type ObjectManifest,
type PackageJson,
type RoleManifest,
type ServerlessFunctionManifest,
} from 'twenty-shared/application';
import { findPathFile } from '@/cli/utils/find-path-file';
import { getTsProgramAndDiagnostics } from '@/cli/utils/get-ts-program-and-diagnostics';
import { parseJsoncFile, parseTextFile } from '@/cli/utils/jsonc-parser';
import { formatAndWarnTsDiagnostics } from '@/cli/utils/format-and-warn-ts-diagnostics';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
const expr = node.expression;
if (isCallExpression(expr)) {
if (isIdentifier(expr.expression)) return expr.expression.text === name;
if (isPropertyAccessExpression(expr.expression))
return expr.expression.name.text === name;
}
return false;
};
const exprToValue = (expr: Expression): JSONValue => {
if (isStringLiteralLike(expr)) return expr.text;
if (isNumericLiteral(expr)) return Number(expr.text);
if (expr.kind === SyntaxKind.TrueKeyword) return true;
if (expr.kind === SyntaxKind.FalseKeyword) return false;
if (expr.kind === SyntaxKind.NullKeyword) return null;
if (isPropertyAccessExpression(expr)) {
if (isIdentifier(expr.expression) && isIdentifier(expr.name)) {
return expr.name.text;
}
return String(expr.getText());
}
if (isNoSubstitutionTemplateLiteral(expr)) {
return expr.text;
}
if (isTemplateExpression(expr)) {
let out = expr.head.text;
for (const span of expr.templateSpans) {
const v = exprToValue(span.expression);
out += String(v) + span.literal.text;
}
return out;
}
if (isArrayLiteralExpression(expr)) {
return expr.elements.map((e) =>
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
);
}
if (isObjectLiteralExpression(expr)) {
const obj: Record<string, JSONValue> = {};
for (const prop of expr.properties) {
if (isPropertyAssignment(prop)) {
const key =
isIdentifier(prop.name) || isStringLiteralLike(prop.name)
? prop.name.text
: isComputedPropertyName(prop.name) &&
isStringLiteralLike(prop.name.expression)
? prop.name.expression.text
: undefined;
if (key) obj[key] = exprToValue(prop.initializer);
} else if (isShorthandPropertyAssignment(prop)) {
// Unsupported without a checker; skip to keep it "light".
// Could resolve via typechecker if needed.
}
// getters/setters/methods are ignored intentionally
}
return obj;
}
// Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback.
// You can throw instead if you prefer to fail fast.
return isIdentifier(expr)
? expr.text
: String((expr as any).getText?.() ?? '');
};
const getFirstArgObject = (dec: Decorator) => {
if (!isCallExpression(dec.expression)) return undefined;
const [firstArg] = dec.expression.arguments;
return firstArg && isObjectLiteralExpression(firstArg)
? (exprToValue(firstArg) as Record<string, JSONValue>)
: undefined;
};
const collectObjects = (program: Program) => {
const manifest: ObjectManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) {
continue;
}
const visit = (node: Node) => {
if (isClassDeclaration(node) && getDecorators(node)?.length) {
const decorators = getDecorators(node);
const objectDec = decorators?.find(
(d) =>
isDecoratorNamed(d, 'ObjectMetadata') ||
isDecoratorNamed(d, 'Object'),
);
if (objectDec) {
const cfg = getFirstArgObject(objectDec);
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
const fields: Array<Record<string, JSONValue>> = [];
for (const member of node.members) {
if (!isPropertyDeclaration(member)) {
continue;
}
const fieldDec = getDecorators(member)?.find(
(d) =>
isDecoratorNamed(d, 'FieldManifest') ||
isDecoratorNamed(d, 'Field'),
);
if (!fieldDec) {
continue;
}
const fieldCfg = getFirstArgObject(fieldDec);
if (!fieldCfg) {
continue;
}
// Try to attach the TypeScript property name as "name"
let name: string | undefined;
if (member.name && isIdentifier(member.name)) {
name = member.name.text;
} else {
// fallback to AST text if not a simple identifier
name = member.name?.getText?.() ?? undefined;
}
fields.push({
...(fieldCfg as any),
...(name ? { name } : {}),
});
}
manifest.push({ ...(cfg as any), fields } as ObjectManifest);
}
}
}
forEachChild(node, visit);
};
visit(sf);
}
return manifest;
};
// Add if you want a small guard for "export" presence on statements
const hasExportModifier = (st: any) =>
st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ??
false;
import { type Sources } from 'twenty-shared/types';
import { type FunctionConfig } from '@/application/functions/function-config';
import { type RoleConfig } from '@/application/role-config';
import { loadConfig, loadFunctionModule } from './config-loader';
import { findPathFile } from './find-path-file';
import { parseJsoncFile, parseTextFile } from './jsonc-parser';
import {
validateManifest,
ManifestValidationError,
type ValidationWarning,
} from './validate-manifest';
/**
* Finds (and validates) the new serverless file shape:
* - exactly 2 exported bindings
* - one must be `config` (typed FunctionConfig)
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
* Validate that the required folder structure exists.
*/
const findHandlerAndConfig = (
sf: SourceFile,
): {
handlerName: ServerlessFunctionManifest['handlerName'];
configObject: Pick<
ServerlessFunctionManifest,
| 'universalIdentifier'
| 'name'
| 'description'
| 'timeoutSeconds'
| 'triggers'
>;
} => {
type Exported = {
name: string;
kind: 'function' | 'const';
init?: Expression;
declNode: Node;
};
const validateFolderStructure = async (appPath: string): Promise<void> => {
const appFolder = path.join(appPath, 'src', 'app');
const exported: Exported[] = [];
// 1) export const X = <arrow|function expr>
for (const st of sf.statements) {
if (!isVariableStatement(st) || !hasExportModifier(st)) continue;
for (const decl of st.declarationList.declarations) {
if (!isIdentifier(decl.name)) continue;
const name = decl.name.text;
const init = decl.initializer ?? undefined;
exported.push({
name,
kind: 'const',
init,
declNode: decl,
});
}
}
// 2) export function X() { ... }
for (const st of sf.statements) {
if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) {
const fd = st as FunctionDeclaration;
if (fd.name && isIdentifier(fd.name)) {
exported.push({
name: fd.name.text,
kind: 'function',
init: undefined,
declNode: fd,
});
}
}
}
// Enforce exactly two exports
const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values());
if (unique.length !== 2) {
if (!(await fs.pathExists(appFolder))) {
throw new Error(
`Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`,
`Missing src/app/ folder in ${appPath}.\n` +
'Create it with: mkdir -p src/app',
);
}
// Find config
const configExport = unique.find((e) => e.name === 'config');
if (!configExport) {
throw new Error(
`Serverless file ${sf.fileName} must export a binding named "config".`,
);
const configFile = path.join(appPath, 'src', 'app', 'application.config.ts');
if (!(await fs.pathExists(configFile))) {
throw new Error('Missing src/app/application.config.ts');
}
// Must be initialized to an object literal
if (!configExport.init || !isObjectLiteralExpression(configExport.init)) {
throw new Error(
`"config" in ${sf.fileName} must be initialized to an object literal.`,
);
}
// (Light) type guard: ensure declared type mentions FunctionConfig if present
const maybeVarDecl = configExport.declNode as VariableDeclaration;
if ('type' in maybeVarDecl && maybeVarDecl.type) {
const typeText = maybeVarDecl.type.getText(sf);
if (!/\bFunctionConfig\b/.test(typeText)) {
throw new Error(
`"config" in ${sf.fileName} must be typed as FunctionConfig (got: ${typeText}).`,
);
}
}
const configObject = exprToValue(configExport.init) as Pick<
ServerlessFunctionManifest,
| 'universalIdentifier'
| 'name'
| 'description'
| 'timeoutSeconds'
| 'triggers'
>;
// Identify the handler: the other export
const handlerExport = unique.find((e) => e.name !== 'config');
if (!handlerExport) {
throw new Error(`Could not find the handler export in ${sf.fileName}.`);
}
// If it's a const, make sure its a function-ish initializer
if (handlerExport.kind === 'const') {
const init = handlerExport.init;
const isFuncLike =
!!init && (isArrowFunction(init) || isFunctionExpression(init));
if (!isFuncLike) {
throw new Error(
`Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`,
);
}
}
return {
handlerName: handlerExport.name,
configObject,
};
};
const posixRelativeFromCwd = (fileName: string, appPath: string) => {
const rel = relative(appPath, fileName);
// normalize to posix separators for portability / manifest stability
/**
* Convert a file path to posix format relative to appPath.
*/
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
};
const collectServerlessFunctions = (program: Program, appPath: string) => {
const serverlessFunctions: ServerlessFunctionManifest[] = [];
const loadFiles = async (
patterns: string[],
cwd: string,
): Promise<string[]> => {
return glob(patterns, {
cwd,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
};
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
/**
* Load all object definitions from src/app/ (any *.object.ts file).
*/
const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
const objectFiles = await loadFiles(['src/app/**/*.object.ts'], appPath);
const objects: ObjectManifest[] = [];
for (const filepath of objectFiles) {
try {
const { handlerName, configObject } = findHandlerAndConfig(sf);
const manifest = await loadConfig<ObjectManifest>(filepath);
const handlerPath = posixRelativeFromCwd(sf.fileName, appPath);
objects.push(manifest);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
serverlessFunctions.push({
...configObject,
return objects;
};
/**
* Load all function definitions from src/app/ (any *.function.ts file).
*/
const loadFunctions = async (
appPath: string,
): Promise<ServerlessFunctionManifest[]> => {
const functionFiles = await loadFiles(['src/app/**/*.function.ts'], appPath);
const functions: ServerlessFunctionManifest[] = [];
for (const filepath of functionFiles) {
try {
const { config, handlerName, handlerPath } = await loadFunctionModule(
filepath,
appPath,
);
const fnConfig = config as FunctionConfig;
const manifest: ServerlessFunctionManifest = {
universalIdentifier: fnConfig.universalIdentifier,
name: fnConfig.name,
description: fnConfig.description,
timeoutSeconds: fnConfig.timeoutSeconds,
triggers: fnConfig.triggers ?? [],
handlerPath,
handlerName,
});
} catch {
// Not a serverless file under the new format — ignore and continue scanning.
continue;
};
functions.push(manifest);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return serverlessFunctions;
return functions;
};
const setNested = (root: Sources, parts: string[], value: string) => {
let cur: Sources = root;
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
if (i === parts.length - 1) {
cur[key] = value;
} else {
cur[key] = (cur[key] ?? {}) as Sources;
cur = cur[key] as Sources;
}
}
};
/**
* Load all role definitions from src/app/ (any *.role.ts file).
*/
const loadRoles = async (appPath: string): Promise<RoleManifest[]> => {
const roleFiles = await loadFiles(['src/app/**/*.role.ts'], appPath);
const loadFolderContentIntoJson = async (
program: Program,
appPath: string,
): Promise<Sources> => {
const sources: Sources = {};
const roles: RoleManifest[] = [];
// Iterate only files the TS program knows about.
for (const sf of program.getSourceFiles()) {
const abs = sf.fileName;
// Skip .d.ts and anything outside sourcePath
if (sf.isDeclarationFile) continue;
if (!abs.startsWith(appPath + sep) && abs !== appPath) continue;
// Keep only TS/TSX files
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
if (abs.includes(`${sep}node_modules${sep}`)) continue;
const relFromRoot = relative(appPath, abs);
const parts = relFromRoot.split(sep);
const content = await fs.readFile(abs, 'utf8');
setNested(sources, parts, content);
}
return sources;
};
export const extractTwentyAppConfig = (program: Program): Application => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts'))
continue;
let found: Application | undefined;
const visit = (node: any): void => {
// Look for "export default twentyAppConfig"
if (isExportAssignment(node) && isIdentifier(node.expression)) {
const varName = node.expression.text;
// find the corresponding variable declaration
for (const stmt of sf.statements) {
if (isVariableStatement(stmt)) {
for (const decl of stmt.declarationList.declarations) {
if (isIdentifier(decl.name) && decl.name.text === varName) {
if (
decl.initializer &&
isObjectLiteralExpression(decl.initializer)
) {
found = exprToValue(
decl.initializer,
) as unknown as Application;
}
}
}
}
}
}
if (!found) forEachChild(node, visit);
};
visit(sf);
if (found) return found;
}
throw new Error('Could not find default exported ApplicationConfig');
};
const isGeneratedModuleUsedInProgram = (program: Program): boolean => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
let found = false;
const visit = (node: Node): void => {
if (found) return;
if (isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier;
if (isStringLiteralLike(moduleSpecifier)) {
const moduleText = moduleSpecifier.text;
// Match ../../generated, ../generated, ./foo/generated, etc.
const isGeneratedModule =
moduleText === GENERATED_FOLDER_NAME ||
moduleText.endsWith(`/${GENERATED_FOLDER_NAME}`);
if (isGeneratedModule && node.importClause) {
found = true;
return;
}
}
}
forEachChild(node, visit);
};
visit(sf);
if (found) return true;
}
return false;
};
export const collectRoles = (program: Program): Array<RoleManifest> => {
const roles: Array<RoleManifest> = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
for (const st of sf.statements) {
if (!isVariableStatement(st)) continue;
// must be "export const ..."
const isExported =
st.modifiers?.some((m) => m.kind === SyntaxKind.ExportKeyword) ?? false;
if (!isExported) continue;
for (const decl of st.declarationList.declarations) {
if (!isIdentifier(decl.name)) continue;
// must be typed RoleConfig (matches: RoleConfig, foo.RoleConfig, import type RoleConfig, etc.)
const typeText = decl.type?.getText(sf) ?? '';
if (!typeText.includes('RoleConfig')) continue;
// must be "= { ... }"
const init = decl.initializer;
if (!init || !isObjectLiteralExpression(init)) continue;
roles.push(exprToValue(init) as unknown as RoleManifest);
}
for (const filepath of roleFiles) {
try {
const config = await loadConfig<RoleConfig>(filepath);
roles.push(config);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return roles;
};
export const loadManifest = async (
appPath: string,
): Promise<{
/**
* Build a nested object structure from all TypeScript source files.
*/
const loadSources = async (appPath: string): Promise<Sources> => {
const sources: Sources = {};
// Get all TypeScript files in src/ folder
const tsFiles = await loadFiles(['src/**/*.ts'], appPath);
for (const filepath of tsFiles) {
const relPath = relative(appPath, filepath);
const parts = relPath.split(sep);
const content = await fs.readFile(filepath, 'utf8');
// Build nested structure
let current: Sources = sources;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
current[part] = content;
} else {
current[part] = (current[part] ?? {}) as Sources;
current = current[part] as Sources;
}
}
}
return sources;
};
/**
* Check if the app imports from the generated folder.
* Detects any `import ... from '...generated'` or `import ... from '...generated/...'` pattern.
*/
const checkShouldGenerate = async (appPath: string): Promise<boolean> => {
const tsFiles = await loadFiles(['src/**/*.ts'], appPath);
// Matches: import ... from 'generated' or from '.../generated' or from '.../generated/...'
const generatedImportPattern =
/from\s+['"][^'"]*\/generated(?:\/[^'"]*)?['"]|from\s+['"]generated['"]/;
for (const filepath of tsFiles) {
const content = await fs.readFile(filepath, 'utf8');
if (generatedImportPattern.test(content)) {
return true;
}
}
return false;
};
export type LoadManifestResult = {
packageJson: PackageJson;
yarnLock: string;
manifest: ApplicationManifest;
shouldGenerate: boolean;
}> => {
warnings: ValidationWarning[];
};
/**
* Load an application manifest using the folder structure with jiti runtime evaluation.
*
* Files are detected by their suffix (*.object.ts, *.function.ts, *.role.ts)
* and can be placed anywhere within src/app/.
*
* Example structures:
* ```
* # Traditional (by type)
* my-app/
* ├── src/
* │ └── app/
* │ ├── application.config.ts
* │ ├── objects/
* │ │ └── postCard.object.ts
* │ ├── functions/
* │ │ └── createPostCard.function.ts
* │ └── roles/
* │ └── admin.role.ts
*
* # Feature-based
* my-app/
* ├── src/
* │ └── app/
* │ ├── application.config.ts
* │ └── post-card/
* │ ├── postCard.object.ts
* │ ├── createPostCard.function.ts
* │ └── postCardAdmin.role.ts
* ```
*/
export const loadManifest = async (
appPath: string,
): Promise<LoadManifestResult> => {
// Validate folder structure
await validateFolderStructure(appPath);
// Load package.json and yarn.lock
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
@@ -536,34 +252,51 @@ export const loadManifest = async (
await findPathFile(appPath, 'yarn.lock'),
);
const { diagnostics, program } = await getTsProgramAndDiagnostics({
// Load application config
const applicationConfigPath = path.join(
appPath,
'src',
'app',
'application.config.ts',
);
const application = await loadConfig<Application>(applicationConfigPath);
// Load all entities in parallel
const [objects, serverlessFunctions, roles, sources, shouldGenerate] =
await Promise.all([
loadObjects(appPath),
loadFunctions(appPath),
loadRoles(appPath),
loadSources(appPath),
checkShouldGenerate(appPath),
]);
// Build manifest
const manifest: ApplicationManifest = {
application,
objects,
serverlessFunctions,
roles,
sources,
};
// Validate manifest
const validation = validateManifest({
application,
objects,
serverlessFunctions,
roles,
});
formatAndWarnTsDiagnostics({
diagnostics,
});
const [objects, serverlessFunctions, application, roles, sources] = [
collectObjects(program),
collectServerlessFunctions(program, appPath),
extractTwentyAppConfig(program),
collectRoles(program),
await loadFolderContentIntoJson(program, appPath),
];
const shouldGenerate = isGeneratedModuleUsedInProgram(program);
if (!validation.isValid) {
throw new ManifestValidationError(validation.errors);
}
return {
packageJson,
yarnLock,
manifest: {
application,
objects,
serverlessFunctions,
roles,
sources,
},
manifest,
shouldGenerate,
warnings: validation.warnings,
};
};
@@ -0,0 +1,384 @@
import {
type ApplicationManifest,
type ServerlessFunctionManifest,
type ObjectManifest,
type RoleManifest,
type Application,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
export type ValidationError = {
path: string;
message: string;
};
export type ValidationWarning = {
path?: string;
message: string;
};
export type ValidationResult = {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
};
export class ManifestValidationError extends Error {
constructor(public readonly errors: ValidationError[]) {
const messages = errors.map((e) => `${e.path}: ${e.message}`).join('\n');
super(`Manifest validation failed:\n${messages}`);
this.name = 'ManifestValidationError';
}
}
/**
* Collect all universalIdentifiers from the manifest for duplicate checking.
*/
const collectAllIds = (
manifest: Omit<ApplicationManifest, 'sources'>,
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
// Application
if (manifest.application?.universalIdentifier) {
ids.push({
id: manifest.application.universalIdentifier,
location: 'application',
});
}
// Application variables
if (manifest.application?.applicationVariables) {
for (const [name, variable] of Object.entries(
manifest.application.applicationVariables,
)) {
if (variable.universalIdentifier) {
ids.push({
id: variable.universalIdentifier,
location: `application.variables.${name}`,
});
}
}
}
// Objects
for (const obj of manifest.objects ?? []) {
if (obj.universalIdentifier) {
ids.push({
id: obj.universalIdentifier,
location: `objects/${obj.nameSingular}`,
});
}
// Object fields
for (const field of obj.fields ?? []) {
if (field.universalIdentifier) {
ids.push({
id: field.universalIdentifier,
location: `objects/${obj.nameSingular}.fields.${field.label}`,
});
}
}
}
// Functions
for (const fn of manifest.serverlessFunctions ?? []) {
if (fn.universalIdentifier) {
ids.push({
id: fn.universalIdentifier,
location: `functions/${fn.name ?? fn.handlerName}`,
});
}
// Function triggers
for (const trigger of fn.triggers ?? []) {
if (trigger.universalIdentifier) {
ids.push({
id: trigger.universalIdentifier,
location: `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`,
});
}
}
}
// Roles
for (const role of manifest.roles ?? []) {
if (role.universalIdentifier) {
ids.push({
id: role.universalIdentifier,
location: `roles/${role.label}`,
});
}
}
return ids;
};
/**
* Find duplicate universalIdentifiers.
*/
const findDuplicates = (
ids: Array<{ id: string; location: string }>,
): Array<{ id: string; locations: string[] }> => {
const seen = new Map<string, string[]>();
for (const { id, location } of ids) {
const locations = seen.get(id) ?? [];
locations.push(location);
seen.set(id, locations);
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
};
/**
* Validate an application config.
*/
const validateApplication = (
application: Application | undefined,
errors: ValidationError[],
): void => {
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',
});
}
};
/**
* Validate objects and their fields.
*/
const validateObjects = (
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',
});
}
// Validate fields
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',
});
}
// Check SELECT/MULTI_SELECT fields have options
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!field.options || (field.options as unknown[]).length === 0)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
};
/**
* Validate serverless functions.
*/
const validateFunctions = (
functions: ServerlessFunctionManifest[],
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',
});
}
if (!fn.triggers || fn.triggers.length === 0) {
errors.push({
path: fnPath,
message: 'Function must have at least one trigger',
});
}
// Validate triggers
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;
}
}
}
};
/**
* Validate roles.
*/
const validateRoles = (
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',
});
}
}
};
/**
* Validate a complete application manifest.
*/
export const validateManifest = (
manifest: Omit<ApplicationManifest, 'sources'>,
): ValidationResult => {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate application
validateApplication(manifest.application, errors);
// Validate objects
validateObjects(manifest.objects ?? [], errors);
// Validate functions
validateFunctions(manifest.serverlessFunctions ?? [], errors);
// Validate roles
validateRoles(manifest.roles ?? [], errors);
// Check for duplicate universalIdentifiers
const allIds = collectAllIds(manifest);
const duplicates = findDuplicates(allIds);
for (const dup of duplicates) {
errors.push({
path: dup.locations.join(', '),
message: `Duplicate universalIdentifier: ${dup.id}`,
});
}
// Warnings
if (!manifest.objects || manifest.objects.length === 0) {
warnings.push({
message: 'No objects defined in src/app/objects/',
});
}
if (!manifest.serverlessFunctions || manifest.serverlessFunctions.length === 0) {
warnings.push({
message: 'No functions defined in src/app/functions/',
});
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
};