Fix twenty cli (#15997)
As title fixes "app add" and "app init" commands adds tests
This commit is contained in:
@@ -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);
|
||||
};
|
||||
+3
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user