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:
@@ -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,
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -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 it’s 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user