1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347)

This PR adds 2 columns handlerPath and handlerName in serverlessFunction
to locate the entrypoint of a serverless in a codebase

It adds the following decorators in twenty-sdk:
- ServerlessFunction
- DatabaseEventTrigger
- RouteTrigger
- CronTrigger
- ApplicationVariable

It still supports deprecated entity.manifest.jsonc 

Overall code needs to be cleaned a little bit, but it should work
properly so you can try to test if the DEVX fits your needs

See updates in hello-world application

```typescript
import axios from 'axios';
import {
  DatabaseEventTrigger,
  ServerlessFunction,
  RouteTrigger,
  CronTrigger,
  ApplicationVariable,
} from 'twenty-sdk';

@ApplicationVariable({
  universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
  key: 'TWENTY_API_KEY',
  description: 'Twenty API Key',
  isSecret: true,
})
@DatabaseEventTrigger({
  universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
  eventName: 'person.created',
})
@RouteTrigger({
  universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
  path: '/post-card/create',
  httpMethod: 'GET',
  isAuthRequired: false,
})
@CronTrigger({
  universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
  pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
class CreateNewPostCard {
  main = async (params: { recipient: string }): Promise<string> => {
    const { recipient } = params;

    const options = {
      method: 'POST',
      url: 'http://localhost:3000/rest/postCards',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
      },
      data: { name: recipient ?? 'Unknown' },
    };

    try {
      const { data } = await axios.request(options);

      console.log(`New post card to "${recipient}" created`);

      return data;
    } catch (error) {
      console.error(error);
      throw error;
    }
  };
}

export const createNewPostCardHandler = new CreateNewPostCard().main;

```


### [edit] V2 

After the v1 proposal, I see that using a class method to define the
serverless function handler is pretty confusing. Lets leave
serverlessFunction configuration decorators on the class, but move the
handler like before. Here is the v2 hello-world serverless function:

```typescript
import axios from 'axios';
import {
  DatabaseEventTrigger,
  ServerlessFunction,
  RouteTrigger,
  CronTrigger,
  ApplicationVariable,
} from 'twenty-sdk';

@ApplicationVariable({
  universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
  key: 'TWENTY_API_KEY',
  description: 'Twenty API Key',
  isSecret: true,
})
@DatabaseEventTrigger({
  universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
  eventName: 'person.created',
})
@RouteTrigger({
  universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
  path: '/post-card/create',
  httpMethod: 'GET',
  isAuthRequired: false,
})
@CronTrigger({
  universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
  pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
export class ServerlessFunctionDefinition {}

export const main = async (params: { recipient: string }): Promise<string> => {
  const { recipient } = params;

  const options = {
    method: 'POST',
    url: 'http://localhost:3000/rest/postCards',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
    },
    data: { name: recipient ?? 'Unknown' },
  };

  try {
    const { data } = await axios.request(options);

    console.log(`New post card to "${recipient}" created`);

    return data;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

```


### [edit] V3

After the v2 proposal, we don't really like decorators on empty classes.
We decided to go with a Vercel approach with a config constant

