First Application POC (#14382)
Quick proof of concept for twenty-apps + twenty-cli, with local development / hot reload Let's discuss it! https://github.com/user-attachments/assets/c6789936-cd5f-4110-a265-863a6ac1af2d
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { Command } from 'commander';
|
||||
import { AppCommand } from './commands/app.command';
|
||||
import { AuthCommand } from './commands/auth.command';
|
||||
import { ConfigCommand } from './commands/config.command';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('twenty')
|
||||
.description('CLI for Twenty application development')
|
||||
.version('0.1.0');
|
||||
|
||||
program
|
||||
.option('-v, --verbose', 'Enable verbose logging')
|
||||
.option(
|
||||
'--api-url <url>',
|
||||
'Twenty API URL',
|
||||
process.env.TWENTY_API_URL || 'http://localhost:3000',
|
||||
);
|
||||
|
||||
program.addCommand(new AuthCommand().getCommand());
|
||||
program.addCommand(new AppCommand().getCommand());
|
||||
program.addCommand(new ConfigCommand().getCommand());
|
||||
|
||||
program.exitOverride();
|
||||
|
||||
try {
|
||||
program.parse();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(chalk.red('Error:'), error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import chalk from 'chalk';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { resolveAppPath } from '../utils/app-path-resolver';
|
||||
import { syncApp } from '../utils/app-sync';
|
||||
|
||||
export class AppDeployCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute(options: { path?: string }): Promise<void> {
|
||||
try {
|
||||
const appPath = await resolveAppPath(options.path);
|
||||
|
||||
console.log(chalk.blue('🚀 Deploying Twenty Application'));
|
||||
console.log(chalk.gray(`📁 App Path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const result = await syncApp(appPath, this.apiService);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red('❌ Deployment failed:'), result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ Application deployed successfully'));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Deployment failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import chalk from 'chalk';
|
||||
import * as chokidar from 'chokidar';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { resolveAppPath } from '../utils/app-path-resolver';
|
||||
import { syncApp } from '../utils/app-sync';
|
||||
|
||||
export class AppDevCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute(options: {
|
||||
path?: string;
|
||||
debounce: string;
|
||||
verbose?: boolean;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const appPath = await resolveAppPath(options.path, options.verbose);
|
||||
const debounceMs = parseInt(options.debounce, 10);
|
||||
|
||||
this.logStartupInfo(appPath, debounceMs, options.verbose);
|
||||
|
||||
await syncApp(appPath, this.apiService);
|
||||
|
||||
const watcher = this.setupFileWatcher(
|
||||
appPath,
|
||||
debounceMs,
|
||||
options.verbose,
|
||||
);
|
||||
|
||||
this.setupGracefulShutdown(watcher);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Development mode failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private logStartupInfo(
|
||||
appPath: string,
|
||||
debounceMs: number,
|
||||
verbose?: boolean,
|
||||
): void {
|
||||
console.log(chalk.blue('🚀 Starting Twenty Application Development Mode'));
|
||||
console.log(chalk.gray(`📁 App Path: ${appPath}`));
|
||||
console.log(chalk.gray(`⏱️ Debounce: ${debounceMs}ms`));
|
||||
console.log(chalk.gray(`🔧 Verbose: ${verbose ? 'On' : 'Off'}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private setupFileWatcher(
|
||||
appPath: string,
|
||||
debounceMs: number,
|
||||
verbose?: boolean,
|
||||
): chokidar.FSWatcher {
|
||||
const watcher = chokidar.watch(appPath, {
|
||||
ignored: /node_modules|\.git/,
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const debouncedSync = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(async () => {
|
||||
console.log(chalk.blue('🔄 Changes detected, syncing...'));
|
||||
await syncApp(appPath, this.apiService);
|
||||
console.log(
|
||||
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
|
||||
);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
watcher.on('change', (filePath) => {
|
||||
if (verbose) {
|
||||
console.log(chalk.gray(`📝 ${filePath} changed`));
|
||||
}
|
||||
debouncedSync();
|
||||
});
|
||||
|
||||
console.log(
|
||||
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
|
||||
);
|
||||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
private setupGracefulShutdown(watcher: chokidar.FSWatcher): void {
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\n🛑 Stopping development mode...'));
|
||||
watcher.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
createAgentManifest,
|
||||
createManifest,
|
||||
createReadmeContent,
|
||||
} from '../utils/app-template';
|
||||
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);
|
||||
|
||||
await this.validateDirectory(appDir);
|
||||
|
||||
this.logCreationInfo(appDir, appName);
|
||||
|
||||
await this.createAppStructure(appDir, appName);
|
||||
|
||||
this.logSuccess(appDir);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Initialization failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppName(providedName?: string): Promise<string> {
|
||||
if (providedName) {
|
||||
return providedName;
|
||||
}
|
||||
|
||||
const nameAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'appName',
|
||||
message: 'Application name:',
|
||||
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;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return nameAnswer.appName;
|
||||
}
|
||||
|
||||
private determineAppDirectory(
|
||||
providedPath?: string,
|
||||
appName?: string,
|
||||
): string {
|
||||
if (providedPath) {
|
||||
return path.resolve(providedPath);
|
||||
}
|
||||
|
||||
return path.join(process.cwd(), appName!);
|
||||
}
|
||||
|
||||
private async validateDirectory(appDir: string): Promise<void> {
|
||||
if (!(await fs.pathExists(appDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(appDir);
|
||||
if (files.length > 0) {
|
||||
throw new Error(`Directory ${appDir} already exists and is not empty`);
|
||||
}
|
||||
}
|
||||
|
||||
private logCreationInfo(appDir: string, appName: string): void {
|
||||
console.log(chalk.blue('🎯 Creating Twenty Application'));
|
||||
console.log(chalk.gray(`📁 Directory: ${appDir}`));
|
||||
console.log(chalk.gray(`📝 Name: ${appName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private async createAppStructure(
|
||||
appDir: string,
|
||||
appName: string,
|
||||
): Promise<void> {
|
||||
await fs.ensureDir(appDir);
|
||||
|
||||
// Create agents directory
|
||||
const agentsDir = path.join(appDir, 'agents');
|
||||
await fs.ensureDir(agentsDir);
|
||||
|
||||
// Create main manifest with agent references
|
||||
const manifest = createManifest(appName);
|
||||
const manifestPath = path.join(appDir, 'twenty-app.jsonc');
|
||||
await writeJsoncFile(manifestPath, manifest);
|
||||
|
||||
// Create agent manifest 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);
|
||||
}
|
||||
|
||||
private logSuccess(appDir: string): void {
|
||||
console.log(chalk.green('✅ Application created successfully!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(` cd ${appDir}`);
|
||||
console.log(' twenty app dev');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { ApiService } from '../services/api.service';
|
||||
|
||||
export class AppInstallCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute(options: { source?: string; type: string }): Promise<void> {
|
||||
try {
|
||||
const source = await this.getSource(options.source);
|
||||
|
||||
this.logInstallInfo(source, options.type);
|
||||
|
||||
const result = await this.apiService.installApplication(
|
||||
source,
|
||||
options.type as 'local' | 'git' | 'marketplace',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red('❌ Installation failed:'), result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ Application installed successfully'));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Installation failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSource(providedSource?: string): Promise<string> {
|
||||
if (providedSource) {
|
||||
return providedSource;
|
||||
}
|
||||
|
||||
const answer = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'source',
|
||||
message: 'Application source (URL, path, or ID):',
|
||||
validate: (input) => input.length > 0 || 'Source is required',
|
||||
},
|
||||
]);
|
||||
|
||||
return answer.source;
|
||||
}
|
||||
|
||||
private logInstallInfo(source: string, type: string): void {
|
||||
console.log(chalk.blue('📦 Installing Twenty Application'));
|
||||
console.log(chalk.gray(`📍 Source: ${source}`));
|
||||
console.log(chalk.gray(`🔧 Type: ${type}`));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import chalk from 'chalk';
|
||||
import { ApiService } from '../services/api.service';
|
||||
|
||||
export class AppListCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute(): Promise<void> {
|
||||
try {
|
||||
console.log(chalk.blue('📋 Listing Twenty Applications'));
|
||||
console.log('');
|
||||
|
||||
const result = await this.apiService.listApplications();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.error(
|
||||
chalk.red('❌ Failed to list applications:'),
|
||||
result.error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
this.displayApplications(result.data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('List failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private displayApplications(apps: any[]): void {
|
||||
if (apps.length === 0) {
|
||||
console.log(chalk.yellow('No applications found'));
|
||||
return;
|
||||
}
|
||||
|
||||
apps.forEach((app: any, index: number) => {
|
||||
console.log(`${index + 1}. ${chalk.bold(app.name)}`);
|
||||
console.log(` ${chalk.gray(app.description || 'No description')}`);
|
||||
console.log(` ${chalk.gray(`Version: ${app.version || 'N/A'}`)}`);
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Command } from 'commander';
|
||||
import { AppDeployCommand } from './app-deploy.command';
|
||||
import { AppDevCommand } from './app-dev.command';
|
||||
import { AppInitCommand } from './app-init.command';
|
||||
import { AppInstallCommand } from './app-install.command';
|
||||
import { AppListCommand } from './app-list.command';
|
||||
|
||||
export class AppCommand {
|
||||
private devCommand = new AppDevCommand();
|
||||
private deployCommand = new AppDeployCommand();
|
||||
private installCommand = new AppInstallCommand();
|
||||
private listCommand = new AppListCommand();
|
||||
private initCommand = new AppInitCommand();
|
||||
|
||||
getCommand(): Command {
|
||||
const appCommand = new Command('app');
|
||||
appCommand.description('Application development commands');
|
||||
|
||||
appCommand
|
||||
.command('dev')
|
||||
.description('Watch and sync local application changes')
|
||||
.option(
|
||||
'-p, --path <path>',
|
||||
'Application directory path (auto-detected if not specified)',
|
||||
)
|
||||
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
|
||||
.option('--verbose', 'Enable verbose logging')
|
||||
.action(async (options) => {
|
||||
await this.devCommand.execute(options);
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('deploy')
|
||||
.description('Deploy application to Twenty')
|
||||
.option(
|
||||
'-p, --path <path>',
|
||||
'Application directory path (auto-detected if not specified)',
|
||||
)
|
||||
.action(async (options) => {
|
||||
await this.deployCommand.execute(options);
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('install')
|
||||
.description('Install application from source')
|
||||
.option(
|
||||
'-s, --source <source>',
|
||||
'Application source (git URL, local path, or marketplace ID)',
|
||||
)
|
||||
.option(
|
||||
'-t, --type <type>',
|
||||
'Source type (git, local, marketplace)',
|
||||
'local',
|
||||
)
|
||||
.action(async (options) => {
|
||||
await this.installCommand.execute(options);
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('list')
|
||||
.description('List installed applications')
|
||||
.action(async () => {
|
||||
await this.listCommand.execute();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return appCommand;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import chalk from 'chalk';
|
||||
import { Command } from 'commander';
|
||||
import inquirer from 'inquirer';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ConfigService } from '../services/config.service';
|
||||
|
||||
export class AuthCommand {
|
||||
private configService = new ConfigService();
|
||||
private apiService = new ApiService();
|
||||
|
||||
getCommand(): Command {
|
||||
const authCommand = new Command('auth');
|
||||
authCommand.description('Authentication commands');
|
||||
|
||||
authCommand
|
||||
.command('login')
|
||||
.description('Authenticate with Twenty')
|
||||
.option('--api-key <key>', 'API key for authentication')
|
||||
.option('--api-url <url>', 'Twenty API URL')
|
||||
.action(async (options) => {
|
||||
await this.login(options);
|
||||
});
|
||||
|
||||
authCommand
|
||||
.command('logout')
|
||||
.description('Remove authentication credentials')
|
||||
.action(async () => {
|
||||
await this.logout();
|
||||
});
|
||||
|
||||
authCommand
|
||||
.command('status')
|
||||
.description('Check authentication status')
|
||||
.action(async () => {
|
||||
await this.status();
|
||||
});
|
||||
|
||||
return authCommand;
|
||||
}
|
||||
|
||||
private async login(options: {
|
||||
apiKey?: string;
|
||||
apiUrl?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
let { apiKey, apiUrl } = options;
|
||||
|
||||
// Get current config
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
// Prompt for missing values
|
||||
if (!apiUrl) {
|
||||
const urlAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'apiUrl',
|
||||
message: 'Twenty API URL:',
|
||||
default: config.apiUrl,
|
||||
validate: (input) => {
|
||||
try {
|
||||
new URL(input);
|
||||
return true;
|
||||
} catch {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
apiUrl = urlAnswer.apiUrl;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
const keyAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'apiKey',
|
||||
message: 'API Key:',
|
||||
mask: '*',
|
||||
validate: (input) => input.length > 0 || 'API key is required',
|
||||
},
|
||||
]);
|
||||
apiKey = keyAnswer.apiKey;
|
||||
}
|
||||
|
||||
// Update config
|
||||
await this.configService.setConfig({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Validate authentication
|
||||
const isValid = await this.apiService.validateAuth();
|
||||
|
||||
if (isValid) {
|
||||
console.log(chalk.green('✓ Successfully authenticated with Twenty'));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.red('✗ Authentication failed. Please check your credentials.'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Login failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async logout(): Promise<void> {
|
||||
try {
|
||||
await this.configService.clearConfig();
|
||||
console.log(chalk.green('✓ Successfully logged out'));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Logout failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async status(): Promise<void> {
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
console.log(chalk.blue('Authentication Status:'));
|
||||
console.log(`API URL: ${config.apiUrl}`);
|
||||
console.log(
|
||||
`API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'Not set'}`,
|
||||
);
|
||||
|
||||
if (config.apiKey) {
|
||||
const isValid = await this.apiService.validateAuth();
|
||||
console.log(
|
||||
`Status: ${isValid ? chalk.green('✓ Valid') : chalk.red('✗ Invalid')}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Status: ${chalk.yellow('⚠ Not authenticated')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Status check failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import chalk from 'chalk';
|
||||
import { Command } from 'commander';
|
||||
import { ConfigService } from '../services/config.service';
|
||||
import { TwentyConfig } from '../types/config.types';
|
||||
|
||||
export class ConfigCommand {
|
||||
private configService = new ConfigService();
|
||||
|
||||
getCommand(): Command {
|
||||
const configCommand = new Command('config');
|
||||
configCommand.description('Configuration management');
|
||||
|
||||
configCommand
|
||||
.command('get [key]')
|
||||
.description('Get configuration value(s)')
|
||||
.action(async (key) => {
|
||||
await this.get(key);
|
||||
});
|
||||
|
||||
configCommand
|
||||
.command('set <key> <value>')
|
||||
.description('Set configuration value')
|
||||
.action(async (key, value) => {
|
||||
await this.set(key, value);
|
||||
});
|
||||
|
||||
configCommand
|
||||
.command('unset <key>')
|
||||
.description('Remove configuration value')
|
||||
.action(async (key) => {
|
||||
await this.unset(key);
|
||||
});
|
||||
|
||||
configCommand
|
||||
.command('list')
|
||||
.description('List all configuration values')
|
||||
.action(async () => {
|
||||
await this.list();
|
||||
});
|
||||
|
||||
return configCommand;
|
||||
}
|
||||
|
||||
private async get(key: string | undefined): Promise<void> {
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
if (key) {
|
||||
const value = (config as any)[key];
|
||||
if (value !== undefined) {
|
||||
console.log(value);
|
||||
} else {
|
||||
console.log(chalk.gray('(not set)'));
|
||||
}
|
||||
} else {
|
||||
this.printConfig('Configuration', config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Failed to get configuration:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async set(key: keyof TwentyConfig, value: string): Promise<void> {
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
config[key] = value;
|
||||
await this.configService.setConfig(config);
|
||||
console.log(chalk.green(`✓ Set ${key} in configuration`));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Failed to set configuration:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async unset(key: string): Promise<void> {
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
delete (config as any)[key];
|
||||
await this.configService.setConfig(config);
|
||||
console.log(chalk.green(`✓ Removed ${key} from configuration`));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Failed to unset configuration:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async list(): Promise<void> {
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
this.printConfig('Configuration', config);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Failed to list configuration:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private printConfig(title: string, config: Record<string, any>): void {
|
||||
console.log(chalk.blue(title + ':'));
|
||||
|
||||
const keys = Object.keys(config);
|
||||
if (keys.length === 0) {
|
||||
console.log(chalk.gray(' (empty)'));
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach((key) => {
|
||||
const value = config[key];
|
||||
const displayValue =
|
||||
key.toLowerCase().includes('key') && value
|
||||
? '***' + value.slice(-4)
|
||||
: value;
|
||||
|
||||
// Show if value is overridden by environment variable
|
||||
const envVarName = `TWENTY_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`;
|
||||
const isOverridden = process.env[envVarName] !== undefined;
|
||||
const suffix = isOverridden ? chalk.gray(' (from env)') : '';
|
||||
|
||||
console.log(` ${key}: ${displayValue}${suffix}`);
|
||||
});
|
||||
|
||||
// Show available environment variables
|
||||
console.log(chalk.gray('\nEnvironment variables:'));
|
||||
console.log(chalk.gray(' TWENTY_API_URL - Override API URL'));
|
||||
console.log(chalk.gray(' TWENTY_API_KEY - Override API key'));
|
||||
console.log(chalk.gray(' TWENTY_DEFAULT_APP - Override default app'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { type ApiResponse, type AppManifest } from '../types/config.types';
|
||||
import { ConfigService } from './config.service';
|
||||
|
||||
export class ApiService {
|
||||
private client: AxiosInstance;
|
||||
private configService: ConfigService;
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService();
|
||||
this.client = axios.create();
|
||||
|
||||
this.client.interceptors.request.use(async (config) => {
|
||||
const twentyConfig = await this.configService.getConfig();
|
||||
|
||||
config.baseURL = twentyConfig.apiUrl;
|
||||
|
||||
if (twentyConfig.apiKey) {
|
||||
config.headers.Authorization = `Bearer ${twentyConfig.apiKey}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Authentication failed. Please run `twenty auth login` first.',
|
||||
),
|
||||
);
|
||||
} else if (error.response?.status === 403) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Access denied. Check your API key and workspace permissions.',
|
||||
),
|
||||
);
|
||||
} else if (error.code === 'ECONNREFUSED') {
|
||||
console.error(
|
||||
chalk.red('Cannot connect to Twenty server. Is it running?'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async validateAuth(): Promise<boolean> {
|
||||
try {
|
||||
const query = `
|
||||
query FindManyAgents {
|
||||
findManyAgents {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
'x-schema-version': '6',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.status === 200 && !response.data.errors;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(manifest: AppManifest): Promise<ApiResponse> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
syncApplication(manifest: $manifest) {
|
||||
id
|
||||
standardId
|
||||
label
|
||||
description
|
||||
version
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
manifest,
|
||||
};
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
'x-schema-version': '6',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
response.data.errors[0]?.message || 'Failed to sync application',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.syncApplication,
|
||||
message: `Successfully synced application: ${manifest.label}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response.data?.errors?.[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async installApplication(
|
||||
source: string,
|
||||
sourceType: 'git' | 'local' | 'marketplace' = 'local',
|
||||
): Promise<ApiResponse> {
|
||||
// For now, installation is the same as syncing a local manifest
|
||||
// In the future, this could handle different source types
|
||||
try {
|
||||
if (sourceType === 'local') {
|
||||
// Try to load manifest using the new loader
|
||||
try {
|
||||
const { loadAppManifest } = await import(
|
||||
'../utils/app-manifest-loader'
|
||||
);
|
||||
const manifest = await loadAppManifest(source);
|
||||
return this.syncApplication(manifest);
|
||||
} catch (manifestError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to load manifest: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Source type "${sourceType}" not yet supported`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Installation failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async listApplications(): Promise<ApiResponse> {
|
||||
try {
|
||||
const query = `
|
||||
query FindManyAgents {
|
||||
findManyAgents {
|
||||
id
|
||||
name
|
||||
label
|
||||
description
|
||||
isCustom
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response: AxiosResponse = await this.client.post('/metadata', {
|
||||
query,
|
||||
});
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0]?.message || 'Failed to fetch agents',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.findManyAgents,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response.data?.errors?.[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getWorkspaces(): Promise<ApiResponse> {
|
||||
try {
|
||||
const query = `
|
||||
query CurrentUser {
|
||||
currentUser {
|
||||
id
|
||||
email
|
||||
currentWorkspace {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response: AxiosResponse = await this.client.post('/metadata', {
|
||||
query,
|
||||
});
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
response.data.errors[0]?.message || 'Failed to fetch workspace',
|
||||
};
|
||||
}
|
||||
|
||||
const workspace = response.data.data.currentUser?.currentWorkspace;
|
||||
return {
|
||||
success: true,
|
||||
data: workspace ? [workspace] : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response.data?.errors?.[0]?.message || error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { TwentyConfig } from '../types/config.types';
|
||||
|
||||
export class ConfigService {
|
||||
private configPath: string;
|
||||
|
||||
constructor() {
|
||||
this.configPath = path.join(os.homedir(), '.twenty', 'config.json');
|
||||
this.loadEnvironmentVariables();
|
||||
}
|
||||
|
||||
private loadEnvironmentVariables(): void {
|
||||
// Load local .env file if it exists in current working directory
|
||||
const localEnvPath = path.join(process.cwd(), '.env');
|
||||
if (fs.existsSync(localEnvPath)) {
|
||||
loadDotenv({ path: localEnvPath });
|
||||
}
|
||||
|
||||
// Also try to load from user's home .twenty directory
|
||||
const userEnvPath = path.join(os.homedir(), '.twenty', '.env');
|
||||
if (fs.existsSync(userEnvPath)) {
|
||||
loadDotenv({ path: userEnvPath });
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<TwentyConfig> {
|
||||
try {
|
||||
// Start with default config
|
||||
const defaultConfig = this.getDefaultConfig();
|
||||
|
||||
// Load config file if it exists
|
||||
let fileConfig = {};
|
||||
await fs.ensureFile(this.configPath);
|
||||
const configExists = await fs.pathExists(this.configPath);
|
||||
|
||||
if (configExists) {
|
||||
const configContent = await fs.readFile(this.configPath, 'utf8');
|
||||
fileConfig = JSON.parse(configContent || '{}');
|
||||
}
|
||||
|
||||
// Environment variables override everything
|
||||
const envConfig = this.getEnvironmentConfig();
|
||||
|
||||
// Merge configs with proper precedence: defaults < file < environment
|
||||
return {
|
||||
...defaultConfig,
|
||||
...fileConfig,
|
||||
...envConfig,
|
||||
};
|
||||
} catch {
|
||||
return this.getDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async setConfig(config: Partial<TwentyConfig>): Promise<void> {
|
||||
const currentConfig = await this.getConfig();
|
||||
const newConfig = { ...currentConfig, ...config };
|
||||
|
||||
await fs.ensureDir(path.dirname(this.configPath));
|
||||
await fs.writeFile(this.configPath, JSON.stringify(newConfig, null, 2));
|
||||
}
|
||||
|
||||
async clearConfig(): Promise<void> {
|
||||
const configExists = await fs.pathExists(this.configPath);
|
||||
if (configExists) {
|
||||
await fs.remove(this.configPath);
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultConfig(): TwentyConfig {
|
||||
return {
|
||||
apiUrl: 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
private getEnvironmentConfig(): Partial<TwentyConfig> {
|
||||
const envConfig: Partial<TwentyConfig> = {};
|
||||
|
||||
if (process.env.TWENTY_API_URL) {
|
||||
envConfig.apiUrl = process.env.TWENTY_API_URL;
|
||||
}
|
||||
|
||||
if (process.env.TWENTY_API_KEY) {
|
||||
envConfig.apiKey = process.env.TWENTY_API_KEY;
|
||||
}
|
||||
|
||||
if (process.env.TWENTY_DEFAULT_APP) {
|
||||
envConfig.defaultApp = process.env.TWENTY_DEFAULT_APP;
|
||||
}
|
||||
|
||||
return envConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface TwentyConfig {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
defaultApp?: string;
|
||||
}
|
||||
|
||||
export interface AppManifest {
|
||||
standardId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
version: string;
|
||||
agents: AgentManifest[];
|
||||
}
|
||||
|
||||
export interface AgentManifest {
|
||||
standardId: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
prompt: string;
|
||||
modelId?: string;
|
||||
responseFormat?: AgentResponseFormat;
|
||||
}
|
||||
|
||||
export interface AgentResponseFormat {
|
||||
type: 'json' | 'text';
|
||||
schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
createAgentManifest,
|
||||
createManifest,
|
||||
createReadmeContent,
|
||||
} from '../app-template';
|
||||
|
||||
// Mock crypto.randomUUID to make tests deterministic
|
||||
jest.mock('crypto', () => ({
|
||||
randomUUID: jest.fn(() => 'mocked-uuid-12345'),
|
||||
}));
|
||||
|
||||
describe('app-template', () => {
|
||||
describe('createManifest', () => {
|
||||
it('should create a valid app manifest with correct structure', () => {
|
||||
const appName = 'my-test-app';
|
||||
const manifest = createManifest(appName);
|
||||
|
||||
expect(manifest).toEqual({
|
||||
$schema:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
|
||||
standardId: 'mocked-uuid-12345',
|
||||
label: 'My Test App',
|
||||
description: 'A Twenty application for my-test-app',
|
||||
version: '1.0.0',
|
||||
// agents will be discovered from the agents/ folder
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single word app names', () => {
|
||||
const appName = 'calculator';
|
||||
const manifest = createManifest(appName);
|
||||
|
||||
expect(manifest.label).toBe('Calculator');
|
||||
expect(manifest.standardId).toBe('mocked-uuid-12345');
|
||||
});
|
||||
|
||||
it('should handle kebab-case app names correctly', () => {
|
||||
const appName = 'user-management-system';
|
||||
const manifest = createManifest(appName);
|
||||
|
||||
expect(manifest.label).toBe('User Management System');
|
||||
expect(manifest.standardId).toBe('mocked-uuid-12345');
|
||||
});
|
||||
|
||||
it('should generate unique standardIds', () => {
|
||||
const manifest = createManifest('test-app');
|
||||
|
||||
expect(manifest.standardId).toBeDefined();
|
||||
expect(typeof manifest.standardId).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:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
|
||||
export const findProjectRoot = async (): Promise<string | null> => {
|
||||
let currentDir = process.cwd();
|
||||
const maxDepth = 10;
|
||||
let depth = 0;
|
||||
|
||||
while (depth < maxDepth) {
|
||||
const nxConfig = path.join(currentDir, 'nx.json');
|
||||
const packageJson = path.join(currentDir, 'package.json');
|
||||
|
||||
if (await fs.pathExists(nxConfig)) {
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
if (await fs.pathExists(packageJson)) {
|
||||
try {
|
||||
const pkg = await fs.readJson(packageJson);
|
||||
if (pkg.workspaces || pkg.name === 'twenty') {
|
||||
return currentDir;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) break;
|
||||
|
||||
currentDir = parentDir;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findNearbyApps = async (startDir: string): Promise<string[]> => {
|
||||
const apps: string[] = [];
|
||||
|
||||
try {
|
||||
const searchPaths = [
|
||||
startDir,
|
||||
path.join(startDir, '..'),
|
||||
path.join(startDir, '../..'),
|
||||
path.join(startDir, 'packages/twenty-apps'),
|
||||
path.join(startDir, '../../packages/twenty-apps'),
|
||||
];
|
||||
|
||||
for (const searchPath of searchPaths) {
|
||||
if (await fs.pathExists(searchPath)) {
|
||||
const items = await fs.readdir(searchPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) {
|
||||
const manifestPath = path.join(
|
||||
searchPath,
|
||||
item.name,
|
||||
'twenty-app.json',
|
||||
);
|
||||
if (await fs.pathExists(manifestPath)) {
|
||||
apps.push(path.join(searchPath, item.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during search
|
||||
}
|
||||
|
||||
return apps.slice(0, 5);
|
||||
};
|
||||
|
||||
export const isValidAppPath = async (appPath: string): Promise<boolean> => {
|
||||
const manifestPath = path.join(appPath, 'twenty-app.json');
|
||||
return fs.pathExists(manifestPath);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { AgentManifest, AppManifest } from '../types/config.types';
|
||||
import { parseJsoncFile } from './jsonc-parser';
|
||||
import { schemaValidator } from './schema-validator';
|
||||
|
||||
export interface AppManifestWithMeta extends AppManifest {
|
||||
_meta?: {
|
||||
agentFiles?: string[];
|
||||
manifestPath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AppManifestRaw = Omit<AppManifest, 'agents'> & {
|
||||
// agents will be discovered from the agents/ folder
|
||||
agents?: AgentManifest[];
|
||||
};
|
||||
|
||||
export class AppManifestLoader {
|
||||
private appPath: string;
|
||||
|
||||
constructor(appPath: string) {
|
||||
this.appPath = appPath;
|
||||
}
|
||||
|
||||
async loadManifest(): Promise<AppManifestWithMeta> {
|
||||
const manifestPath = await this.findManifestFile();
|
||||
const rawManifest = await parseJsoncFile(manifestPath);
|
||||
|
||||
// Validate the raw manifest structure
|
||||
await schemaValidator.validateAppManifest(rawManifest, manifestPath);
|
||||
|
||||
return this.discoverAndLoadAgents(rawManifest, manifestPath);
|
||||
}
|
||||
|
||||
private async findManifestFile(): Promise<string> {
|
||||
// Try JSONC first, then fall back to JSON for backward compatibility
|
||||
const jsoncPath = path.join(this.appPath, 'twenty-app.jsonc');
|
||||
const jsonPath = path.join(this.appPath, 'twenty-app.json');
|
||||
|
||||
if (await fs.pathExists(jsoncPath)) {
|
||||
return jsoncPath;
|
||||
}
|
||||
|
||||
if (await fs.pathExists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No manifest file found. Expected twenty-app.jsonc or twenty-app.json in ${this.appPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async discoverAndLoadAgents(
|
||||
rawManifest: AppManifestRaw,
|
||||
manifestPath: string,
|
||||
): Promise<AppManifestWithMeta> {
|
||||
const agentsDir = path.join(this.appPath, 'agents');
|
||||
const agentFiles: string[] = [];
|
||||
const agents: AgentManifest[] = [];
|
||||
|
||||
// Check if agents directory exists
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
const files = await fs.readdir(agentsDir);
|
||||
const agentFileNames = files.filter(
|
||||
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
|
||||
);
|
||||
|
||||
for (const fileName of agentFileNames) {
|
||||
const agentPath = path.join(agentsDir, fileName);
|
||||
const agentManifest = await parseJsoncFile(agentPath);
|
||||
|
||||
// Validate the agent against schema
|
||||
await schemaValidator.validateAgent(agentManifest, agentPath);
|
||||
|
||||
agents.push(agentManifest);
|
||||
agentFiles.push(`agents/${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
standardId: rawManifest.standardId,
|
||||
label: rawManifest.label,
|
||||
description: rawManifest.description,
|
||||
icon: rawManifest.icon,
|
||||
version: rawManifest.version,
|
||||
agents,
|
||||
_meta: {
|
||||
agentFiles,
|
||||
manifestPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Utility method to split agents from an existing manifest
|
||||
static async splitAgentsFromManifest(
|
||||
appPath: string,
|
||||
options: {
|
||||
agentsDir?: string;
|
||||
preserveOriginal?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const loader = new AppManifestLoader(appPath);
|
||||
const manifest = await loader.loadManifest();
|
||||
|
||||
const agentsDir = options.agentsDir || 'agents';
|
||||
const agentsDirPath = path.join(appPath, agentsDir);
|
||||
|
||||
// Create agents directory
|
||||
await fs.ensureDir(agentsDirPath);
|
||||
|
||||
// Extract agents to separate files
|
||||
for (const agent of manifest.agents) {
|
||||
const agentFileName = `${agent.name}.jsonc`;
|
||||
const agentFilePath = path.join(agentsDirPath, agentFileName);
|
||||
|
||||
// Write agent to separate file
|
||||
await fs.writeFile(agentFilePath, JSON.stringify(agent, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// Update main manifest (remove agents array since they're now discovered)
|
||||
const updatedManifest = {
|
||||
standardId: manifest.standardId,
|
||||
label: manifest.label,
|
||||
description: manifest.description,
|
||||
icon: manifest.icon,
|
||||
version: manifest.version,
|
||||
// No agents array - they will be discovered from the agents/ folder
|
||||
};
|
||||
|
||||
// Write updated manifest as JSONC
|
||||
const newManifestPath = path.join(appPath, 'twenty-app.jsonc');
|
||||
await fs.writeFile(
|
||||
newManifestPath,
|
||||
JSON.stringify(updatedManifest, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Optionally remove original JSON file
|
||||
if (!options.preserveOriginal) {
|
||||
const oldManifestPath = path.join(appPath, 'twenty-app.json');
|
||||
if (await fs.pathExists(oldManifestPath)) {
|
||||
await fs.remove(oldManifestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function for backward compatibility
|
||||
export const loadAppManifest = async (
|
||||
appPath: string,
|
||||
): Promise<AppManifest> => {
|
||||
const loader = new AppManifestLoader(appPath);
|
||||
const manifest = await loader.loadManifest();
|
||||
|
||||
// Remove meta information for backward compatibility
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _meta, ...cleanManifest } = manifest;
|
||||
return cleanManifest;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
findNearbyApps,
|
||||
findProjectRoot,
|
||||
isValidAppPath,
|
||||
} from './app-discovery';
|
||||
|
||||
export const resolveAppPath = async (
|
||||
providedPath?: string,
|
||||
verbose = false,
|
||||
): Promise<string> => {
|
||||
if (providedPath && path.isAbsolute(providedPath)) {
|
||||
return validateAppPath(providedPath, verbose);
|
||||
}
|
||||
|
||||
if (providedPath) {
|
||||
return resolveRelativePath(providedPath);
|
||||
}
|
||||
|
||||
return autoDetectAppPath(verbose);
|
||||
};
|
||||
|
||||
const resolveRelativePath = async (providedPath: string): Promise<string> => {
|
||||
const fromCwd = path.resolve(process.cwd(), providedPath);
|
||||
if (await isValidAppPath(fromCwd)) {
|
||||
return fromCwd;
|
||||
}
|
||||
|
||||
const projectRoot = await findProjectRoot();
|
||||
if (projectRoot) {
|
||||
const fromProjectRoot = path.resolve(projectRoot, providedPath);
|
||||
if (await isValidAppPath(fromProjectRoot)) {
|
||||
return fromProjectRoot;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Cannot find twenty-app.json at any of these locations:
|
||||
- ${fromCwd}
|
||||
- ${projectRoot ? path.resolve(projectRoot, providedPath) : 'N/A (no project root found)'}
|
||||
|
||||
Please check the path or run from the correct directory.`);
|
||||
};
|
||||
|
||||
const autoDetectAppPath = async (verbose = false): Promise<string> => {
|
||||
let currentDir = process.cwd();
|
||||
const maxDepth = 10;
|
||||
let depth = 0;
|
||||
|
||||
while (depth < maxDepth) {
|
||||
if (await isValidAppPath(currentDir)) {
|
||||
if (verbose) {
|
||||
console.log(chalk.gray(`Auto-detected app path: ${currentDir}`));
|
||||
}
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) break;
|
||||
|
||||
currentDir = parentDir;
|
||||
depth++;
|
||||
}
|
||||
|
||||
const suggestions = await findNearbyApps(process.cwd());
|
||||
let errorMessage =
|
||||
'No twenty-app.json found in current directory or parent directories.';
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
errorMessage += '\n\nFound Twenty applications nearby:';
|
||||
suggestions.forEach((suggestion, i) => {
|
||||
errorMessage += `\n ${i + 1}. ${suggestion}`;
|
||||
});
|
||||
errorMessage +=
|
||||
'\n\nTry running from one of these directories or use --path option.';
|
||||
} else {
|
||||
errorMessage += '\n\nRun `twenty app init` to create a new application.';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
|
||||
const validateAppPath = async (
|
||||
appPath: string,
|
||||
verbose = false,
|
||||
): Promise<string> => {
|
||||
if (verbose) {
|
||||
console.log(chalk.gray(`Checking app path: ${appPath}`));
|
||||
}
|
||||
|
||||
const jsoncManifestPath = path.join(appPath, 'twenty-app.jsonc');
|
||||
const jsonManifestPath = path.join(appPath, 'twenty-app.json');
|
||||
|
||||
const hasJsoncManifest = await fs.pathExists(jsoncManifestPath);
|
||||
const hasJsonManifest = await fs.pathExists(jsonManifestPath);
|
||||
|
||||
if (!hasJsoncManifest && !hasJsonManifest) {
|
||||
let errorMessage = `No manifest file found. Expected twenty-app.jsonc or twenty-app.json in: ${appPath}`;
|
||||
|
||||
if (await fs.pathExists(appPath)) {
|
||||
try {
|
||||
const files = await fs.readdir(appPath);
|
||||
errorMessage += `\n\nFiles in directory: ${files.join(', ')}`;
|
||||
} catch {
|
||||
errorMessage += '\n\nCould not read directory contents.';
|
||||
}
|
||||
} else {
|
||||
errorMessage += '\n\nDirectory does not exist.';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return appPath;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import chalk from 'chalk';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { loadAppManifest } from './app-manifest-loader';
|
||||
|
||||
export const syncApp = async (
|
||||
appPath: string,
|
||||
apiService: ApiService,
|
||||
): Promise<any> => {
|
||||
const manifest = await loadAppManifest(appPath);
|
||||
|
||||
try {
|
||||
const result = await apiService.syncApplication(manifest);
|
||||
|
||||
if (result.success) {
|
||||
console.log(chalk.green('✅ Application synced successfully'));
|
||||
} else {
|
||||
console.error(chalk.red('❌ Sync failed:'), result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Sync error:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AgentManifest, AppManifest } from '../types/config.types';
|
||||
import { SchemaValidator } from './schema-validator';
|
||||
|
||||
export type AppManifestTemplate = Omit<AppManifest, 'agents'> & {
|
||||
$schema?: string;
|
||||
// agents will be discovered from the agents/ folder
|
||||
};
|
||||
|
||||
export type AgentManifestTemplate = AgentManifest & {
|
||||
$schema?: string;
|
||||
};
|
||||
|
||||
export const createManifest = (appName: string): AppManifestTemplate => {
|
||||
const schemas = SchemaValidator.getSchemaUrls();
|
||||
|
||||
return {
|
||||
$schema: schemas.appManifest,
|
||||
standardId: randomUUID(),
|
||||
label: appName
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' '),
|
||||
description: `A Twenty application for ${appName}`,
|
||||
version: '1.0.0',
|
||||
// agents will be discovered from the agents/ folder
|
||||
};
|
||||
};
|
||||
|
||||
export const createAgentManifest = (appName: string): AgentManifestTemplate => {
|
||||
const schemas = SchemaValidator.getSchemaUrls();
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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}
|
||||
\`\`\`
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { ParseError, parse as parseJsonc } from 'jsonc-parser';
|
||||
|
||||
export interface JsoncParseOptions {
|
||||
allowTrailingComma?: boolean;
|
||||
disallowComments?: boolean;
|
||||
allowEmptyContent?: boolean;
|
||||
}
|
||||
|
||||
export class JsoncParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly parseErrors: ParseError[],
|
||||
public readonly filePath?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'JsoncParseError';
|
||||
}
|
||||
}
|
||||
|
||||
export const parseJsoncString = (
|
||||
content: string,
|
||||
options: JsoncParseOptions = {},
|
||||
): any => {
|
||||
const parseErrors: ParseError[] = [];
|
||||
|
||||
const result = parseJsonc(content, parseErrors, {
|
||||
allowTrailingComma: options.allowTrailingComma ?? true,
|
||||
disallowComments: options.disallowComments ?? false,
|
||||
allowEmptyContent: options.allowEmptyContent ?? false,
|
||||
});
|
||||
|
||||
if (parseErrors.length > 0) {
|
||||
const errorMessages = parseErrors.map(
|
||||
(error) => `Line ${error.offset}: ${error.error}`,
|
||||
);
|
||||
throw new JsoncParseError(
|
||||
`JSONC parse errors:\n${errorMessages.join('\n')}`,
|
||||
parseErrors,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parseJsoncFile = async (
|
||||
filePath: string,
|
||||
options: JsoncParseOptions = {},
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return parseJsoncString(content, options);
|
||||
} catch (error) {
|
||||
if (error instanceof JsoncParseError) {
|
||||
throw new JsoncParseError(error.message, error.parseErrors, filePath);
|
||||
}
|
||||
throw new Error(`Failed to read file ${filePath}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const writeJsoncFile = async (
|
||||
filePath: string,
|
||||
data: any,
|
||||
options: { spaces?: number } = {},
|
||||
): Promise<void> => {
|
||||
const content = JSON.stringify(data, null, options.spaces ?? 2);
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errors: any[],
|
||||
public readonly filePath?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SchemaValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaValidator {
|
||||
private ajv: Ajv;
|
||||
private schemasLoaded = false;
|
||||
|
||||
constructor() {
|
||||
this.ajv = new Ajv({
|
||||
allErrors: true,
|
||||
verbose: true,
|
||||
strict: false,
|
||||
});
|
||||
addFormats(this.ajv);
|
||||
}
|
||||
|
||||
private async loadSchemas(): Promise<void> {
|
||||
if (this.schemasLoaded) return;
|
||||
|
||||
const schemasDir = path.join(__dirname, '../../schemas');
|
||||
|
||||
try {
|
||||
// Load agent schema
|
||||
const agentSchemaPath = path.join(schemasDir, 'agent.schema.json');
|
||||
const agentSchema = await fs.readJson(agentSchemaPath);
|
||||
this.ajv.addSchema(agentSchema, 'agent');
|
||||
|
||||
// Load app manifest schema
|
||||
const appSchemaPath = path.join(schemasDir, 'app-manifest.schema.json');
|
||||
const appSchema = await fs.readJson(appSchemaPath);
|
||||
this.ajv.addSchema(appSchema, 'app-manifest');
|
||||
|
||||
this.schemasLoaded = true;
|
||||
} catch {
|
||||
// Gracefully handle missing schemas in development
|
||||
console.warn('Warning: Could not load JSON schemas for validation');
|
||||
this.schemasLoaded = true; // Prevent retry
|
||||
}
|
||||
}
|
||||
|
||||
async validateAgent(agent: any, filePath?: string): Promise<void> {
|
||||
await this.loadSchemas();
|
||||
|
||||
const validate = this.ajv.getSchema('agent');
|
||||
if (!validate) {
|
||||
// Schema not available, skip validation
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validate(agent);
|
||||
if (!valid) {
|
||||
const errorMessages = this.formatErrors(validate.errors || []);
|
||||
throw new SchemaValidationError(
|
||||
`Agent validation failed:\n${errorMessages}`,
|
||||
validate.errors || [],
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateAppManifest(manifest: any, filePath?: string): Promise<void> {
|
||||
await this.loadSchemas();
|
||||
|
||||
const validate = this.ajv.getSchema('app-manifest');
|
||||
if (!validate) {
|
||||
// Schema not available, skip validation
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validate(manifest);
|
||||
if (!valid) {
|
||||
const errorMessages = this.formatErrors(validate.errors || []);
|
||||
throw new SchemaValidationError(
|
||||
`App manifest validation failed:\n${errorMessages}`,
|
||||
validate.errors || [],
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private 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');
|
||||
}
|
||||
|
||||
// Get schema URLs for $schema references
|
||||
static getSchemaUrls() {
|
||||
return {
|
||||
agent:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
|
||||
appManifest:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const schemaValidator = new SchemaValidator();
|
||||
Reference in New Issue
Block a user