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:
Félix Malfait
2025-09-10 15:12:38 +02:00
committed by GitHub
parent 9a05daa624
commit 30a2164980
79 changed files with 5515 additions and 365 deletions
+37
View File
@@ -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;
};
+28
View File
@@ -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();