```typescript
import axios from 'axios';
import { ServerlessFunctionConfig } from 'twenty-sdk';

export const main = async (params: { recipient: string }): Promise<string> => {
  const { recipient } = params;

  const options = {
    method: 'POST',
    url: 'http://localhost:3000/rest/postCards',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
    },
    data: { name: recipient ?? 'Unknown' },
  };

  try {
    const { data } = await axios.request(options);

    console.log(`New post card to "${recipient}" created`);

    return data;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

export const config: ServerlessFunctionConfig = {
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
  routeTriggers: [
  {
    universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
    path: '/post-card/create',
    httpMethod: 'GET',
    isAuthRequired: false,
  }
  ],
  cronTriggers: [
    {
      universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
      pattern: '0 0 1 1 *', // Every year 1st of January
    }
  ],
  databaseEventTriggers: [
  {
    universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
    eventName: 'person.created',
   }
  ]
}

```
This commit is contained in:
martmull
2025-10-29 17:51:43 +01:00
committed by GitHub
parent 75ed5cb3a2
commit a6cc80eedd
81 changed files with 2070 additions and 1044 deletions
@@ -5,32 +5,19 @@ import inquirer from 'inquirer';
import path from 'path';
import camelcase from 'lodash.camelcase';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { HTTPMethod } from '../types/config.types';
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
import { BASE_SCHEMAS_PATH } from '../constants/constants-path';
import { getDecoratedClass } from '../utils/get-decorated-class';
import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class';
import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
const ROOT_FOLDER = 'src';
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
SERVERLESS_FUNCTION = 'serverlessFunction',
TRIGGER = 'trigger',
}
const getFolderName = (entity: SyncableEntity) => {
switch (entity) {
case SyncableEntity.AGENT:
return 'agents';
case SyncableEntity.OBJECT:
return 'objects';
case SyncableEntity.SERVERLESS_FUNCTION:
return 'serverlessFunctions';
default:
throw new Error(`Unknown entity type: ${entity}`);
}
};
export const isSyncableEntity = (value: string): value is SyncableEntity => {
return Object.values(SyncableEntity).includes(value as SyncableEntity);
};
@@ -38,22 +25,12 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => {
export class AppAddCommand {
async execute(entityType?: SyncableEntity): Promise<void> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER);
await fs.ensureDir(appPath);
const entity = entityType ?? (await this.getEntity());
const appExists = await fs.pathExists(appPath);
if (!appExists) {
console.error(chalk.red('App does not exist'));
process.exit(1);
}
if (entity === SyncableEntity.TRIGGER) {
await this.addTriggerToServerlessFunction(appPath);
return;
}
const entityName = await this.getEntityName(entity);
const entityData = await this.getEntityToCreateData(entity, entityName);
@@ -64,7 +41,7 @@ export class AppAddCommand {
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedObject = getDecoratedClass({
const decoratedObject = getObjectMetadataDecoratedClass({
data: entityData,
name: entityName,
});
@@ -74,18 +51,20 @@ export class AppAddCommand {
return;
}
const folderName = getFolderName(entity);
if (entity === SyncableEntity.SERVERLESS_FUNCTION) {
const objectFileName = `${camelcase(entityName)}.ts`;
const entitiesDir = path.join(appPath, folderName, entityName);
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
name: entityName,
});
await fs.ensureDir(entitiesDir);
await fs.writeFile(
path.join(appPath, objectFileName),
decoratedServerlessFunction,
);
await writeJsoncFile(
path.join(entitiesDir, `${entity}.manifest.jsonc`),
entityData,
);
await this.addEntityInitFiles(entity, entitiesDir);
return;
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
@@ -123,11 +102,7 @@ export class AppAddCommand {
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [
SyncableEntity.SERVERLESS_FUNCTION,
SyncableEntity.OBJECT,
SyncableEntity.TRIGGER,
],
choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.OBJECT],
},
]);
@@ -212,178 +187,4 @@ export class AppAddCommand {
return entityToCreateData;
}
private async addTriggerToServerlessFunction(appPath: string) {
const serverlessFunctionsDir = path.join(appPath, 'serverlessFunctions');
if (!(await fs.pathExists(serverlessFunctionsDir))) {
console.error(chalk.red('No serverless functions found in this app'));
process.exit(1);
}
const serverlessFunctions = await fs.readdir(serverlessFunctionsDir);
if (serverlessFunctions.length === 0) {
console.error(chalk.red('No serverless functions found in this app'));
process.exit(1);
}
const { serverlessFunctionName } = await inquirer.prompt<{
serverlessFunctionName: string;
}>([
{
type: 'list',
name: 'serverlessFunctionName',
message: 'Select a serverless function to add a trigger to:',
choices: serverlessFunctions,
},
]);
const { triggerType } = await inquirer.prompt<{ triggerType: string }>([
{
type: 'list',
name: 'triggerType',
message: 'Select the type of trigger:',
choices: ['databaseEvent', 'cron', 'route'],
},
]);
let triggerData: any;
if (triggerType === 'databaseEvent') {
triggerData = await this.createDatabaseEventTrigger();
} else if (triggerType === 'cron') {
triggerData = await this.createCronTrigger();
} else if (triggerType === 'route') {
triggerData = await this.createRouteTrigger();
}
const manifestPath = path.join(
serverlessFunctionsDir,
serverlessFunctionName,
'serverlessFunction.manifest.jsonc',
);
const manifest = await parseJsoncFile(manifestPath);
if (!manifest.triggers) {
manifest.triggers = [];
}
manifest.triggers.push(triggerData);
await writeJsoncFile(manifestPath, manifest);
console.log(
chalk.green(`✅ Trigger added successfully to ${serverlessFunctionName}`),
);
}
private async createDatabaseEventTrigger() {
const uuid = randomUUID();
const { eventName } = await inquirer.prompt<{ eventName: string }>([
{
type: 'input',
name: 'eventName',
message:
'Enter the database event name (e.g. company.created, *.updated, person.*):',
validate: (input) => {
if (input.length === 0) {
return 'Event name is required';
}
if (!/^(?:[a-zA-Z]+|\*)\.(created|updated|deleted|\*)$/.test(input)) {
return 'Event name must be in format: (objectName|*).(created|updated|deleted|*)';
}
return true;
},
},
]);
return {
universalIdentifier: uuid,
type: 'databaseEvent',
eventName,
};
}
private async createCronTrigger() {
const uuid = randomUUID();
const { schedule } = await inquirer.prompt<{ schedule: string }>([
{
type: 'input',
name: 'schedule',
message: 'Enter the cron schedule (e.g., 0 9 * * * for daily at 9 AM):',
validate: (input) => {
if (input.length === 0) {
return 'Schedule is required';
}
const parts = input.trim().split(/\s+/);
if (parts.length < 5 || parts.length > 6) {
return 'Cron schedule must have 5 or 6 fields (e.g., 0 9 * * *)';
}
return true;
},
},
]);
return {
universalIdentifier: uuid,
type: 'cron',
schedule,
};
}
private async createRouteTrigger() {
const uuid = randomUUID();
const { path } = await inquirer.prompt<{ path: string }>([
{
type: 'input',
name: 'path',
message: 'Enter the route path (e.g., /webhook/company):',
validate: (input) => {
if (input.length === 0) {
return 'Path is required';
}
if (!input.startsWith('/')) {
return 'Path must start with /';
}
return true;
},
},
]);
const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([
{
type: 'list',
name: 'httpMethod',
message: 'Select the HTTP method:',
choices: Object.values(HTTPMethod),
default: HTTPMethod.GET,
},
]);
const { isAuthRequired } = await inquirer.prompt<{
isAuthRequired: boolean;
}>([
{
type: 'confirm',
name: 'isAuthRequired',
message: 'Is authentication required?',
default: true,
},
]);
return {
universalIdentifier: uuid,
type: 'route',
path,
httpMethod,
isAuthRequired,
};
}
}
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/app-manifest-loader';
import { loadManifest } from '../utils/load-manifest';
export class AppDeleteCommand {
private apiService = new ApiService();
@@ -25,9 +25,11 @@ export class AppDeleteCommand {
process.exit(1);
}
const { packageJson } = await loadManifest(appPath);
const { manifest } = await loadManifest(appPath);
const result = await this.apiService.deleteApplication(packageJson);
const result = await this.apiService.deleteApplication(
manifest.application.universalIdentifier,
);
if (!result.success) {
console.error(chalk.red('❌ Deletion failed:'), result.error);
@@ -2,14 +2,17 @@ import chalk from 'chalk';
import * as chokidar from 'chokidar';
import { ApiService } from '../services/api.service';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { loadManifest } from '../utils/app-manifest-loader';
import { loadManifest } from '../utils/load-manifest';
export class AppDevCommand {
private apiService = new ApiService();
async execute(options: { debounce: string }): Promise<void> {
async execute(options: {
appPath?: string;
debounce: string;
}): Promise<void> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const debounceMs = parseInt(options.debounce, 10);
@@ -8,7 +8,7 @@ import kebabCase from 'lodash.kebabcase';
export class AppInitCommand {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDirectory, appDescription } =
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
await this.validateDirectory(appDirectory);
@@ -19,6 +19,7 @@ export class AppInitCommand {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
@@ -35,19 +36,31 @@ export class AppInitCommand {
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDirectory: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}> {
const { name, description } = await inquirer.prompt([
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name (eg: My awesome application):',
message: 'Application name (eg: my-awesome-app):',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
},
},
{
type: 'input',
name: 'displayName',
message: 'Display name (eg: My awesome app):',
default: (answers: any) => {
return answers.name
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
},
},
{
type: 'input',
name: 'description',
@@ -58,13 +71,15 @@ export class AppInitCommand {
const appName = name.trim();
const appDisplayName = displayName.trim();
const appDescription = description.trim();
const appDirectory = directory
? path.join(process.cwd(), kebabCase(directory))
: path.join(process.cwd(), kebabCase(appName));
return { appName, appDirectory, appDescription };
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
@@ -2,7 +2,7 @@ import chalk from 'chalk';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/app-manifest-loader';
import { loadManifest } from '../utils/load-manifest';
export class AppSyncCommand {
private apiService = new ApiService();
@@ -9,6 +9,7 @@ import { AppDeleteCommand } from './app-delete.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppSyncCommand } from './app-sync.command';
import { formatPath } from '../utils/format-path';
export class AppCommand {
private devCommand = new AppDevCommand();
@@ -22,19 +23,22 @@ export class AppCommand {
appCommand.description('Application development commands');
appCommand
.command('dev')
.command('dev [appPath]')
.description('Watch and sync local application changes')
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
.action(async (options) => {
await this.devCommand.execute(options);
.action(async (appPath, options) => {
await this.devCommand.execute({
...options,
appPath: formatPath(appPath),
});
});
appCommand
.command('sync')
.command('sync [appPath]')
.description('Sync application to Twenty')
.action(async () => {
.action(async (appPath?: string) => {
try {
const result = await this.syncCommand.execute();
const result = await this.syncCommand.execute(formatPath(appPath));
if (!result.success) {
process.exit(1);
}
@@ -44,11 +48,12 @@ export class AppCommand {
});
appCommand
.command('delete')
.command('delete [appPath]')
.description('Delete application from Twenty')
.action(async () => {
.action(async (appPath?: string) => {
try {
const result = await this.deleteCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: true,
});
if (!result.success) {
@@ -5,9 +5,20 @@
# You can access them directly in your code using:
# const myValue = process.env.KEY;
#
# To make these variables available to your application,
# add them to package.json "env" key. This "env" key defines all
# environment variables that will be provided to your serverless
# To make these variables available to your application, add them in application.config.ts file
#
# const config: ApplicationConfig = {
# ...
# applicationVariables: {
# KEY: {
# universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
# description: 'Description',
# isSecret: true,
# },
# },
# };
#
# Those environment variables will be provided to your serverless
# functions at runtime.
#
# Example:
@@ -1,4 +1,5 @@
{
"name": "my-application",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -8,7 +9,7 @@
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"twenty-sdk": "^0.0.2"
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
@@ -17,6 +17,10 @@
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"],
"include": ["**/*.ts"]
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -45,6 +45,11 @@
"type": "object",
"title": "Environment Variable Definition",
"properties": {
"universalIdentifier": {
"type": "string",
"description": "Unique identifier (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"description": {
"type": "string",
"description": "Description for this environment variable."
@@ -58,7 +63,6 @@
"description": "If true, the value will be treated as sensitive and hidden from logs or UI."
}
},
"required": ["isSecret"],
"additionalProperties": false
}
},
@@ -4,10 +4,7 @@
"title": "Twenty Serverless Function Manifest",
"description": "Schema for Twenty AI serverless function configuration files",
"type": "object",
"required": [
"universalIdentifier",
"name"
],
"required": ["universalIdentifier"],
"properties": {
"$schema": {
"type": "string",
@@ -16,7 +16,7 @@
"type": { "enum": ["cron", "databaseEvent", "route"] },
"schedule": { "type": "string" },
"pattern": { "type": "string" },
"eventName": { "type": "string" },
@@ -29,7 +29,7 @@
"allOf": [
{
"if": { "properties": { "type": { "const": "cron" } } },
"then": { "required": ["schedule"] }
"then": { "required": ["pattern"] }
},
{
"if": { "properties": { "type": { "const": "databaseEvent" } } },
@@ -128,7 +128,7 @@ export class ApiService {
return {
success: true,
data: response.data.data.syncApplication,
message: `Successfully synced application: ${manifest.name}`,
message: `Successfully synced application: ${packageJson.name}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
@@ -141,15 +141,15 @@ export class ApiService {
}
}
async deleteApplication(packageJson: PackageJson): Promise<ApiResponse> {
async deleteApplication(universalIdentifier: string): Promise<ApiResponse> {
try {
const mutation = `
mutation DeleteApplication($packageJson: JSON!) {
deleteApplication(packageJson: $packageJson)
mutation DeleteApplication($universalIdentifier: String!) {
deleteApplication(universalIdentifier: $universalIdentifier)
}
`;
const variables = { packageJson };
const variables = { universalIdentifier };
const response: AxiosResponse = await this.client.post(
'/metadata',
@@ -176,7 +176,7 @@ export class ApiService {
return {
success: true,
data: response.data.data.deleteApplication,
message: `Successfully deleted application: ${packageJson.name}`,
message: 'Successfully deleted application',
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
+43 -65
View File
@@ -5,79 +5,75 @@ export interface TwentyConfig {
}
export type PackageJson = {
$schema?: string;
universalIdentifier: string;
name: string;
license: string;
description?: string;
engines: {
node: string;
npm: string;
yarn: string;
};
packageManager: string;
icon?: string;
version: string;
dependencies?: object;
devDependencies?: object;
};
export type AppManifest = PackageJson & {
agents: AgentManifest[];
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
type ApplicationVariable = {
universalIdentifier: string;
value?: string;
description?: string;
isSecret?: boolean;
};
export type CoreEntityManifest =
| AgentManifest
| ObjectManifest
| ServerlessFunctionManifest;
export type Application = {
universalIdentifier: string;
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
};
export type AppManifest = {
application: Application;
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
sources: Sources;
};
export type ServerlessFunctionManifest = {
$schema?: string;
universalIdentifier: string;
name: string;
name?: string;
description?: string;
timeoutSeconds?: number;
triggers: ServerlessFunctionTriggerManifest[];
code: ServerlessFunctionCodeManifest;
handlerPath: string;
handlerName: string;
};
export enum HTTPMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
export type ServerlessFunctionTriggerManifest =
| {
type: 'cron';
schedule: string;
}
| {
type: 'databaseEvent';
eventName: string;
}
| {
type: 'route';
path: string;
httpMethod: HTTPMethod;
isAuthRequired: boolean;
};
type Sources = { [key: string]: string | Sources };
export type ServerlessFunctionCodeManifest = {
src: {
'index.ts': string;
} & Sources;
export type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
};
export type CronTrigger = {
type: 'cron';
pattern: string;
};
export type RouteTrigger = {
type: 'route';
path: string;
httpMethod: string;
isAuthRequired: boolean;
};
export type ServerlessFunctionTriggerManifest = {
universalIdentifier: string;
} & (CronTrigger | DatabaseEventTrigger | RouteTrigger);
export type Sources = { [key: string]: string | Sources };
export type ObjectManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
nameSingular: string;
namePlural: string;
@@ -87,24 +83,6 @@ export type ObjectManifest = {
icon?: string;
};
export type AgentManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
name: string;
label: string;
description?: string;
icon?: string;
prompt: string;
modelId: string;
responseFormat?: AgentResponseFormat;
};
export interface AgentResponseFormat {
type: 'json' | 'text';
schema?: Record<string, unknown>;
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
@@ -1,13 +1,13 @@
import { getDecoratedClass } from '../../utils/get-decorated-class';
import { getObjectMetadataDecoratedClass } from '../../utils/get-object-metadata-decorated-class';
describe('getDecoratedClass', () => {
it('should return properly formatted class', () => {
const result = getDecoratedClass({
const result = getObjectMetadataDecoratedClass({
data: { nameSingular: 'Name', namePlural: 'Names' },
name: 'MyNewObject',
});
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk';
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
nameSingular: 'Name',
@@ -0,0 +1,276 @@
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
import { copyBaseApplicationProject } from '../app-template';
import { loadManifest } from '../load-manifest';
const write = (root: string, file: string, content: string) => {
const abs = join(root, file);
ensureDirSync(resolve(abs, '..'));
writeFileSync(abs, content, 'utf8');
};
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;
}`;
const twentySdkTypesMock = `
declare module 'twenty-sdk/application' {
export type SyncableEntityOptions = { universalIdentifier: string };
type ApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: boolean;
};
export type ApplicationConfig = SyncableEntityOptions & {
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
};
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 ServerlessFunctionConfig = 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 ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
return () => {};
};
}
`;
const serverlessFunctionMock = `
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: any): Promise<any> => {
return {};
}
export const config: ServerlessFunctionConfig = {
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 { ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
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 {}
`;
describe('loadManifest (integration)', () => {
const appName = 'my-app';
const appDisplayName = 'My App';
const appDescription = 'My app description';
const appDirectory = join(tmpdir(), 'twenty-manifest-');
beforeEach(async () => {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
write(appDirectory, 'src/Account.ts', objectMock);
write(appDirectory, 'src/hello.ts', serverlessFunctionMock);
write(
appDirectory,
'src/types/twenty-sdk-application.d.ts',
twentySdkTypesMock,
);
write(
appDirectory,
'src/types/tslib.d.ts',
// minimal + future-proof
tsLibMock,
);
});
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');
expect(packageJson.version).toBe('0.0.1');
expect(packageJson.license).toBe('MIT');
expect(yarnLock).toContain('# This file is generated by running ');
// application
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { universalIdentifier: _, ...otherInfo } = manifest.application;
expect(otherInfo).toEqual({
displayName: 'My App',
description: 'My app description',
});
// objects collected from @ObjectMetadata
for (const object of manifest.objects) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { universalIdentifier: _, ...otherInfo } = object;
expect(otherInfo).toEqual({
description: ' A post card object',
icon: 'IconMail',
labelPlural: 'Post cards',
labelSingular: 'Post card',
namePlural: 'postCards',
nameSingular: 'postCard',
});
}
// serverless functions
for (const serverlessFunction of manifest.serverlessFunctions) {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
universalIdentifier: _,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handlerPath: __,
triggers,
...otherInfo
} = serverlessFunction;
expect(otherInfo).toEqual({
handlerName: 'main',
name: 'hello',
timeoutSeconds: 2,
});
for (const trigger of triggers) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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;
}
}
}
});
it('should not define serverless for util file', async () => {
write(
appDirectory,
'src/utils/format.ts',
`
export const format = async (params: any): Promise<any> => {
return {};
}
`,
);
const { manifest } = await loadManifest(appDirectory);
expect(manifest.serverlessFunctions.length).toBe(1);
});
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',
'hello.ts',
]);
});
it('fails fast if TS validation fails', async () => {
write(appDirectory, 'src/utils/broken.ts', `const x: number = 'oops';`);
await expect(loadManifest(appDirectory)).rejects.toThrow(
/TypeScript validation failed/,
);
});
});
@@ -1,190 +0,0 @@
import dotenv from 'dotenv';
import assert from 'assert';
import * as fs from 'fs-extra';
import * as path from 'path';
import {
AppManifest,
CoreEntityManifest,
ObjectManifest,
PackageJson,
} from '../types/config.types';
import { validateSchema } from '../utils/schema-validator';
import { parseJsoncFile } from './jsonc-parser';
import { loadManifestFromDecorators } from '../utils/load-manifest-from-decorators';
type Sources = { [key: string]: string | Sources };
const findPathFile = async (
appPath: string,
fileName: string,
): Promise<string> => {
const jsonPath = path.join(appPath, fileName);
if (await fs.pathExists(jsonPath)) {
return jsonPath;
}
throw new Error(`${fileName} not found in ${appPath}`);
};
const loadCoreEntity = async (
coreEntityPath: string,
validator: (manifest: CoreEntityManifest, path: string) => Promise<void>,
): Promise<CoreEntityManifest[]> => {
const coreEntities: CoreEntityManifest[] = [];
if (await fs.pathExists(coreEntityPath)) {
const entities = await fs.readdir(coreEntityPath);
for (const entity of entities) {
const entityPath = path.join(coreEntityPath, entity);
const entityResources = await fs.readdir(entityPath);
const entityManifests = entityResources.filter(
(file) =>
file.endsWith('.manifest.jsonc') || file.endsWith('.manifest.json'),
);
assert(
entityManifests.length === 1,
'Entity should have strictly one manifest file',
);
const entityManifest = entityManifests[0];
const coreEntityManifest = await parseJsoncFile(
path.join(coreEntityPath, entity, entityManifest),
);
const entitySources = entityResources.filter(
(folder) => folder === 'src',
);
assert(
entitySources.length <= 1,
'Entity should have less than one src folder or file',
);
if (entitySources.length === 1) {
const entitySourcePath = path.join(
coreEntityPath,
entity,
entitySources[0],
);
const sources = await loadFolderContentIntoJson(entitySourcePath);
coreEntityManifest['code'] = { src: sources };
}
await validator(coreEntityManifest, coreEntityPath);
coreEntities.push(coreEntityManifest);
}
}
return coreEntities;
};
const loadFolderContentIntoJson = async (
sourcePath: string,
): Promise<Sources> => {
const sources: Sources = {};
const resources = await fs.readdir(sourcePath);
for (const resource of resources) {
const resourcePath = path.join(sourcePath, resource);
const stats = await fs.stat(resourcePath);
if (stats.isFile()) {
sources[resource] = await fs.readFile(resourcePath, 'utf8');
} else {
sources[resource] = await loadFolderContentIntoJson(resourcePath);
}
}
return sources;
};
export const loadManifest = async (
appPath: string,
): Promise<{
packageJson: PackageJson;
yarnLock: string;
manifest: AppManifest;
}> => {
const packageJsonPath = await findPathFile(appPath, 'package.json');
const rawPackageJson = await parseJsoncFile(packageJsonPath);
const yarnLockPath = await findPathFile(appPath, 'yarn.lock');
const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8');
let envFile = '';
try {
const envFilePath = await findPathFile(appPath, '.env');
envFile = await fs.readFile(envFilePath, 'utf8');
} catch {
// Allow missing .env
}
const envVariables = dotenv.parse(envFile);
const packageJsonEnv = rawPackageJson.env || {};
for (const key of Object.keys(envVariables)) {
if (packageJsonEnv[key]) {
packageJsonEnv[key] = {
isSecret: false,
...packageJsonEnv[key],
value: envVariables[key],
};
} else {
throw new Error(
`Environment variable "${key}" is defined in .env but missing from package.json. Please add it to the "env" section in package.json.`,
);
}
}
const packageJson = { ...rawPackageJson, env: packageJsonEnv };
await validateSchema('appManifest', packageJson, packageJsonPath);
const agents = await loadCoreEntity(
path.join(appPath, 'agents'),
(manifest, path) => validateSchema('agent', manifest, path),
);
const objectFromManifests = await loadCoreEntity(
path.join(appPath, 'objects'),
(manifest, path) => validateSchema('object', manifest, path),
);
const serverlessFunctions = await loadCoreEntity(
path.join(appPath, 'serverlessFunctions'),
(manifest, path) => validateSchema('serverlessFunction', manifest, path),
);
const { objects: objectsFromDecorators } = loadManifestFromDecorators();
const objects = (
[...objectFromManifests, ...objectsFromDecorators] as ObjectManifest[]
).map((object) => {
object.standardId = object.universalIdentifier;
return object;
});
return {
packageJson,
yarnLock: rawYarnLock,
manifest: {
...packageJson,
agents,
objects,
serverlessFunctions,
},
};
};
+37 -12
View File
@@ -5,13 +5,16 @@ import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path';
import { writeJsoncFile } from '../utils/jsonc-parser';
import { join } from 'path';
import path from 'path';
import { v4 } from 'uuid';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
@@ -26,24 +29,50 @@ export const copyBaseApplicationProject = async ({
await createBasePackageJson({
appName,
appDescription,
appDirectory,
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory,
});
await createReadmeContent({
appName,
displayName: appDisplayName,
appDescription,
appDirectory,
});
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
}: {
displayName: string;
description?: string;
appDirectory: string;
}) => {
const content = `import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
};
export default config;
`;
await fs.writeFile(path.join(appDirectory, 'application.config.ts'), content);
};
const createBasePackageJson = async ({
appName,
appDescription,
appDirectory,
}: {
appName: string;
appDescription: string;
appDirectory: string;
}) => {
const base = JSON.parse(await readBaseApplicationProjectFile('package.json'));
@@ -52,27 +81,23 @@ const createBasePackageJson = async ({
base['$schema'] = schemas.appManifest;
base['universalIdentifier'] = randomUUID();
base['name'] = appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
base['description'] = appDescription;
base['name'] = appName;
await writeJsoncFile(join(appDirectory, 'package.json'), base);
};
const createReadmeContent = async ({
appName,
displayName,
appDescription,
appDirectory,
}: {
appName: string;
displayName: string;
appDescription: string;
appDirectory: string;
}) => {
let readmeContent = await readBaseApplicationProjectFile('README.md');
readmeContent = readmeContent.replace(/\{title}/g, appName);
readmeContent = readmeContent.replace(/\{title}/g, displayName);
readmeContent = readmeContent.replace(/\{description}/g, appDescription);
@@ -0,0 +1,15 @@
import path from 'path';
import * as fs from 'fs-extra';
export const findPathFile = async (
appPath: string,
fileName: string,
): Promise<string> => {
const jsonPath = path.join(appPath, fileName);
if (await fs.pathExists(jsonPath)) {
return jsonPath;
}
throw new Error(`${fileName} not found in ${appPath}`);
};
@@ -0,0 +1,8 @@
import { join } from 'path';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export const formatPath = (appPath?: string) => {
return appPath && !appPath?.startsWith('/')
? join(CURRENT_EXECUTION_DIRECTORY, appPath)
: appPath;
};
@@ -1,6 +1,6 @@
import camelcase from 'lodash.camelcase';
export const getDecoratedClass = ({
export const getObjectMetadataDecoratedClass = ({
data,
name,
}: {
@@ -15,7 +15,7 @@ export const getDecoratedClass = ({
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { ObjectMetadata } from 'twenty-sdk';
return `import { ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
${decoratorOptions}
@@ -0,0 +1,29 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getServerlessFunctionBaseFile = ({ name }: { name: string }) => {
const kebabCaseName = kebabCase(name);
return `import { ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = 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}\`;
return { message };
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '${v4()}',
name: '${kebabCaseName}',
timeoutSeconds: 5,
};
`;
};
@@ -43,6 +43,10 @@ export const parseJsoncString = (
return result;
};
export const parseTextFile = async (filePath: string) => {
return await fs.readFile(filePath, 'utf8');
};
export const parseJsoncFile = async (
filePath: string,
options: JsoncParseOptions = {},
@@ -0,0 +1,17 @@
import * as fs from 'fs-extra';
import dotenv from 'dotenv';
import { findPathFile } from './find-path-file';
export const loadEnvVariables = async (appPath: string) => {
let envFile = '';
try {
const envFilePath = await findPathFile(appPath, '.env');
envFile = await fs.readFile(envFilePath, 'utf8');
} catch {
// Allow missing .env
}
return dotenv.parse(envFile);
};
@@ -1,175 +0,0 @@
import {
sys,
getDecorators,
readConfigFile,
parseJsonConfigFileContent,
formatDiagnosticsWithColorAndContext,
createProgram,
Decorator,
isPropertyAccessExpression,
isNumericLiteral,
SyntaxKind,
isArrayLiteralExpression,
Expression,
isPropertyAssignment,
isComputedPropertyName,
isStringLiteralLike,
isShorthandPropertyAssignment,
isIdentifier,
Program,
Node,
isClassDeclaration,
isCallExpression,
isObjectLiteralExpression,
forEachChild,
} from 'typescript';
import { AppManifest, ObjectManifest } from '../types/config.types';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const getProgramFromTsconfig = (tsconfigPath = 'tsconfig.json') => {
const basePath = process.cwd();
const configFile = readConfigFile(tsconfigPath, sys.readFile);
if (configFile.error)
throw new Error(
formatDiagnosticsWithColorAndContext([configFile.error], {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath);
if (parsed.errors.length) {
throw new Error(
formatDiagnosticsWithColorAndContext(parsed.errors, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
}
return createProgram(parsed.fileNames, parsed.options);
};
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 (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 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'),
);
if (objectDec && isCallExpression(objectDec.expression)) {
const [firstArg] = objectDec.expression.arguments;
if (firstArg && isObjectLiteralExpression(firstArg)) {
const config = exprToValue(firstArg);
if (
config &&
typeof config === 'object' &&
!Array.isArray(config)
) {
manifest.push({
...config,
} as ObjectManifest);
}
}
}
}
forEachChild(node, visit);
};
visit(sf);
}
return manifest;
};
const validateProgram = (program: Program) => {
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
if (diagnostics.length > 0) {
const formatted = formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
});
throw new Error(`TypeScript validation failed:\n${formatted}`);
}
};
export const loadManifestFromDecorators = (): Pick<AppManifest, 'objects'> => {
const program = getProgramFromTsconfig('tsconfig.json');
validateProgram(program);
const objects = collectObjects(program);
return { objects };
};
@@ -0,0 +1,476 @@
import * as fs from 'fs-extra';
import {
sys,
getDecorators,
readConfigFile,
parseJsonConfigFileContent,
formatDiagnosticsWithColorAndContext,
createProgram,
Decorator,
isPropertyAccessExpression,
isNumericLiteral,
SyntaxKind,
isArrayLiteralExpression,
Expression,
isPropertyAssignment,
isComputedPropertyName,
isStringLiteralLike,
isShorthandPropertyAssignment,
isIdentifier,
FunctionDeclaration,
VariableDeclaration,
Program,
Node,
isClassDeclaration,
isCallExpression,
isObjectLiteralExpression,
forEachChild,
SourceFile,
isVariableStatement,
isArrowFunction,
isFunctionExpression,
isExportAssignment,
Modifier,
} from 'typescript';
import {
AppManifest,
Application,
ObjectManifest,
PackageJson,
ServerlessFunctionManifest,
Sources,
} from '../types/config.types';
import { posix, relative, sep, resolve, join } from 'path';
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
import { findPathFile } from '../utils/find-path-file';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const getProgramFromTsconfig = (
appPath?: string,
tsconfigPath = 'tsconfig.json',
) => {
const basePath = appPath ?? process.cwd();
const configFile = readConfigFile(join(basePath, tsconfigPath), sys.readFile);
if (configFile.error)
throw new Error(
formatDiagnosticsWithColorAndContext([configFile.error], {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath);
if (parsed.errors.length) {
throw new Error(
formatDiagnosticsWithColorAndContext(parsed.errors, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
}
return createProgram(parsed.fileNames, parsed.options);
};
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 (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'),
);
if (objectDec) {
const cfg = getFirstArgObject(objectDec);
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
manifest.push({ ...(cfg as any) } 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;
/**
* Finds (and validates) the new serverless file shape:
* - exactly 2 exported bindings
* - one must be `config` (typed ServerlessFunctionConfig)
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
*/
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 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) {
throw new Error(
`Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`,
);
}
// 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".`,
);
}
// 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 ServerlessFunctionConfig if present
const maybeVarDecl = configExport.declNode as VariableDeclaration;
if ('type' in maybeVarDecl && maybeVarDecl.type) {
const typeText = maybeVarDecl.type.getText(sf);
if (!/\bServerlessFunctionConfig\b/.test(typeText)) {
throw new Error(
`"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (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 = (absPath: string) => {
const rel = relative(process.cwd(), absPath);
// normalize to posix separators for portability / manifest stability
return rel.split(sep).join(posix.sep);
};
const collectServerlessFunctions = (program: Program) => {
const serverlessFunctions: ServerlessFunctionManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
try {
const { handlerName, configObject } = findHandlerAndConfig(sf);
const handlerPath = posixRelativeFromCwd(sf.fileName);
serverlessFunctions.push({
...configObject,
handlerPath,
handlerName,
});
} catch {
// Not a serverless file under the new format — ignore and continue scanning.
continue;
}
}
return serverlessFunctions;
};
const validateProgram = (program: Program) => {
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
if (diagnostics.length > 0) {
const formatted = formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
});
throw new Error(`TypeScript validation failed:\n${formatted}`);
}
};
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;
}
}
};
const loadFolderContentIntoJson = async (
sourcePath = '.',
tsconfigPath = 'tsconfig.json',
): Promise<Sources> => {
const sources: Sources = {};
const baseAbs = resolve(sourcePath);
// Build the program from tsconfig (uses your getProgramFromTsconfig)
const program: Program = getProgramFromTsconfig(baseAbs, tsconfigPath);
// 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(baseAbs + sep) && abs !== baseAbs) 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(baseAbs, 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 Application;
}
}
}
}
}
}
if (!found) forEachChild(node, visit);
};
visit(sf);
if (found) return found;
}
throw new Error('Could not find default exported ApplicationConfig');
};
export const loadManifest = async (
path?: string,
): Promise<{
packageJson: PackageJson;
yarnLock: string;
manifest: AppManifest;
}> => {
const appPath = path ?? process.cwd();
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
const yarnLock = await parseTextFile(
await findPathFile(appPath, 'yarn.lock'),
);
const program = getProgramFromTsconfig(appPath, 'tsconfig.json');
validateProgram(program);
const [objects, serverlessFunctions, application, sources] =
await Promise.all([
Promise.resolve(collectObjects(program)),
Promise.resolve(collectServerlessFunctions(program)),
Promise.resolve(extractTwentyAppConfig(program)),
loadFolderContentIntoJson(appPath),
]);
return {
packageJson,
yarnLock,
manifest: {
application,
objects,
serverlessFunctions,
sources,
},
};
};