1571 extensibility twenty cli add command to create base entities like object agent or serverless (#14722)
Remove default agent when init new app add command to create a new entity (object or agent) https://github.com/user-attachments/assets/b8f28281-2b40-4eaa-a958-55d2dd5183c8
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import chalk from 'chalk';
|
||||
import { resolveAppPath } from '../utils/app-path-resolver';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import { v4 } from 'uuid';
|
||||
import path from 'path';
|
||||
import { getSchemaUrls } from '../utils/schema-validator';
|
||||
import { writeJsoncFile } from '../utils/jsonc-parser';
|
||||
|
||||
type SyncableEntity = 'agent' | 'object';
|
||||
|
||||
const getFolderName = (entity: SyncableEntity) => {
|
||||
switch (entity) {
|
||||
case 'agent':
|
||||
return 'agents';
|
||||
case 'object':
|
||||
return 'objects';
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${entity}`);
|
||||
}
|
||||
};
|
||||
|
||||
export class AppAddCommand {
|
||||
async execute(options: { path?: string }): Promise<void> {
|
||||
const entity = await this.getEntity();
|
||||
|
||||
try {
|
||||
const appPath = await resolveAppPath(options.path);
|
||||
|
||||
const appExists = await fs.pathExists(appPath);
|
||||
|
||||
if (!appExists) {
|
||||
console.error(chalk.red('App does not exist'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entityName = await this.getEntityName(entity);
|
||||
|
||||
const entityData = await this.getEntityToCreateData(entity, entityName);
|
||||
|
||||
const folderName = getFolderName(entity);
|
||||
|
||||
const entitiesDir = path.join(appPath, folderName);
|
||||
|
||||
await fs.ensureDir(entitiesDir);
|
||||
|
||||
const entityPath = path.join(entitiesDir, `${entityName}.jsonc`);
|
||||
|
||||
await writeJsoncFile(entityPath, entityData);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Add new ${entity} failed:`),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEntity() {
|
||||
const { entity } = await inquirer.prompt([
|
||||
{
|
||||
type: 'select',
|
||||
name: 'entity',
|
||||
message: `What entity do you want to create?`,
|
||||
default: '',
|
||||
choices: ['agent', 'object'],
|
||||
},
|
||||
]);
|
||||
|
||||
return entity as SyncableEntity;
|
||||
}
|
||||
|
||||
private async getEntityName(entity: SyncableEntity) {
|
||||
const { name } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: `Enter a name for your new ${entity}:`,
|
||||
default: '',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return `${entity} name is required`;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(input)) {
|
||||
return 'Name must contain only lowercase letters, numbers, and hyphens';
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private async getEntityToCreateData(
|
||||
entity: SyncableEntity,
|
||||
entityName: string,
|
||||
) {
|
||||
const schemas = getSchemaUrls();
|
||||
|
||||
const entityToCreateData: Record<string, string> = {
|
||||
$schema: schemas[entity],
|
||||
standardId: v4(),
|
||||
};
|
||||
|
||||
const schemasDir = path.join(__dirname, '../../schemas');
|
||||
|
||||
const schemaPath = path.join(schemasDir, `${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([
|
||||
{
|
||||
type: 'input',
|
||||
name: requiredField,
|
||||
message: `Enter a ${requiredField} for your new ${entity}:`,
|
||||
default: '',
|
||||
validate: (input) => {
|
||||
try {
|
||||
return input.length > 0;
|
||||
} catch {
|
||||
return 'Please enter non empty string';
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
entityToCreateData[requiredField] = answer[requiredField];
|
||||
}
|
||||
|
||||
return entityToCreateData;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
createAgentManifest,
|
||||
createBasePackageJson,
|
||||
createGitignoreContent,
|
||||
createReadmeContent,
|
||||
@@ -13,14 +12,15 @@ import { writeJsoncFile } from '../utils/jsonc-parser';
|
||||
export class AppInitCommand {
|
||||
async execute(options: { path?: string; name?: string }): Promise<void> {
|
||||
try {
|
||||
const appName = await this.getAppName(options.name);
|
||||
const appDir = this.determineAppDirectory(options.path, appName);
|
||||
const { name, description } = await this.getAppInfos(options.name);
|
||||
|
||||
const appDir = this.determineAppDirectory(name, options.path);
|
||||
|
||||
await this.validateDirectory(appDir);
|
||||
|
||||
this.logCreationInfo(appDir, appName);
|
||||
this.logCreationInfo(appDir, name);
|
||||
|
||||
await this.createAppStructure(appDir, appName);
|
||||
await this.createAppStructure(appDir, name, description);
|
||||
|
||||
this.logSuccess(appDir);
|
||||
} catch (error) {
|
||||
@@ -32,15 +32,17 @@ export class AppInitCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppName(providedName?: string): Promise<string> {
|
||||
private async getAppInfos(
|
||||
providedName?: string,
|
||||
): Promise<{ name: string; description: string }> {
|
||||
if (providedName) {
|
||||
return providedName;
|
||||
return { name: providedName, description: '' };
|
||||
}
|
||||
|
||||
const nameAnswer = await inquirer.prompt([
|
||||
return inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'appName',
|
||||
name: 'name',
|
||||
message: 'Application name:',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
@@ -49,17 +51,21 @@ export class AppInitCommand {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
message: 'Application description (optional):',
|
||||
default: '',
|
||||
},
|
||||
]);
|
||||
|
||||
return nameAnswer.appName;
|
||||
}
|
||||
|
||||
private determineAppDirectory(
|
||||
appName: string,
|
||||
providedPath?: string,
|
||||
appName?: string,
|
||||
): string {
|
||||
if (providedPath) {
|
||||
return path.resolve(providedPath);
|
||||
return path.resolve(providedPath, appName);
|
||||
}
|
||||
|
||||
return path.join(process.cwd(), appName!);
|
||||
@@ -86,24 +92,15 @@ export class AppInitCommand {
|
||||
private async createAppStructure(
|
||||
appDir: string,
|
||||
appName: string,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
await fs.ensureDir(appDir);
|
||||
|
||||
// Create agents directory
|
||||
const agentsDir = path.join(appDir, 'agents');
|
||||
await fs.ensureDir(agentsDir);
|
||||
|
||||
// Create main basePackageJson with agent references
|
||||
const basePackageJson = createBasePackageJson(appName);
|
||||
const basePackageJson = createBasePackageJson(appName, description);
|
||||
const basePackageJsonPath = path.join(appDir, 'package.json');
|
||||
await writeJsoncFile(basePackageJsonPath, basePackageJson);
|
||||
|
||||
// Create agent basePackageJson file
|
||||
const agentManifest = createAgentManifest(appName);
|
||||
const agentFileName = `${appName}-agent`;
|
||||
const agentPath = path.join(agentsDir, `${agentFileName}.jsonc`);
|
||||
await writeJsoncFile(agentPath, agentManifest);
|
||||
|
||||
// Create README
|
||||
const readmeContent = createReadmeContent(appName, appDir);
|
||||
await fs.writeFile(path.join(appDir, 'README.md'), readmeContent);
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Command } from 'commander';
|
||||
import { AppSyncCommand } from './app-sync.command';
|
||||
import { AppDevCommand } from './app-dev.command';
|
||||
import { AppInitCommand } from './app-init.command';
|
||||
import { AppAddCommand } from './app-add.command';
|
||||
|
||||
export class AppCommand {
|
||||
private devCommand = new AppDevCommand();
|
||||
private syncCommand = new AppSyncCommand();
|
||||
private initCommand = new AppInitCommand();
|
||||
private addCommand = new AppAddCommand();
|
||||
|
||||
getCommand(): Command {
|
||||
const appCommand = new Command('app');
|
||||
@@ -44,6 +46,17 @@ export class AppCommand {
|
||||
await this.initCommand.execute(options);
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('add')
|
||||
.description('Add a new entity to your application')
|
||||
.option(
|
||||
'-p, --path <path>',
|
||||
'Application directory path (auto-detected if not specified)',
|
||||
)
|
||||
.action(async (options) => {
|
||||
await this.addCommand.execute(options);
|
||||
});
|
||||
|
||||
return appCommand;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ describe('app-template', () => {
|
||||
describe('createBasePackageJson', () => {
|
||||
it('should create a valid app package.json with correct structure', () => {
|
||||
const appName = 'my-test-app';
|
||||
const basePackageJson = createBasePackageJson(appName);
|
||||
const description = 'A Twenty application for my-test-app';
|
||||
const basePackageJson = createBasePackageJson(appName, description);
|
||||
|
||||
expect(basePackageJson).toEqual({
|
||||
$schema:
|
||||
@@ -27,7 +28,7 @@ describe('app-template', () => {
|
||||
|
||||
it('should handle single word app names', () => {
|
||||
const appName = 'calculator';
|
||||
const basePackageJson = createBasePackageJson(appName);
|
||||
const basePackageJson = createBasePackageJson(appName, '');
|
||||
|
||||
expect(basePackageJson.label).toBe('Calculator');
|
||||
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
|
||||
@@ -35,14 +36,14 @@ describe('app-template', () => {
|
||||
|
||||
it('should handle kebab-case app names correctly', () => {
|
||||
const appName = 'user-management-system';
|
||||
const basePackageJson = createBasePackageJson(appName);
|
||||
const basePackageJson = createBasePackageJson(appName, '');
|
||||
|
||||
expect(basePackageJson.label).toBe('User Management System');
|
||||
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
|
||||
});
|
||||
|
||||
it('should generate unique standardIds', () => {
|
||||
const basePackageJson = createBasePackageJson('test-app');
|
||||
const basePackageJson = createBasePackageJson('test-app', '');
|
||||
|
||||
expect(basePackageJson.standardId).toBeDefined();
|
||||
expect(typeof basePackageJson.standardId).toBe('string');
|
||||
|
||||
@@ -2,7 +2,10 @@ import { randomUUID } from 'crypto';
|
||||
import { AgentManifest, PackageJson } from '../types/config.types';
|
||||
import { getSchemaUrls } from './schema-validator';
|
||||
|
||||
export const createBasePackageJson = (appName: string): PackageJson => {
|
||||
export const createBasePackageJson = (
|
||||
appName: string,
|
||||
description: string,
|
||||
): PackageJson => {
|
||||
const schemas = getSchemaUrls();
|
||||
|
||||
return {
|
||||
@@ -12,7 +15,7 @@ export const createBasePackageJson = (appName: string): PackageJson => {
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' '),
|
||||
description: `A Twenty application for ${appName}`,
|
||||
description,
|
||||
version: '0.0.1',
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user