Merge twenty-cli into twenty-sdk (#16150)

- Moves twenty-cli content into twenty-sdk
- add a new twenty-sdk:0.1.0 version
- this new twenty-sdk exports a cli command called 'twenty' (like
twenty-cli before)
- deprecates twenty-cli
- simplify app init command base-project
- use `twenty-sdk:0.1.0` in base project
- move the "twenty-sdk/application" barrel to "twenty-sdk"
- add `create-twenty-app` package

<img width="1512" height="919" alt="image"
src="https://github.com/user-attachments/assets/007bef45-4e71-419a-9213-cebed376adbf"
/>

<img width="1506" height="929" alt="image"
src="https://github.com/user-attachments/assets/3de2fec6-1624-4923-ae13-f4e1cf165eb5"
/>
This commit is contained in:
martmull
2025-12-01 11:44:35 +01:00
committed by GitHub
parent 3f08a0c901
commit e498367e2f
85 changed files with 1077 additions and 1560 deletions
@@ -0,0 +1,169 @@
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import { join } from 'path';
import camelcase from 'lodash.camelcase';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { getObjectDecoratedClass } from '../utils/get-object-decorated-class';
import { getFunctionBaseFile } from '../utils/get-function-base-file';
import { convertToLabel } from '../utils/convert-to-label';
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
FUNCTION = 'function',
}
export const isSyncableEntity = (value: string): value is SyncableEntity => {
return Object.values(SyncableEntity).includes(value as SyncableEntity);
};
export class AppAddCommand {
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
const appPath = join(CURRENT_EXECUTION_DIRECTORY, path ?? '');
await fs.ensureDir(appPath);
const entity = entityType ?? (await this.getEntity());
if (entity === SyncableEntity.OBJECT) {
const entityData = await this.getObjectData();
const name = entityData.nameSingular;
const objectFileName = `${camelcase(name)}.ts`;
const decoratedObject = getObjectDecoratedClass({
data: entityData,
name,
});
await fs.writeFile(join(appPath, objectFileName), decoratedObject);
return;
}
if (entity === SyncableEntity.FUNCTION) {
const entityName = await this.getEntityName(entity);
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedServerlessFunction = getFunctionBaseFile({
name: entityName,
});
await fs.writeFile(
join(appPath, objectFileName),
decoratedServerlessFunction,
);
return;
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getEntity() {
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [SyncableEntity.FUNCTION, SyncableEntity.OBJECT],
},
]);
return entity;
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt<{ name: string }>([
{
type: 'input',
name: 'name',
message: `Enter a name for your new ${entity}:`,
default: '',
validate: (input) => {
if (input.length === 0) {
return `${entity} name is required`;
}
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Name must contain only lowercase letters, numbers, and hyphens';
}
return true;
},
},
]);
return name;
}
private async getObjectData() {
return inquirer.prompt([
{
type: 'input',
name: 'nameSingular',
message: 'Enter a name singular for your object (eg: company):',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'namePlural',
message: 'Enter a name plural for your object (eg: companies):',
default: '',
validate: (input: string, answers?: any) => {
if (input.trim() === answers?.nameSingular.trim()) {
return 'Name plural must be different from name singular';
}
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelSingular',
message: 'Enter a label singular for your object:',
default: (answers: any) => {
return convertToLabel(answers.nameSingular);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelPlural',
message: 'Enter a label plural for your object:',
default: (answers: any) => {
return convertToLabel(answers.namePlural);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
}
@@ -0,0 +1,86 @@
import chalk from 'chalk';
import * as chokidar from 'chokidar';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { AppSyncCommand } from './app-sync.command';
export class AppDevCommand {
private syncCommand = new AppSyncCommand();
async execute(options: {
appPath?: string;
debounce: string;
}): Promise<void> {
try {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
const debounceMs = parseInt(options.debounce, 10);
this.logStartupInfo(appPath, debounceMs);
await this.syncCommand.execute(appPath);
const watcher = this.setupFileWatcher(appPath, debounceMs);
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): 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('');
}
private setupFileWatcher(
appPath: string,
debounceMs: number,
): 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 this.syncCommand.execute(appPath);
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
);
}, debounceMs);
};
watcher.on('change', () => {
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,19 @@
import chalk from 'chalk';
import { GenerateService } from '../services/generate.service';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export class AppGenerateCommand {
private generateService = new GenerateService();
async execute(appPath: string = CURRENT_EXECUTION_DIRECTORY) {
try {
await this.generateService.generateClient(appPath);
} catch (error) {
console.error(
chalk.red('Generate Twenty client failed:'),
error instanceof Error ? error.message : error,
);
throw error;
}
}
}
@@ -0,0 +1,63 @@
import chalk from 'chalk';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { GenerateService } from '../services/generate.service';
import { type ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/load-manifest';
export class AppSyncCommand {
private apiService = new ApiService();
private generateService = new GenerateService();
async execute(
appPath: string = CURRENT_EXECUTION_DIRECTORY,
): Promise<ApiResponse<any>> {
try {
console.log(chalk.blue('🚀 Syncing Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
return await this.synchronize({ appPath });
} catch (error) {
console.error(
chalk.red('Sync failed:'),
error instanceof Error ? error.message : error,
);
throw error;
}
}
private async synchronize({ appPath }: { appPath: string }) {
const { manifest, packageJson, yarnLock, shouldGenerate } =
await loadManifest(appPath);
let serverlessSyncResult = await this.apiService.syncApplication({
manifest,
packageJson,
yarnLock,
});
if (shouldGenerate) {
await this.generateService.generateClient(appPath);
const { manifest: manifestWithClient } = await loadManifest(appPath);
serverlessSyncResult = await this.apiService.syncApplication({
manifest: manifestWithClient,
packageJson,
yarnLock,
});
}
if (serverlessSyncResult.success === false) {
console.error(
chalk.red('❌ Serverless functions Sync failed:'),
serverlessSyncResult.error,
);
} else {
console.log(chalk.green('✅ Serverless functions synced successfully'));
}
return serverlessSyncResult;
}
}
@@ -0,0 +1,62 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { type ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/load-manifest';
export class AppUninstallCommand {
private apiService = new ApiService();
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
askForConfirmation,
}: {
appPath?: string;
askForConfirmation: boolean;
}): Promise<ApiResponse<any>> {
try {
console.log(chalk.blue('🚀 Uninstall Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
if (askForConfirmation && !(await this.confirmationPrompt())) {
console.error(chalk.red('⛔️ Aborting uninstall'));
process.exit(1);
}
const { manifest } = await loadManifest(appPath);
const result = await this.apiService.uninstallApplication(
manifest.application.universalIdentifier,
);
if (result.success === false) {
console.error(chalk.red('❌ Uninstall failed:'), result.error);
} else {
console.log(chalk.green('✅ Application uninstalled successfully'));
}
return result;
} catch (error) {
console.error(
chalk.red('Uninstall failed:'),
error instanceof Error ? error.message : error,
);
throw error;
}
}
private async confirmationPrompt(): Promise<boolean> {
const { confirmation } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmation',
message: 'Are you sure you want to uninstall this application?',
default: false,
},
]);
return confirmation;
}
}
@@ -0,0 +1,115 @@
import chalk from 'chalk';
import { Command } from 'commander';
import {
AppAddCommand,
isSyncableEntity,
SyncableEntity,
} from './app-add.command';
import { AppUninstallCommand } from './app-uninstall.command';
import { AppDevCommand } from './app-dev.command';
import { AppSyncCommand } from './app-sync.command';
import { formatPath } from '../utils/format-path';
import { AppGenerateCommand } from './app-generate.command';
export class AppCommand {
private devCommand = new AppDevCommand();
private syncCommand = new AppSyncCommand();
private uninstallCommand = new AppUninstallCommand();
private addCommand = new AppAddCommand();
private generateCommand = new AppGenerateCommand();
getCommand(): Command {
const appCommand = new Command('app');
appCommand.description('Application development commands');
appCommand
.command('dev [appPath]')
.description('Watch and sync local application changes')
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
.action(async (appPath, options) => {
await this.devCommand.execute({
...options,
appPath: formatPath(appPath),
});
});
appCommand
.command('sync [appPath]')
.description('Sync application to Twenty')
.action(async (appPath?: string) => {
try {
const result = await this.syncCommand.execute(formatPath(appPath));
if (!result.success) {
process.exit(1);
}
} catch {
process.exit(1);
}
});
appCommand
.command('uninstall [appPath]')
.description('Uninstall application from Twenty')
.action(async (appPath?: string) => {
try {
const result = await this.uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: true,
});
if (!result.success) {
process.exit(1);
}
} catch {
process.exit(1);
}
});
// Keeping to avoid breaking changes
appCommand
.command('delete [appPath]', { hidden: true })
.description('Delete application from Twenty')
.action(async (appPath?: string) => {
try {
const result = await this.uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: true,
});
if (!result.success) {
process.exit(1);
}
} catch {
process.exit(1);
}
});
appCommand
.command('add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')
.description(
`Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`,
)
.action(async (entityType?: string, options?: { path?: string }) => {
if (entityType && !isSyncableEntity(entityType)) {
console.error(
chalk.red(
`Invalid entity type "${entityType}". Must be one of: ${Object.values(SyncableEntity).join('|')}`,
),
);
process.exit(1);
}
await this.addCommand.execute(
entityType as SyncableEntity,
options?.path,
);
});
appCommand
.command('generate [outputPath]')
.description('Generate Twenty client')
.action(async (appPath?: string) => {
await this.generateCommand.execute(formatPath(appPath));
});
return appCommand;
}
}
@@ -0,0 +1,162 @@
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) {
const activeWorkspace = ConfigService.getActiveWorkspace();
console.log(
chalk.green(
`✓ Successfully authenticated with Twenty (workspace: ${activeWorkspace})`,
),
);
} 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();
const activeWorkspace = ConfigService.getActiveWorkspace();
console.log(
chalk.green(
`✓ Successfully logged out (workspace: ${activeWorkspace})`,
),
);
} catch (error) {
console.error(
chalk.red('Logout failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async status(): Promise<void> {
try {
const activeWorkspace = ConfigService.getActiveWorkspace();
const config = await this.configService.getConfig();
console.log(chalk.blue('Authentication Status:'));
console.log(`Workspace: ${activeWorkspace}`);
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);
}
}
}