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:
@@ -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',
|
||||
);
|
||||
Binary file not shown.
Vendored
Executable
+942
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user