Fix twenty cli (#15997)

As title

fixes "app add" and "app init" commands
adds tests
This commit is contained in:
martmull
2025-11-21 19:21:30 +01:00
committed by GitHub
parent 3b5949ec3c
commit aa5d30a911
22 changed files with 201 additions and 696 deletions
@@ -1,16 +1,12 @@
import chalk from 'chalk';
import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import { join } from 'path';
import camelcase from 'lodash.camelcase';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { getSchemaUrls } from '../utils/schema-validator';
import { BASE_SCHEMAS_PATH } from '../constants/constants-path';
import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class';
import { getObjectDecoratedClass } from '../utils/get-object-decorated-class';
import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
const ROOT_FOLDER = 'src';
import { convertToLabel } from '../utils/convert-to-label';
export enum SyncableEntity {
AGENT = 'agent',
@@ -23,35 +19,34 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => {
};
export class AppAddCommand {
async execute(entityType?: SyncableEntity): Promise<void> {
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER);
const appPath = join(CURRENT_EXECUTION_DIRECTORY, path ?? '');
await fs.ensureDir(appPath);
const entity = entityType ?? (await this.getEntity());
const entityName = await this.getEntityName(entity);
const entityData = await this.getEntityToCreateData(entity, entityName);
if (entity === SyncableEntity.OBJECT) {
delete entityData['standardId'];
delete entityData['$schema'];
const entityData = await this.getObjectData();
const objectFileName = `${camelcase(entityName)}.ts`;
const name = entityData.nameSingular;
const decoratedObject = getObjectMetadataDecoratedClass({
const objectFileName = `${camelcase(name)}.ts`;
const decoratedObject = getObjectDecoratedClass({
data: entityData,
name: entityName,
name,
});
await fs.writeFile(path.join(appPath, objectFileName), decoratedObject);
await fs.writeFile(join(appPath, objectFileName), decoratedObject);
return;
}
if (entity === SyncableEntity.SERVERLESS_FUNCTION) {
const entityName = await this.getEntityName(entity);
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
@@ -59,7 +54,7 @@ export class AppAddCommand {
});
await fs.writeFile(
path.join(appPath, objectFileName),
join(appPath, objectFileName),
decoratedServerlessFunction,
);
@@ -74,27 +69,6 @@ export class AppAddCommand {
}
}
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 }>([
{
@@ -133,58 +107,63 @@ export class AppAddCommand {
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';
}
},
private async getObjectData() {
return inquirer.prompt([
{
type: 'input',
name: 'nameSingular',
message: 'Enter a name singular for your object (eg: company):',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
]);
entityToCreateData[requiredField] = answer[requiredField];
}
return entityToCreateData;
},
{
type: 'input',
name: 'namePlural',
message: 'Enter a name plural for your object (eg: companies):',
default: '',
validate: (input: string, answers?: any) => {
if (input.trim() === answers?.nameSingular.trim()) {
return 'Name plural must be different from name singular';
}
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelSingular',
message: 'Enter a label singular for your object:',
default: (answers: any) => {
return convertToLabel(answers.nameSingular);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelPlural',
message: 'Enter a label plural for your object:',
default: (answers: any) => {
return convertToLabel(answers.namePlural);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
}
@@ -4,6 +4,7 @@ import inquirer from 'inquirer';
import * as path from 'path';
import { copyBaseApplicationProject } from '../utils/app-template';
import kebabCase from 'lodash.kebabcase';
import { convertToLabel } from '../utils/convert-to-label';
export class AppInitCommand {
async execute(directory?: string): Promise<void> {
@@ -44,7 +45,9 @@ export class AppInitCommand {
{
type: 'input',
name: 'name',
message: 'Application name (eg: my-awesome-app):',
message: 'Application name:',
when: () => !directory,
default: 'my-awesome-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
@@ -53,12 +56,9 @@ export class AppInitCommand {
{
type: 'input',
name: 'displayName',
message: 'Display name (eg: My awesome app):',
message: 'Application display name:',
default: (answers: any) => {
return answers.name
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return convertToLabel(answers?.name ?? directory);
},
},
{
@@ -69,7 +69,9 @@ export class AppInitCommand {
},
]);
const appName = name.trim();
const computedName = name ?? directory;
const appName = computedName.trim();
const appDisplayName = displayName.trim();
@@ -101,10 +101,11 @@ export class AppCommand {
appCommand
.command('add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')
.description(
`Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`,
)
.action(async (entityType?: string) => {
.action(async (entityType?: string, options?: { path?: string }) => {
if (entityType && !isSyncableEntity(entityType)) {
console.error(
chalk.red(
@@ -113,7 +114,10 @@ export class AppCommand {
);
process.exit(1);
}
await this.addCommand.execute(entityType as SyncableEntity);
await this.addCommand.execute(
entityType as SyncableEntity,
options?.path,
);
});
appCommand
@@ -6,5 +6,3 @@ export const BASE_APPLICATION_PROJECT_PATH = join(
BASE_PATH,
'base-application-project',
);
export const BASE_SCHEMAS_PATH = join(BASE_PATH, 'schemas');
@@ -1,8 +0,0 @@
const SCHEMA_BASE_URL =
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas';
export const APP_MANIFEST_SCHEMA_URL = `${SCHEMA_BASE_URL}/appManifest.schema.json`;
export const AGENT_SCHEMA_URL = `${SCHEMA_BASE_URL}/agent.schema.json`;
export const OBJECT_SCHEMA_URL = `${SCHEMA_BASE_URL}/object.schema.json`;
export const TRIGGER_SCHEMA_URL = `${SCHEMA_BASE_URL}/trigger.schema.json`;
export const SERVERLESS_FUNCTION_SCHEMA_URL = `${SCHEMA_BASE_URL}/serverlessFunction.schema.json`;
@@ -1,109 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/agent.schema.json",
"title": "Twenty Agent Manifest",
"description": "Schema for Twenty AI agent configuration files",
"type": "object",
"required": ["standardId", "universalIdentifier", "name", "label", "prompt", "modelId"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"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}$"
},
"standardId": {
"const": { "$data": "1/universalIdentifier" },
"description": "Should be the same as universalIdentifier"
},
"name": {
"type": "string",
"description": "Internal name for the agent (camelCase, used in code)",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
"minLength": 1,
"maxLength": 100
},
"label": {
"type": "string",
"description": "Human-readable display name for the agent",
"minLength": 1,
"maxLength": 200
},
"description": {
"type": "string",
"description": "Brief description of what the agent does",
"maxLength": 500
},
"icon": {
"type": "string",
"description": "Icon for the agent (emoji or icon name)",
"maxLength": 50
},
"prompt": {
"type": "string",
"description": "System prompt that defines the agent's behavior and personality",
"minLength": 10,
"maxLength": 10000
},
"modelId": {
"type": "string",
"description": "AI model to use for this agent",
"default": "auto",
"enum": [
"auto",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-3-5-haiku-20241022",
"grok-3",
"grok-3-mini",
"grok-4"
]
},
"responseFormat": {
"type": "object",
"description": "Format specification for agent responses",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"description": "Response format type",
"enum": ["text", "json"]
},
"schema": {
"type": "object",
"description": "JSON schema for structured responses (required when type is 'json')",
"additionalProperties": true
}
},
"if": {
"properties": {
"type": { "const": "json" }
}
},
"then": {
"required": ["schema"]
}
}
},
"additionalProperties": false,
"examples": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440001",
"name": "customerSupportAgent",
"label": "Customer Support Assistant",
"description": "Helps customers with their inquiries and issues",
"icon": "🎧",
"prompt": "You are a helpful customer support agent. Always be polite, professional, and solution-oriented.",
"modelId": "auto",
"responseFormat": {
"type": "text"
}
}
]
}
@@ -1,129 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"title": "Twenty App Manifest",
"description": "Schema for Twenty application manifest files",
"type": "object",
"required": ["universalIdentifier", "name", "version", "license", "engines", "packageManager"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"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}$"
},
"name": {
"type": "string",
"description": "Human-readable display name for the application",
"minLength": 1,
"maxLength": 200
},
"description": {
"type": "string",
"description": "Brief description of what the application does",
"maxLength": 1000
},
"icon": {
"type": "string",
"description": "Icon for the application (emoji or icon name)",
"maxLength": 50
},
"license": {
"const": "MIT",
"title": "The application's license",
"description": "Currently only MIT is accepted, although more licenses will probably be available in the future."
},
"env": {
"type": "object",
"title": "Environment Variables",
"description": "Key-value pairs defining environment variables available to all serverless functions.",
"patternProperties": {
"^[A-Z_][A-Z0-9_]*$": {
"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."
},
"value": {
"type": "string",
"description": "Default value for this environment variable"
},
"isSecret": {
"type": "boolean",
"description": "If true, the value will be treated as sensitive and hidden from logs or UI."
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"engines": {
"type": "object",
"title": "The application's engines",
"description": "Define engines here"
},
"packageManager": {
"const": "yarn@4.9.2",
"title": "Package manager of the application"
},
"version": {
"type": "string",
"description": "Semantic version of the application",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9-]+)?$"
},
"dependencies": {
"type": "object",
"title": "The extension's source dependencies",
"description": "Source dependencies following the npm package.json dependency format."
},
"devDependencies": {
"type": "object",
"title": "The extension's source devDependencies",
"description": "Dev dependencies following the npm package.json dependency format."
},
"agents": {
"type": "array",
"description": "Optional inline agent definitions (agents are typically discovered from the agents/ folder)",
"items": {
"type": "object",
"description": "Inline agent definition",
"$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/agent.schema.json"
}
},
"objects": {
"type": "array",
"description": "Optional inline object definitions (objects are typically discovered from the objects/ folder)",
"items": {
"type": "object",
"description": "Inline object definition",
"$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json"
}
}
},
"examples": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440000",
"name": "Customer Support App",
"description": "Comprehensive customer support application with AI agents",
"icon": "🎧",
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.9.2"
}
}
]
}
@@ -1,78 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json",
"title": "Twenty Object Manifest",
"description": "Schema for Twenty AI object configuration files",
"type": "object",
"required": [
"standardId",
"universalIdentifier",
"nameSingular",
"namePlural",
"labelSingular",
"labelPlural"
],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"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}$"
},
"standardId": {
"const": { "$data": "1/universalIdentifier" },
"description": "Should be the same as universalIdentifier"
},
"nameSingular": {
"type": "string",
"description": "Name singular for the object",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
"minLength": 1,
"maxLength": 100
},
"namePlural": {
"type": "string",
"description": "Name plural for the object",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
"minLength": 1,
"maxLength": 100
},
"labelSingular": {
"type": "string",
"description": "Human-readable display name singular for the object",
"minLength": 1,
"maxLength": 200
},
"labelPlural": {
"type": "string",
"description": "Human-readable display name singular for the object",
"minLength": 1,
"maxLength": 200
},
"description": {
"type": "string",
"description": "Brief description of the object",
"maxLength": 500
},
"icon": {
"type": "string",
"description": "Icon for the object (emoji or icon name)",
"maxLength": 50
}
},
"additionalProperties": false,
"examples": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440001",
"nameSingular": "object",
"namePlural": "objects",
"labelSingular": "Object",
"labelPlural": "Objects",
"description": "Object description",
"icon": "🎧"
}
]
}
@@ -1,107 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"title": "Twenty Serverless Function Manifest",
"description": "Schema for Twenty AI serverless function configuration files",
"type": "object",
"required": ["universalIdentifier"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"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}$"
},
"name": {
"type": "string",
"description": "Name singular for the serverless function (eg: my-serverless-function)",
"pattern": "^[a-z0-9-]+$",
"minLength": 1,
"maxLength": 100
},
"description": {
"type": "string",
"description": "Brief description of the serverless function",
"maxLength": 500
},
"timeoutSeconds": {
"type": "number",
"description": "Serverless function timeout in seconds, between 1 and 900",
"min": 1,
"max": 900
},
"triggers": {
"type": "array",
"description": "Serverless function's triggers",
"items": {
"anyOf": [
{ "$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/trigger.schema.json" },
{
"type": "object",
"required": ["$ref"],
"properties": { "$ref": { "type": "string" } },
"additionalProperties": false
}
]
}
},
"code": {
"type": "object",
"description": "Serverless function's code",
"required": ["src"],
"properties": {
"src": {
"type": "object",
"description":"Serverless function source folder",
"required": ["index.ts"],
"properties": {
"index.ts": {
"type": "string",
"description":"Serverless function index.ts file"
},
"additionalProperties": true
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"examples": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440001",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440001",
"name": "My serverless function",
"triggers": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440002",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440002",
"type": "cron",
"schedule": "0 9 * * *"
},
{
"standardId": "550e8400-e29b-41d4-a716-446655440003",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440003",
"type": "databaseEvent",
"eventName": "company.created"
},
{
"standardId": "550e8400-e29b-41d4-a716-446655440004",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440004",
"type": "route",
"path": "test-route",
"httpMethod": "GET",
"isAuthRequired": false
}
],
"code": {
"src": {
"index.ts": "{\n \"code\": \"import axios from 'axios';\\n\\nexport const main = async (params) => {\\n const { a, b } = params;\\n const message = \\\"toto\\\";\\n return { message };\\n};\",\n \"params\": { \"a\": \"1\", \"b\": 2 }\n}\n"
}
}
}
]
}
@@ -1,43 +0,0 @@
{
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/trigger.schema.json",
"type": "object",
"required": ["universalIdentifier", "type"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"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}$"
},
"type": { "enum": ["cron", "databaseEvent", "route"] },
"pattern": { "type": "string" },
"eventName": { "type": "string" },
"path": { "type": "string" },
"httpMethod": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"isAuthRequired": { "type": "boolean" }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "cron" } } },
"then": { "required": ["pattern"] }
},
{
"if": { "properties": { "type": { "const": "databaseEvent" } } },
"then": { "required": ["eventName"] }
},
{
"if": { "properties": { "type": { "const": "route" } } },
"then": { "required": ["path", "httpMethod", "isAuthRequired"] }
}
]
}
@@ -0,0 +1,10 @@
import { convertToLabel } from '../convert-to-label';
describe('convertToLabel', () => {
it('should convert to label', () => {
expect(convertToLabel('toto')).toBe('Toto');
expect(convertToLabel('totoTata')).toBe('Toto tata');
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
});
});
@@ -1,21 +0,0 @@
import { getObjectMetadataDecoratedClass } from '../../utils/get-object-metadata-decorated-class';
describe('getDecoratedClass', () => {
it('should return properly formatted class', () => {
const result = getObjectMetadataDecoratedClass({
data: { nameSingular: 'Name', namePlural: 'Names' },
name: 'MyNewObject',
});
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
nameSingular: 'Name',
namePlural: 'Names',
})
export class MyNewObject {}
`;
expect(result).toEqual(expectedResult);
});
});
@@ -0,0 +1,30 @@
import { getObjectDecoratedClass } from '../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/application';
@Object({
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',
nameSingular: 'name',
namePlural: 'names',
labelSingular: 'Name',
labelPlural: 'Names',
})
export class MyNewObject {}
`,
);
});
});
@@ -0,0 +1,34 @@
import { getServerlessFunctionBaseFile } from '../get-serverless-function-base-file';
describe('getServerlessFunctionBaseFile', () => {
it('should render proper file', () => {
expect(
getServerlessFunctionBaseFile({
name: 'serverless-function-name',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
}),
)
.toBe(`import { type 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: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
name: 'serverless-function-name',
timeoutSeconds: 5,
};
`);
});
});
@@ -1,5 +1,3 @@
import { randomUUID } from 'crypto';
import { getSchemaUrls } from './schema-validator';
import * as fs from 'fs-extra';
import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path';
import { writeJsoncFile } from '../utils/jsonc-parser';
@@ -77,10 +75,7 @@ const createBasePackageJson = async ({
}) => {
const base = JSON.parse(await readBaseApplicationProjectFile('package.json'));
const schemas = getSchemaUrls();
base['$schema'] = schemas.appManifest;
base['universalIdentifier'] = randomUUID();
base['universalIdentifier'] = v4();
base['name'] = appName;
await writeJsoncFile(join(appDirectory, 'package.json'), base);
@@ -0,0 +1,6 @@
import { startCase } from 'lodash';
export const convertToLabel = (str: string) => {
const s = startCase(str).toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};
@@ -1,6 +1,6 @@
import camelcase from 'lodash.camelcase';
export const getObjectMetadataDecoratedClass = ({
export const getObjectDecoratedClass = ({
data,
name,
}: {
@@ -15,9 +15,9 @@ export const getObjectMetadataDecoratedClass = ({
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { ObjectMetadata } from 'twenty-sdk/application';
return `import { Object } from 'twenty-sdk/application';
@ObjectMetadata({
@Object({
${decoratorOptions}
})
export class ${className} {}
@@ -1,7 +1,13 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getServerlessFunctionBaseFile = ({ name }: { name: string }) => {
export const getServerlessFunctionBaseFile = ({
name,
universalIdentifier = v4(),
}: {
name: string;
universalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
return `import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
@@ -20,7 +26,7 @@ export const main = async (params: {
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '${v4()}',
universalIdentifier: '${universalIdentifier}',
name: '${kebabCaseName}',
timeoutSeconds: 5,
};
@@ -1,83 +0,0 @@
import Ajv from 'ajv';
import * as fs from 'fs-extra';
import * as path from 'path';
import {
AGENT_SCHEMA_URL,
APP_MANIFEST_SCHEMA_URL,
OBJECT_SCHEMA_URL,
SERVERLESS_FUNCTION_SCHEMA_URL,
TRIGGER_SCHEMA_URL,
} from '../constants/schemas';
import { BASE_SCHEMAS_PATH } from '../constants/constants-path';
export class SchemaValidationError extends Error {
constructor(
message: string,
public readonly errors: any[],
public readonly filePath?: string,
) {
super(message);
this.name = 'SchemaValidationError';
}
}
const formatErrors = (errors: any[]): string => {
return errors
.map((error) => {
const path = error.instancePath || 'root';
const message = error.message;
const value =
error.data !== undefined ? ` (got: ${JSON.stringify(error.data)})` : '';
return `${path}: ${message}${value}`;
})
.join('\n');
};
export const validateSchema = async (
schemaName: 'appManifest' | 'agent' | 'object' | 'serverlessFunction',
manifest: any,
filePath?: string,
): Promise<void> => {
const ajv = new Ajv({
allErrors: true,
verbose: true,
strict: false,
$data: true,
});
const schemaUrls = getSchemaUrls();
let schema;
for (const name of Object.keys(schemaUrls) as (keyof typeof schemaUrls)[]) {
const schemaPath = path.join(BASE_SCHEMAS_PATH, `${name}.schema.json`);
ajv.addSchema(await fs.readJson(schemaPath));
if (name === schemaName) {
schema = ajv.getSchema(schemaUrls[name])?.schema;
}
}
if (!schema) throw new Error(`Schema ${schemaName} not found.`);
const valid = ajv.validate(schema, manifest);
if (!valid) {
const errorMessages = formatErrors(ajv.errors || []);
throw new SchemaValidationError(
`${schemaName} validation failed:\n${errorMessages}`,
ajv.errors || [],
filePath,
);
}
};
export const getSchemaUrls = () => {
return {
trigger: TRIGGER_SCHEMA_URL,
agent: AGENT_SCHEMA_URL,
object: OBJECT_SCHEMA_URL,
serverlessFunction: SERVERLESS_FUNCTION_SCHEMA_URL,
appManifest: APP_MANIFEST_SCHEMA_URL,
};
};