1574 extensibility twenty cli use workspace migration v2 to synchronize serverless function triggers (#14830)

twenty-cli serverless triggers follow up. Fixes:
- eventName don't support wildcard
- universalIdentifier not used to create or update trigger : update does
not work properly (does deletion then creation)
- add a base project in twenty-cli that is copied when creating a new
app
This commit is contained in:
martmull
2025-10-02 14:06:56 +02:00
committed by GitHub
parent 326d2a99ed
commit 0368d2a094
24 changed files with 1143 additions and 407 deletions
@@ -2,7 +2,7 @@ import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import { v4 } from 'uuid';
import { randomUUID } from 'crypto';
import { resolveAppPath } from '../utils/app-path-resolver';
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
@@ -141,7 +141,7 @@ export class AppAddCommand {
) {
const schemas = getSchemaUrls();
const uuid = v4();
const uuid = randomUUID();
const entityToCreateData: Record<string, string> = {
$schema: schemas[entity],
@@ -255,7 +255,7 @@ export class AppAddCommand {
}
private async createDatabaseEventTrigger() {
const uuid = v4();
const uuid = randomUUID();
const { eventName } = await inquirer.prompt([
{
@@ -266,8 +266,8 @@ export class AppAddCommand {
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)';
if (!/^(?:[a-zA-Z]+|\*)\.(created|updated|deleted|\*)$/.test(input)) {
return 'Event name must be in format: (objectName|*).(created|updated|deleted|*)';
}
return true;
},
@@ -282,7 +282,7 @@ export class AppAddCommand {
}
private async createCronTrigger() {
const uuid = v4();
const uuid = randomUUID();
const { schedule } = await inquirer.prompt([
{
@@ -2,27 +2,28 @@ import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import * as path from 'path';
import {
createBasePackageJson,
createGitignoreContent,
createReadmeContent,
} from '../utils/app-template';
import { writeJsoncFile } from '../utils/jsonc-parser';
import { copyBaseApplicationProject } from '../utils/app-template';
import kebabCase from 'lodash.kebabcase';
export class AppInitCommand {
async execute(options: { path?: string; name?: string }): Promise<void> {
async execute(options: { path?: string }): Promise<void> {
try {
const { name, description } = await this.getAppInfos(options.name);
const { appName, appDirectory, appDescription } =
await this.getAppInfos(options);
const appDir = this.determineAppDirectory(name, options.path);
await this.validateDirectory(appDirectory);
await this.validateDirectory(appDir);
this.logCreationInfo({ appDirectory, appName });
this.logCreationInfo(appDir, name);
await fs.ensureDir(appDirectory);
await this.createAppStructure(appDir, name, description);
await copyBaseApplicationProject({
appName,
appDescription,
appDirectory,
});
this.logSuccess(appDir);
this.logSuccess(appDirectory);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
@@ -32,22 +33,18 @@ export class AppInitCommand {
}
}
private async getAppInfos(
providedName?: string,
): Promise<{ name: string; description: string }> {
if (providedName) {
return { name: providedName, description: '' };
}
return inquirer.prompt([
private async getAppInfos(options: { path?: string }): Promise<{
appName: string;
appDirectory: string;
appDescription: string;
}> {
const { name, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name:',
message: 'Application name (eg: My awesome application):',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
if (!/^[a-z0-9-]+$/.test(input))
return 'Name must contain only lowercase letters, numbers, and hyphens';
return true;
},
},
@@ -58,66 +55,49 @@ export class AppInitCommand {
default: '',
},
]);
const appName = name.trim();
const appDescription = description.trim();
const appDirectory = options.path
? path.resolve(options.path, kebabCase(appName))
: path.join(process.cwd(), kebabCase(appName)!);
return { appName, appDirectory, appDescription };
}
private determineAppDirectory(
appName: string,
providedPath?: string,
): string {
if (providedPath) {
return path.resolve(providedPath, appName);
}
return path.join(process.cwd(), appName!);
}
private async validateDirectory(appDir: string): Promise<void> {
if (!(await fs.pathExists(appDir))) {
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
}
const files = await fs.readdir(appDir);
const files = await fs.readdir(appDirectory);
if (files.length > 0) {
throw new Error(`Directory ${appDir} already exists and is not empty`);
throw new Error(
`Directory ${appDirectory} already exists and is not empty`,
);
}
}
private logCreationInfo(appDir: string, appName: string): void {
private logCreationInfo({
appDirectory,
appName,
}: {
appDirectory: string;
appName: string;
}): void {
console.log(chalk.blue('🎯 Creating Twenty Application'));
console.log(chalk.gray(`📁 Directory: ${appDir}`));
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
console.log(chalk.gray(`📝 Name: ${appName}`));
console.log('');
}
private async createAppStructure(
appDir: string,
appName: string,
description: string,
): Promise<void> {
await fs.ensureDir(appDir);
// Create main basePackageJson with agent references
const basePackageJson = createBasePackageJson(appName, description);
const basePackageJsonPath = path.join(appDir, 'package.json');
await writeJsoncFile(basePackageJsonPath, basePackageJson);
// Create README
const readmeContent = createReadmeContent(appName, appDir);
await fs.writeFile(path.join(appDir, 'README.md'), readmeContent);
// Create empty yarn.lock
await fs.writeFile(path.join(appDir, 'yarn.lock'), '');
// Create .gitignore
const gitignoreContent = createGitignoreContent();
await fs.writeFile(path.join(appDir, '.gitignore'), gitignoreContent);
}
private logSuccess(appDir: string): void {
private logSuccess(appDirectory: string): void {
console.log(chalk.green('✅ Application created successfully!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(` cd ${appDir}`);
console.log(` cd ${appDirectory}`);
console.log(' twenty app dev');
}
}
@@ -41,7 +41,6 @@ export class AppCommand {
.command('init')
.description('Initialize a new Twenty application')
.option('-p, --path <path>', 'Directory to create the application in')
.option('-n, --name <name>', 'Application name')
.action(async (options) => {
await this.initCommand.execute(options);
});
@@ -0,0 +1,6 @@
import { join } from 'path';
export const BASE_APPLICATION_PROJECT_PATH = join(
__dirname,
'../constants/base-application-project',
);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -0,0 +1,15 @@
# {title}
{description}
## Development
```bash
twenty app dev --path {appDir}
```
### Deployment
```bash
twenty app sync --path {appDir}
```
@@ -0,0 +1,10 @@
{
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2"
}
@@ -1,134 +0,0 @@
import {
createAgentManifest,
createBasePackageJson,
createReadmeContent,
} from '../app-template';
import {
AGENT_SCHEMA_URL,
APP_MANIFEST_SCHEMA_URL,
} from '../../constants/schemas';
// Mock crypto.randomUUID to make tests deterministic
jest.mock('crypto', () => ({
randomUUID: jest.fn(() => 'mocked-uuid-12345'),
}));
describe('app-template', () => {
describe('createBasePackageJson', () => {
it('should create a valid app package.json with correct structure', () => {
const appName = 'my-test-app';
const description = 'A Twenty application for my-test-app';
const basePackageJson = createBasePackageJson(appName, description);
expect(basePackageJson).toEqual({
$schema: APP_MANIFEST_SCHEMA_URL,
universalIdentifier: 'mocked-uuid-12345',
label: 'My Test App',
description: 'A Twenty application for my-test-app',
version: '0.0.1',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
license: 'MIT',
});
});
it('should handle single word app names', () => {
const appName = 'calculator';
const basePackageJson = createBasePackageJson(appName, '');
expect(basePackageJson.label).toBe('Calculator');
expect(basePackageJson.universalIdentifier).toBe('mocked-uuid-12345');
});
it('should handle kebab-case app names correctly', () => {
const appName = 'user-management-system';
const basePackageJson = createBasePackageJson(appName, '');
expect(basePackageJson.label).toBe('User Management System');
expect(basePackageJson.universalIdentifier).toBe('mocked-uuid-12345');
});
it('should generate unique universalIdentifiers', () => {
const basePackageJson = createBasePackageJson('test-app', '');
expect(basePackageJson.universalIdentifier).toBeDefined();
expect(typeof basePackageJson.universalIdentifier).toBe('string');
});
});
describe('createAgentManifest', () => {
it('should create a valid agent manifest with correct structure', () => {
const appName = 'my-test-app';
const agent = createAgentManifest(appName);
expect(agent).toEqual({
$schema: AGENT_SCHEMA_URL,
standardId: 'mocked-uuid-12345',
name: 'myTestAppAgent',
label: 'My Test App Agent',
description: 'AI agent for my-test-app',
prompt:
'You are an AI agent for my-test-app. Help users with their tasks and provide assistance with Twenty CRM features.',
modelId: 'auto',
responseFormat: {
type: 'text',
},
});
});
it('should handle single word app names', () => {
const appName = 'calculator';
const agent = createAgentManifest(appName);
expect(agent.name).toBe('calculatorAgent');
expect(agent.label).toBe('Calculator Agent');
});
it('should handle kebab-case app names correctly', () => {
const appName = 'user-management-system';
const agent = createAgentManifest(appName);
expect(agent.name).toBe('userManagementSystemAgent');
expect(agent.label).toBe('User Management System Agent');
});
});
describe('createReadmeContent', () => {
it('should generate correct README content', () => {
const appName = 'my-awesome-app';
const appDir = '/path/to/my-awesome-app';
const readmeContent = createReadmeContent(appName, appDir);
expect(readmeContent).toContain('# my-awesome-app');
expect(readmeContent).toContain('A Twenty application.');
expect(readmeContent).toContain(
'twenty app dev --path /path/to/my-awesome-app',
);
expect(readmeContent).toContain('cd /path/to/my-awesome-app');
expect(readmeContent).toContain(
'twenty app deploy --path /path/to/my-awesome-app',
);
});
it('should include development and deployment sections', () => {
const readmeContent = createReadmeContent('test-app', '/test/path');
expect(readmeContent).toContain('## Development');
expect(readmeContent).toContain('## Deployment');
expect(readmeContent).toContain('To start development mode:');
expect(readmeContent).toContain('To deploy the application:');
});
it('should handle different app directories', () => {
const appName = 'sample-app';
const appDir = '/custom/directory/sample-app';
const readmeContent = createReadmeContent(appName, appDir);
expect(readmeContent).toContain('/custom/directory/sample-app');
});
});
});
+71 -76
View File
@@ -1,87 +1,82 @@
import { randomUUID } from 'crypto';
import { AgentManifest, PackageJson } from '../types/config.types';
import { getSchemaUrls } from './schema-validator';
import * as fs from 'fs-extra';
import { BASE_APPLICATION_PROJECT_PATH } from '../constants/base-application-project-path';
import { writeJsoncFile } from '../utils/jsonc-parser';
import { join } from 'path';
import path from 'path';
export const copyBaseApplicationProject = async ({
appName,
appDescription,
appDirectory,
}: {
appName: string;
appDescription: string;
appDirectory: string;
}) => {
await fs.copy(BASE_APPLICATION_PROJECT_PATH, appDirectory);
await createBasePackageJson({
appName,
appDescription,
appDirectory,
});
await createReadmeContent({
appName,
appDescription,
appDirectory,
});
};
const createBasePackageJson = async ({
appName,
appDescription,
appDirectory,
}: {
appName: string;
appDescription: string;
appDirectory: string;
}) => {
const base = JSON.parse(await readBaseApplicationProjectFile('package.json'));
export const createBasePackageJson = (
appName: string,
description: string,
): PackageJson => {
const schemas = getSchemaUrls();
return {
$schema: schemas.appManifest,
universalIdentifier: randomUUID(),
label: appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
description,
license: 'MIT',
version: '0.0.1',
};
base['$schema'] = schemas.appManifest;
base['universalIdentifier'] = randomUUID();
base['name'] = appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
base['description'] = appDescription;
await writeJsoncFile(join(appDirectory, 'package.json'), base);
};
export const createAgentManifest = (appName: string): AgentManifest => {
const schemas = getSchemaUrls();
const createReadmeContent = async ({
appName,
appDescription,
appDirectory,
}: {
appName: string;
appDescription: string;
appDirectory: string;
}) => {
let readmeContent = await readBaseApplicationProjectFile('README.md');
return {
$schema: schemas.agent,
standardId: randomUUID(),
name: `${appName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())}Agent`,
label: `${appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')} Agent`,
description: `AI agent for ${appName}`,
prompt: `You are an AI agent for ${appName}. Help users with their tasks and provide assistance with Twenty CRM features.`,
modelId: 'auto',
responseFormat: {
type: 'text',
},
};
readmeContent = readmeContent.replace(/\{title}/g, appName);
readmeContent = readmeContent.replace(/\{description}/g, appDescription);
readmeContent = readmeContent.replace(/\{appDir}/g, appDirectory);
await fs.writeFile(path.join(appDirectory, 'README.md'), readmeContent);
};
export const createGitignoreContent = () => {
return `node_modules
.yarn/install-state.gz
`;
};
export const createReadmeContent = (
appName: string,
appDir: string,
): string => {
return `# ${appName}
A Twenty application.
## Development
To start development mode:
\`\`\`bash
twenty app dev --path ${appDir}
\`\`\`
Or from the app directory:
\`\`\`bash
cd ${appDir}
twenty app dev
\`\`\`
## Deployment
To deploy the application:
\`\`\`bash
twenty app deploy --path ${appDir}
\`\`\`
`;
const readBaseApplicationProjectFile = async (fileName: string) => {
return await fs.readFile(
join(BASE_APPLICATION_PROJECT_PATH, fileName),
'utf-8',
);
};