Files
twenty/packages/twenty-cli/src/commands/app-add.command.ts
T
martmullandGitHub 033c28a3d5 1750 extensibility twenty sdk v2 use twenty sdk to define an object (#15230)
We maintain jsonc object definition but will deprecate them pretty soon

## Before
<img width="1512" height="575" alt="image"
src="https://github.com/user-attachments/assets/d2fa6ca4-c456-4aa9-a1e3-845b61839718"
/>

## After
<img width="1260" height="555" alt="image"
src="https://github.com/user-attachments/assets/ba72f4cf-d443-4967-913c-029bc71f3f48"
/>
2025-10-22 13:18:23 +00:00

390 lines
10 KiB
TypeScript

import chalk from 'chalk';
import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
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';
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);
};
export class AppAddCommand {
async execute(entityType?: SyncableEntity): Promise<void> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
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);
if (entity === SyncableEntity.OBJECT) {
delete entityData['standardId'];
delete entityData['$schema'];
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedObject = getDecoratedClass({
data: entityData,
name: entityName,
});
await fs.writeFile(path.join(appPath, objectFileName), decoratedObject);
return;
}
const folderName = getFolderName(entity);
const entitiesDir = path.join(appPath, folderName, entityName);
await fs.ensureDir(entitiesDir);
await writeJsoncFile(
path.join(entitiesDir, `${entity}.manifest.jsonc`),
entityData,
);
await this.addEntityInitFiles(entity, entitiesDir);
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async addEntityInitFiles(entity: SyncableEntity, entityPath: string) {
switch (entity) {
case SyncableEntity.SERVERLESS_FUNCTION: {
const srcPath = path.join(entityPath, 'src');
await fs.ensureDir(srcPath);
await fs.writeFile(
path.join(srcPath, 'index.ts'),
'export const main = async (params: {\n a: string;\n b: number;\n}): Promise<object> => {\n const { a, b } = params;\n\n // Rename the parameters and code below with your own logic\n // This is just an example\n const message = `Hello, input: ${a} and ${b}`;\n\n\n\n return { message };\n};',
);
return;
}
case SyncableEntity.AGENT:
case SyncableEntity.OBJECT:
return;
default:
throw new Error(`Unknown entity type: ${entity}`);
}
}
private async getEntity() {
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [
SyncableEntity.SERVERLESS_FUNCTION,
SyncableEntity.OBJECT,
SyncableEntity.TRIGGER,
],
},
]);
return entity;
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt<{ name: string }>([
{
type: 'input',
name: 'name',
message: `Enter a name for your new ${entity}:`,
default: '',
validate: (input) => {
if (input.length === 0) {
return `${entity} name is required`;
}
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Name must contain only lowercase letters, numbers, and hyphens';
}
return true;
},
},
]);
return name;
}
private async getEntityToCreateData(
entity: SyncableEntity,
entityName: string,
) {
const schemas = getSchemaUrls();
const uuid = randomUUID();
const entityToCreateData: Record<string, string> = {
$schema: schemas[entity],
universalIdentifier: uuid,
};
if (entity === SyncableEntity.OBJECT || entity === SyncableEntity.AGENT) {
entityToCreateData.standardId = uuid;
}
const schemaPath = path.join(BASE_SCHEMAS_PATH, `${entity}.schema.json`);
const schema = await fs.readJson(schemaPath);
const requiredFields = schema.required;
for (const requiredField of requiredFields) {
if (requiredField === 'name') {
entityToCreateData.name = entityName;
continue;
}
if (Object.keys(entityToCreateData).includes(requiredField)) {
continue;
}
const answer = await inquirer.prompt<{ [key: string]: string }>([
{
type: 'input',
name: requiredField,
message: `Enter a ${requiredField} for your new ${entity}:`,
default: '',
validate: (input) => {
try {
return input.length > 0;
} catch {
return 'Please enter non empty string';
}
},
},
]);
entityToCreateData[requiredField] = answer[requiredField];
}
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,
};
}
}