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
@@ -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'));
}
}