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,48 @@
import { existsSync } from 'fs';
import { AppSyncCommand } from '../../commands/app-sync.command';
import { AppUninstallCommand } from '../../commands/app-uninstall.command';
import { COVERED_APPLICATION_FOLDERS } from './constants/covered-applications-folder.constant';
import { getTestedApplicationPath } from './utils/get-tested-application-path.util';
describe.each(COVERED_APPLICATION_FOLDERS)(
'Application: "%s" install delete and reinstall test suite',
(applicationName) => {
const syncCommand = new AppSyncCommand();
const deleteCommand = new AppUninstallCommand();
const appPath = getTestedApplicationPath(applicationName);
beforeAll(async () => {
expect(existsSync(appPath)).toBe(true);
});
afterAll(async () => {
const result = await deleteCommand.execute({
appPath,
askForConfirmation: false,
});
expect(result.success).toBe(true);
});
it(`should successfully install ${applicationName} application`, async () => {
const result = await syncCommand.execute(appPath);
expect(result.success).toBe(true);
});
it(`should successfully delete ${applicationName} application`, async () => {
const result = await deleteCommand.execute({
appPath,
askForConfirmation: false,
});
expect(result.success).toBe(true);
});
it(`should successfully re-install ${applicationName} application`, async () => {
const result = await syncCommand.execute(appPath);
expect(result.success).toBe(true);
});
},
);
@@ -0,0 +1 @@
export const COVERED_APPLICATION_FOLDERS = ['hello-world'] as const;
@@ -0,0 +1 @@
export const SERVER_URL = 'http://localhost:3000';
@@ -0,0 +1,7 @@
import { type TwentyConfig } from '../../../types/config.types';
export const testConfig: TwentyConfig = {
apiUrl: 'http://localhost:3000',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
};
@@ -0,0 +1,12 @@
import axios from 'axios';
import { SERVER_URL } from './constants/server-url.constant';
describe('Twenty Server Health Check (E2E)', () => {
const HEALTH_ENDPOINT = `${SERVER_URL}/healthz`;
it('should return 200 for health', async () => {
const response = await axios.get(HEALTH_ENDPOINT);
expect(response.status).toBe(200);
expect(response.data).toBeDefined();
});
});
@@ -0,0 +1,12 @@
import { ConfigService } from '../../services/config.service';
import { testConfig } from './constants/testConfig';
beforeAll(() => {
jest
.spyOn(ConfigService.prototype, 'getConfig')
.mockResolvedValue(testConfig);
});
afterAll(() => {
jest.restoreAllMocks();
});
@@ -0,0 +1,13 @@
import { exec } from 'child_process';
export default async () =>
new Promise<void>((resolve) => {
exec('pkill -f "nest start" || true', (error: unknown) => {
if (error) {
console.log('No server processes to kill');
} else {
console.log('✅ Server processes cleaned up');
}
resolve();
});
});
@@ -0,0 +1,10 @@
import path from 'path';
export const getTestedApplicationPath = (relativePath: string): string => {
const twentyAppsPath = path.resolve(
__dirname,
'../../../../../../twenty-apps',
);
return path.join(twentyAppsPath, relativePath);
};
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { readFileSync } from 'fs';
import { join } from 'path';
import { AppCommand } from './commands/app.command';
import { AuthCommand } from './commands/auth.command';
import { ConfigService } from './services/config.service';
const packageJson = JSON.parse(
readFileSync(join(__dirname, '../../package.json'), 'utf-8'),
);
const program = new Command();
program
.name('twenty')
.description('CLI for Twenty application development')
.version(packageJson.version);
program.option(
'--workspace <name>',
'Use a specific workspace configuration',
'default',
);
program.hook('preAction', (thisCommand) => {
const opts = (thisCommand as any).optsWithGlobals
? (thisCommand as any).optsWithGlobals()
: thisCommand.opts();
const workspace = opts.workspace;
ConfigService.setActiveWorkspace(workspace);
console.log(
chalk.gray(`👩‍💻 Workspace - ${ConfigService.getActiveWorkspace()}`),
);
});
program.addCommand(new AuthCommand().getCommand());
program.addCommand(new AppCommand().getCommand());
program.exitOverride();
try {
program.parse();
} catch (error) {
if (error instanceof CommanderError) {
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,2 @@
export const CURRENT_EXECUTION_DIRECTORY =
process.env.INIT_CWD || process.cwd();
@@ -0,0 +1,238 @@
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import chalk from 'chalk';
import {
buildClientSchema,
getIntrospectionQuery,
printSchema,
} from 'graphql/index';
import {
type ApiResponse,
type AppManifest,
type PackageJson,
} 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 CurrentWorkspace {
currentWorkspace {
id
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
return response.status === 200 && !response.data.errors;
} catch {
return false;
}
}
async syncApplication({
packageJson,
yarnLock,
manifest,
}: {
packageJson: PackageJson;
yarnLock: string;
manifest: AppManifest;
}): Promise<ApiResponse> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) {
syncApplication(manifest: $manifest, packageJson: $packageJson, yarnLock: $yarnLock)
}
`;
const variables = {
manifest,
yarnLock,
packageJson,
};
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.syncApplication,
message: `Successfully synced application: ${packageJson.name}`,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async uninstallApplication(
universalIdentifier: string,
): Promise<ApiResponse> {
try {
const mutation = `
mutation UninstallApplication($universalIdentifier: String!) {
uninstallApplication(universalIdentifier: $universalIdentifier)
}
`;
const variables = { universalIdentifier };
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to delete application',
};
}
return {
success: true,
data: response.data.data.uninstallApplication,
message: 'Successfully uninstalled application',
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
throw error;
}
}
async getSchema(): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
const response = await this.client.post(
'/graphql',
{
query: introspectionQuery,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: `GraphQL introspection errors: ${JSON.stringify(response.data.errors)}`,
};
}
const schema = buildClientSchema(response.data.data);
return {
success: true,
data: printSchema(schema),
message: 'Successfully load schema',
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error:
error.response.data.errors[0]?.message ||
'Failed to load graphql Schema',
};
}
throw error;
}
}
}
@@ -0,0 +1,109 @@
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { type TwentyConfig } from '../types/config.types';
type PersistedConfig = TwentyConfig & {
profiles?: Record<string, TwentyConfig>;
};
const DEFAULT_WORKSPACE_NAME = 'default';
export class ConfigService {
private readonly configPath: string;
private static activeWorkspace = DEFAULT_WORKSPACE_NAME;
constructor() {
this.configPath = path.join(os.homedir(), '.twenty', 'config.json');
}
static setActiveWorkspace(name?: string) {
this.activeWorkspace = name ?? DEFAULT_WORKSPACE_NAME;
}
static getActiveWorkspace(): string {
return this.activeWorkspace;
}
private getActiveWorkspaceName(): string {
return ConfigService.getActiveWorkspace();
}
private async readRawConfig(): Promise<PersistedConfig> {
await fs.ensureFile(this.configPath);
const content = await fs.readFile(this.configPath, 'utf8');
return JSON.parse(content || '{}');
}
async getConfig(): Promise<TwentyConfig> {
const defaultConfig = this.getDefaultConfig();
try {
const raw = await this.readRawConfig();
const profile = this.getActiveWorkspaceName();
const profileConfig =
profile === DEFAULT_WORKSPACE_NAME &&
!raw.profiles?.[DEFAULT_WORKSPACE_NAME]
? raw
: raw.profiles?.[profile];
// Fallback to legacy top-level values if profile value is missing
const apiUrl = profileConfig?.apiUrl ?? defaultConfig.apiUrl;
const apiKey = profileConfig?.apiKey;
return {
apiUrl,
apiKey,
};
} catch {
return defaultConfig;
}
}
async setConfig(config: Partial<TwentyConfig>): Promise<void> {
const raw = await this.readRawConfig();
const profile = this.getActiveWorkspaceName();
// Ensure profiles map exists
if (!raw.profiles) {
raw.profiles = {};
}
const currentProfile = raw.profiles[profile] || { apiUrl: '' };
raw.profiles[profile] = { ...currentProfile, ...config };
await fs.ensureDir(path.dirname(this.configPath));
await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2));
}
async clearConfig(): Promise<void> {
// Clear only the active profile credentials (non-breaking for other profiles)
const raw = await this.readRawConfig();
const profile = this.getActiveWorkspaceName();
if (!raw.profiles) {
raw.profiles = {};
}
if (raw.profiles[profile]) {
delete raw.profiles[profile];
}
// Also clear legacy top-level apiKey for compatibility when active profile is default
if (profile === DEFAULT_WORKSPACE_NAME) {
const defaultConfig = this.getDefaultConfig();
delete raw.apiKey;
raw.apiUrl = defaultConfig.apiUrl;
}
await fs.ensureDir(path.dirname(this.configPath));
await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2));
}
private getDefaultConfig(): TwentyConfig {
return {
apiUrl: 'http://localhost:3000',
};
}
}
@@ -0,0 +1,61 @@
import { generate } from '@genql/cli';
import chalk from 'chalk';
import { join, resolve } from 'path';
import { ApiService } from './api.service';
import { ConfigService } from './config.service';
export const GENERATED_FOLDER_NAME = 'generated';
export class GenerateService {
private configService: ConfigService;
private apiService: ApiService;
constructor() {
this.configService = new ConfigService();
this.apiService = new ApiService();
}
async generateClient(appPath: string): Promise<void> {
const outputPath = join(appPath, GENERATED_FOLDER_NAME);
console.log(chalk.blue('📦 Generating Twenty client...'));
console.log(chalk.gray(`📁 Output Path: ${outputPath}`));
console.log('');
const config = await this.configService.getConfig();
const url = config.apiUrl;
const token = config.apiKey;
if (!url || !token) {
console.log(
chalk.yellow(
'⚠️ Skipping Client generation: API URL or token not configured',
),
);
return;
}
console.log(chalk.gray(`API URL: ${url}`));
console.log(chalk.gray(`Output: ${outputPath}`));
const getSchemaResponse = await this.apiService.getSchema();
if (!getSchemaResponse.success) {
return;
}
const { data: schema } = getSchemaResponse;
await generate({
schema,
output: resolve(outputPath),
scalarTypes: {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
},
verbose: true,
});
console.log(chalk.green('✓ Client generated successfully!'));
console.log(chalk.gray(`Generated files at: ${outputPath}`));
}
}
@@ -0,0 +1,111 @@
export interface TwentyConfig {
apiUrl: string;
apiKey?: string;
}
export type PackageJson = {
name: string;
license: string;
engines: {
node: string;
npm: string;
yarn: string;
};
packageManager: string;
version: string;
dependencies?: object;
devDependencies?: object;
};
type ApplicationVariable = {
universalIdentifier: string;
value?: string;
description?: string;
isSecret?: boolean;
};
export type Application = {
universalIdentifier: string;
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
};
export type AppManifest = {
application: Application;
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
sources: Sources;
};
export type ServerlessFunctionManifest = {
universalIdentifier: string;
name?: string;
description?: string;
timeoutSeconds?: number;
triggers: ServerlessFunctionTriggerManifest[];
handlerPath: string;
handlerName: string;
};
export type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
};
export type CronTrigger = {
type: 'cron';
pattern: string;
};
export type RouteTrigger = {
type: 'route';
path: string;
httpMethod: string;
isAuthRequired: boolean;
};
export type ServerlessFunctionTriggerManifest = {
universalIdentifier: string;
} & (CronTrigger | DatabaseEventTrigger | RouteTrigger);
export type Sources = { [key: string]: string | Sources };
export type FieldMetadata = {
universalIdentifier: string;
type: string;
label: string;
description?: string;
icon?: string;
defaultValue?: any;
options?: any;
settings?: any;
isNullable?: boolean;
isFieldUiReadOnly?: boolean;
};
export type ObjectManifest = {
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
fields: FieldMetadata[];
};
export type SuccessfulApiResponse<T = unknown> = {
success: true;
data: T;
message?: string;
};
export type FailingApiResponse = {
success: false;
error?: unknown;
message?: string;
};
export type ApiResponse<T = unknown> =
| SuccessfulApiResponse<T>
| FailingApiResponse;
@@ -0,0 +1,10 @@
import { convertToLabel } from '../convert-to-label';
describe('convertToLabel', () => {
it('should convert to label', () => {
expect(convertToLabel('toto')).toBe('Toto');
expect(convertToLabel('totoTata')).toBe('Toto tata');
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
});
});
@@ -0,0 +1,33 @@
import { getFunctionBaseFile } from '../get-function-base-file';
describe('getFunctionBaseFile', () => {
it('should render proper file', () => {
expect(
getFunctionBaseFile({
name: 'serverless-function-name',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
}),
).toBe(`import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: {
a: string;
b: number;
}): Promise<{ message: string }> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// This is just an example
const message = \`Hello, input: \${a} and \${b}\`;
return { message };
};
export const config: FunctionConfig = {
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
name: 'serverless-function-name',
timeoutSeconds: 5,
};
`);
});
});
@@ -0,0 +1,30 @@
import { getObjectDecoratedClass } from '../get-object-decorated-class';
describe('getObjectDecoratedClass', () => {
it('should return proper object file', () => {
expect(
getObjectDecoratedClass({
data: {
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',
nameSingular: 'name',
namePlural: 'names',
labelSingular: 'Name',
labelPlural: 'Names',
},
name: 'MyNewObject',
}),
).toBe(
`import { Object } from 'twenty-sdk';
@Object({
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',
nameSingular: 'name',
namePlural: 'names',
labelSingular: 'Name',
labelPlural: 'Names',
})
export class MyNewObject {}
`,
);
});
});
@@ -0,0 +1,494 @@
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { loadManifest } from '../load-manifest';
import { v4 } from 'uuid';
const write = (root: string, file: string, content: string) => {
const abs = join(root, file);
ensureDirSync(resolve(abs, '..'));
writeFileSync(abs, content, 'utf8');
};
const tsLibMock = `declare module 'tslib' {
export const __decorate: any;
export const __metadata: any;
export const __param: any;
export const __awaiter: any;
export const __read: any;
export const __spread: any;
export const __spreadArray: any;
export const __assign: any;
}`;
const twentySdkTypesMock = `
declare module 'twenty-sdk' {
export type SyncableEntityOptions = { universalIdentifier: string };
type ApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: boolean;
};
export type ApplicationConfig = SyncableEntityOptions & {
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
};
type RouteTrigger = {
type: 'route';
path: string;
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
isAuthRequired: boolean;
};
type CronTrigger = {
type: 'cron';
pattern: string;
};
type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
};
type ServerlessFunctionTrigger = SyncableEntityOptions &
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
export type FunctionConfig = SyncableEntityOptions & {
name?: string;
description?: string;
timeoutSeconds?: number;
triggers?: ServerlessFunctionTrigger[];
};
type ObjectMetadataOptions = SyncableEntityOptions & {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
};
export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
return () => {};
};
export class BaseObjectMetadata {}
export enum FieldMetadataType {
TEXT = 'TEXT',
FULL_NAME = 'FULL_NAME',
ADDRESS = 'ADDRESS',
SELECT = 'SELECT',
DATE_TIME = 'DATE_TIME',
}
export const FieldMetadata: (_: any) => PropertyDecorator;
}
`;
const serverlessFunctionMock = `
import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: any): Promise<any> => {
return {};
}
export const config: FunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'hello',
timeoutSeconds: 2,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
},
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent',
eventName: 'person.created'
}
]
};`;
const objectMock = `import {
ObjectMetadata,
BaseObjectMetadata,
FieldMetadata,
FieldMetadataType
} from 'twenty-sdk';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
@ObjectMetadata({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: ' A post card object',
icon: 'IconMail',
})
export class PostCard extends BaseObjectMetadata {
@FieldMetadata({
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.TEXT,
label: 'Content',
description: "Postcard's content",
})
content: string;
@FieldMetadata({
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldMetadataType.FULL_NAME,
label: 'Recipient name',
})
recipientName: string;
@FieldMetadata({
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldMetadataType.ADDRESS,
label: 'Recipient address',
})
recipientAddress: string;
@FieldMetadata({
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldMetadataType.SELECT,
label: 'Status',
defaultValue: \`'\${PostCardStatus.DRAFT}'\`,
options: [
{
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
})
status: 'draft' | 'sent' | 'delivered' | 'returned';
@FieldMetadata({
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldMetadataType.DATE_TIME,
label: 'Delivered at',
isNullable: true,
defaultValue: null,
})
deliveredAt?: Date;
}
`;
const packageJsonMock = {
name: 'my-app',
version: '0.0.1',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'create-entity': 'twenty app add',
dev: 'twenty app dev',
generate: 'twenty app generate',
sync: 'twenty app sync',
uninstall: 'twenty app uninstall',
auth: 'twenty auth login',
},
dependencies: {
'twenty-sdk': '0.1.0',
},
devDependencies: {
'@types/node': '^24.7.2',
typescript: '^5.9.3',
},
};
const tsConfigJsonMock = {
compileOnSave: false,
compilerOptions: {
sourceMap: true,
declaration: true,
outDir: './dist',
rootDir: '.',
moduleResolution: 'node',
allowSyntheticDefaultImports: true,
emitDecoratorMetadata: true,
experimentalDecorators: true,
importHelpers: true,
allowUnreachableCode: false,
strictNullChecks: true,
alwaysStrict: true,
noImplicitAny: true,
strictBindCallApply: false,
target: 'es2018',
module: 'esnext',
lib: ['es2020', 'dom'],
skipLibCheck: true,
skipDefaultLibCheck: true,
resolveJsonModule: true,
},
exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts'],
};
const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: 'My App',
description: 'My app description',
};
export default config;
`;
describe('loadManifest (integration)', () => {
const appDirectory = join(tmpdir(), 'test-app');
beforeEach(async () => {
await ensureDirSync(appDirectory);
write(appDirectory, 'yarn.lock', yarnLockMock);
write(appDirectory, 'application.config.ts', applicationConfigMock);
write(
appDirectory,
'tsconfig.json',
JSON.stringify(tsConfigJsonMock, null, 2),
);
write(
appDirectory,
'package.json',
JSON.stringify(packageJsonMock, null, 2),
);
write(appDirectory, 'src/Account.ts', objectMock);
write(appDirectory, 'src/hello.ts', serverlessFunctionMock);
write(
appDirectory,
'src/types/twenty-sdk-application.d.ts',
twentySdkTypesMock,
);
write(
appDirectory,
'src/types/tslib.d.ts',
// minimal + future-proof
tsLibMock,
);
});
afterEach(() => {
removeSync(appDirectory);
});
it('builds a full manifest for a valid workspace', async () => {
const { packageJson, yarnLock, manifest } =
await loadManifest(appDirectory);
expect(packageJson.name).toBe('my-app');
expect(packageJson.version).toBe('0.0.1');
expect(packageJson.license).toBe('MIT');
expect(yarnLock).toContain(
'# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.',
);
// application
const { universalIdentifier: _, ...otherInfo } = manifest.application;
expect(otherInfo).toEqual({
displayName: 'My App',
description: 'My app description',
});
expect(manifest.objects.length).toBe(1);
for (const object of manifest.objects) {
const { universalIdentifier: _, fields, ...otherInfo } = object;
expect(otherInfo).toEqual({
description: ' A post card object',
icon: 'IconMail',
labelPlural: 'Post cards',
labelSingular: 'Post card',
namePlural: 'postCards',
nameSingular: 'postCard',
});
expect(Array.isArray(fields)).toBe(true);
expect(fields).toEqual([
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: 'TEXT',
label: 'Content',
description: "Postcard's content",
name: 'content',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: 'FULL_NAME',
label: 'Recipient name',
name: 'recipientName',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: 'ADDRESS',
label: 'Recipient address',
name: 'recipientAddress',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: 'SELECT',
label: 'Status',
defaultValue: "'DRAFT'",
options: [
{ value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
{ value: 'SENT', label: 'Sent', position: 1, color: 'orange' },
{
value: 'DELIVERED',
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: 'RETURNED',
label: 'Returned',
position: 3,
color: 'orange',
},
],
name: 'status',
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: 'DATE_TIME',
label: 'Delivered at',
isNullable: true,
defaultValue: null,
name: 'deliveredAt',
},
]);
}
// serverless functions
for (const serverlessFunction of manifest.serverlessFunctions) {
const {
universalIdentifier: _,
handlerPath: __,
triggers,
...otherInfo
} = serverlessFunction;
expect(otherInfo).toEqual({
handlerName: 'main',
name: 'hello',
timeoutSeconds: 2,
});
for (const trigger of triggers) {
const { universalIdentifier: _, ...otherInfo } = trigger;
switch (trigger.type) {
case 'route':
expect(otherInfo).toEqual({
isAuthRequired: false,
httpMethod: 'GET',
path: '/post-card/create',
type: 'route',
});
break;
case 'cron':
expect(otherInfo).toEqual({
pattern: '0 0 1 1 *',
type: 'cron',
});
break;
case 'databaseEvent':
expect(otherInfo).toEqual({
eventName: 'person.created',
type: 'databaseEvent',
});
break;
}
}
}
});
it('should not define serverless for util file', async () => {
write(
appDirectory,
'src/utils/format.ts',
`
export const format = async (params: any): Promise<any> => {
return {};
}
`,
);
const { manifest } = await loadManifest(appDirectory);
expect(manifest.serverlessFunctions.length).toBe(1);
});
it('manifest should contains typescript sources', async () => {
const { manifest } = await loadManifest(appDirectory);
// the method is already exercised in loadManifest; just assert again:
expect(Object.keys(manifest.sources)).toEqual([
'application.config.ts',
'src',
]);
expect(Object.keys(manifest.sources['src'])).toEqual([
'Account.ts',
'hello.ts',
]);
});
it('manifest should contains typescript sources', async () => {
const { shouldGenerate } = await loadManifest(appDirectory);
expect(shouldGenerate).toBe(false);
});
});
@@ -0,0 +1,6 @@
import { startCase } from 'lodash';
export const convertToLabel = (str: string) => {
const s = startCase(str).toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};
@@ -0,0 +1,15 @@
import path from 'path';
import * as fs from 'fs-extra';
export const findPathFile = async (
appPath: string,
fileName: string,
): Promise<string> => {
const jsonPath = path.join(appPath, fileName);
if (await fs.pathExists(jsonPath)) {
return jsonPath;
}
throw new Error(`${fileName} not found in ${appPath}`);
};
@@ -0,0 +1,24 @@
import {
type Diagnostic,
formatDiagnosticsWithColorAndContext,
sys,
} from 'typescript';
export const formatAndWarnTsDiagnostics = ({
diagnostics,
}: {
diagnostics: Diagnostic[];
}) => {
if (diagnostics.length > 0) {
const formattedDiagnostics = formatDiagnosticsWithColorAndContext(
diagnostics,
{
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
},
);
console.warn(formattedDiagnostics);
}
};
@@ -0,0 +1,8 @@
import { join } from 'path';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export const formatPath = (appPath?: string) => {
return appPath && !appPath?.startsWith('/')
? join(CURRENT_EXECUTION_DIRECTORY, appPath)
: appPath;
};
@@ -0,0 +1,35 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getFunctionBaseFile = ({
name,
universalIdentifier = v4(),
}: {
name: string;
universalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
return `import { type FunctionConfig } from 'twenty-sdk';
export const main = async (params: {
a: string;
b: number;
}): Promise<{ message: string }> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// This is just an example
const message = \`Hello, input: $\{a} and $\{b}\`;
return { message };
};
export const config: FunctionConfig = {
universalIdentifier: '${universalIdentifier}',
name: '${kebabCaseName}',
timeoutSeconds: 5,
};
`;
};
@@ -0,0 +1,25 @@
import camelcase from 'lodash.camelcase';
export const getObjectDecoratedClass = ({
data,
name,
}: {
data: object;
name: string;
}) => {
const decoratorOptions = Object.entries(data)
.map(([key, value]) => ` ${key}: '${value}',`)
.join('\n');
const camelCaseName = camelcase(name);
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { Object } from 'twenty-sdk';
@Object({
${decoratorOptions}
})
export class ${className} {}
`;
};
@@ -0,0 +1,59 @@
import { join } from 'path';
import {
createProgram,
formatDiagnosticsWithColorAndContext,
parseJsonConfigFileContent,
readConfigFile,
sys,
type Program,
type Diagnostic,
} from 'typescript';
const getProgramFromTsconfig = ({
appPath,
tsconfigPath = 'tsconfig.json',
}: {
appPath: string;
tsconfigPath?: string;
}) => {
const configFile = readConfigFile(join(appPath, tsconfigPath), sys.readFile);
if (configFile.error)
throw new Error(
formatDiagnosticsWithColorAndContext([configFile.error], {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
const parsed = parseJsonConfigFileContent(configFile.config, sys, appPath);
if (parsed.errors.length) {
throw new Error(
formatDiagnosticsWithColorAndContext(parsed.errors, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
}
return createProgram(parsed.fileNames, parsed.options);
};
export const getTsProgramAndDiagnostics = async ({
appPath,
}: {
appPath: string;
}): Promise<{ program: Program; diagnostics: Diagnostic[] }> => {
const program = getProgramFromTsconfig({
appPath,
tsconfigPath: 'tsconfig.json',
});
return {
diagnostics: [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
],
program,
};
};
@@ -0,0 +1,72 @@
import * as fs from 'fs-extra';
import { type 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 parseTextFile = async (filePath: string) => {
return await fs.readFile(filePath, 'utf8');
};
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,17 @@
import * as fs from 'fs-extra';
import dotenv from 'dotenv';
import { findPathFile } from './find-path-file';
export const loadEnvVariables = async (appPath: string) => {
let envFile = '';
try {
const envFilePath = await findPathFile(appPath, '.env');
envFile = await fs.readFile(envFilePath, 'utf8');
} catch {
// Allow missing .env
}
return dotenv.parse(envFile);
};
@@ -0,0 +1,532 @@
import * as fs from 'fs-extra';
import { posix, relative, sep } from 'path';
import {
type Decorator,
type Expression,
type FunctionDeclaration,
type Modifier,
type Node,
type Program,
type SourceFile,
SyntaxKind,
type VariableDeclaration,
forEachChild,
getDecorators,
isArrayLiteralExpression,
isArrowFunction,
isCallExpression,
isClassDeclaration,
isComputedPropertyName,
isExportAssignment,
isFunctionExpression,
isIdentifier,
isImportDeclaration,
isNoSubstitutionTemplateLiteral,
isNumericLiteral,
isObjectLiteralExpression,
isPropertyAccessExpression,
isPropertyAssignment,
isPropertyDeclaration,
isShorthandPropertyAssignment,
isStringLiteralLike,
isTemplateExpression,
isVariableStatement,
} from 'typescript';
import { GENERATED_FOLDER_NAME } from '../services/generate.service';
import {
type AppManifest,
type Application,
type FieldMetadata,
type ObjectManifest,
type PackageJson,
type ServerlessFunctionManifest,
type Sources,
} from '../types/config.types';
import { findPathFile } from '../utils/find-path-file';
import { getTsProgramAndDiagnostics } from '../utils/get-ts-program-and-diagnostics';
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
import { formatAndWarnTsDiagnostics } from './format-and-warn-ts-diagnostics';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
const expr = node.expression;
if (isCallExpression(expr)) {
if (isIdentifier(expr.expression)) return expr.expression.text === name;
if (isPropertyAccessExpression(expr.expression))
return expr.expression.name.text === name;
}
return false;
};
const exprToValue = (expr: Expression): JSONValue => {
if (isStringLiteralLike(expr)) return expr.text;
if (isNumericLiteral(expr)) return Number(expr.text);
if (expr.kind === SyntaxKind.TrueKeyword) return true;
if (expr.kind === SyntaxKind.FalseKeyword) return false;
if (expr.kind === SyntaxKind.NullKeyword) return null;
if (isPropertyAccessExpression(expr)) {
if (isIdentifier(expr.expression) && isIdentifier(expr.name)) {
return expr.name.text;
}
return String(expr.getText());
}
if (isNoSubstitutionTemplateLiteral(expr)) {
return expr.text;
}
if (isTemplateExpression(expr)) {
let out = expr.head.text;
for (const span of expr.templateSpans) {
const v = exprToValue(span.expression);
out += String(v) + span.literal.text;
}
return out;
}
if (isArrayLiteralExpression(expr)) {
return expr.elements.map((e) =>
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
);
}
if (isObjectLiteralExpression(expr)) {
const obj: Record<string, JSONValue> = {};
for (const prop of expr.properties) {
if (isPropertyAssignment(prop)) {
const key =
isIdentifier(prop.name) || isStringLiteralLike(prop.name)
? prop.name.text
: isComputedPropertyName(prop.name) &&
isStringLiteralLike(prop.name.expression)
? prop.name.expression.text
: undefined;
if (key) obj[key] = exprToValue(prop.initializer);
} else if (isShorthandPropertyAssignment(prop)) {
// Unsupported without a checker; skip to keep it "light".
// Could resolve via typechecker if needed.
}
// getters/setters/methods are ignored intentionally
}
return obj;
}
// Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback.
// You can throw instead if you prefer to fail fast.
return isIdentifier(expr)
? expr.text
: String((expr as any).getText?.() ?? '');
};
const getFirstArgObject = (dec: Decorator) => {
if (!isCallExpression(dec.expression)) return undefined;
const [firstArg] = dec.expression.arguments;
return firstArg && isObjectLiteralExpression(firstArg)
? (exprToValue(firstArg) as Record<string, JSONValue>)
: undefined;
};
const collectObjects = (program: Program) => {
const manifest: ObjectManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) {
continue;
}
const visit = (node: Node) => {
if (isClassDeclaration(node) && getDecorators(node)?.length) {
const decorators = getDecorators(node);
const objectDec = decorators?.find(
(d) =>
isDecoratorNamed(d, 'ObjectMetadata') ||
isDecoratorNamed(d, 'Object'),
);
if (objectDec) {
const cfg = getFirstArgObject(objectDec);
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
const fields: Array<Record<string, JSONValue>> = [];
for (const member of node.members) {
if (!isPropertyDeclaration(member)) {
continue;
}
const fieldDec = getDecorators(member)?.find(
(d) =>
isDecoratorNamed(d, 'FieldMetadata') ||
isDecoratorNamed(d, 'Field'),
);
if (!fieldDec) {
continue;
}
const fieldCfg = getFirstArgObject(fieldDec);
if (!fieldCfg) {
continue;
}
// Try to attach the TypeScript property name as "name"
let name: string | undefined;
if (member.name && isIdentifier(member.name)) {
name = member.name.text;
} else {
// fallback to AST text if not a simple identifier
name = member.name?.getText?.() ?? undefined;
}
fields.push({
...(fieldCfg as FieldMetadata),
...(name ? { name } : {}),
});
}
manifest.push({ ...(cfg as any), fields } as ObjectManifest);
}
}
}
forEachChild(node, visit);
};
visit(sf);
}
return manifest;
};
// Add if you want a small guard for "export" presence on statements
const hasExportModifier = (st: any) =>
st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ??
false;
/**
* Finds (and validates) the new serverless file shape:
* - exactly 2 exported bindings
* - one must be `config` (typed FunctionConfig)
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
*/
const findHandlerAndConfig = (
sf: SourceFile,
): {
handlerName: ServerlessFunctionManifest['handlerName'];
configObject: Pick<
ServerlessFunctionManifest,
| 'universalIdentifier'
| 'name'
| 'description'
| 'timeoutSeconds'
| 'triggers'
>;
} => {
type Exported = {
name: string;
kind: 'function' | 'const';
init?: Expression;
declNode: Node;
};
const exported: Exported[] = [];
// 1) export const X = <arrow|function expr>
for (const st of sf.statements) {
if (!isVariableStatement(st) || !hasExportModifier(st)) continue;
for (const decl of st.declarationList.declarations) {
if (!isIdentifier(decl.name)) continue;
const name = decl.name.text;
const init = decl.initializer ?? undefined;
exported.push({
name,
kind: 'const',
init,
declNode: decl,
});
}
}
// 2) export function X() { ... }
for (const st of sf.statements) {
if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) {
const fd = st as FunctionDeclaration;
if (fd.name && isIdentifier(fd.name)) {
exported.push({
name: fd.name.text,
kind: 'function',
init: undefined,
declNode: fd,
});
}
}
}
// Enforce exactly two exports
const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values());
if (unique.length !== 2) {
throw new Error(
`Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`,
);
}
// Find config
const configExport = unique.find((e) => e.name === 'config');
if (!configExport) {
throw new Error(
`Serverless file ${sf.fileName} must export a binding named "config".`,
);
}
// Must be initialized to an object literal
if (!configExport.init || !isObjectLiteralExpression(configExport.init)) {
throw new Error(
`"config" in ${sf.fileName} must be initialized to an object literal.`,
);
}
// (Light) type guard: ensure declared type mentions FunctionConfig if present
const maybeVarDecl = configExport.declNode as VariableDeclaration;
if ('type' in maybeVarDecl && maybeVarDecl.type) {
const typeText = maybeVarDecl.type.getText(sf);
if (!/\bFunctionConfig\b/.test(typeText)) {
throw new Error(
`"config" in ${sf.fileName} must be typed as FunctionConfig (got: ${typeText}).`,
);
}
}
const configObject = exprToValue(configExport.init) as Pick<
ServerlessFunctionManifest,
| 'universalIdentifier'
| 'name'
| 'description'
| 'timeoutSeconds'
| 'triggers'
>;
// Identify the handler: the other export
const handlerExport = unique.find((e) => e.name !== 'config');
if (!handlerExport) {
throw new Error(`Could not find the handler export in ${sf.fileName}.`);
}
// If it's a const, make sure its a function-ish initializer
if (handlerExport.kind === 'const') {
const init = handlerExport.init;
const isFuncLike =
!!init && (isArrowFunction(init) || isFunctionExpression(init));
if (!isFuncLike) {
throw new Error(
`Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`,
);
}
}
return {
handlerName: handlerExport.name,
configObject,
};
};
const posixRelativeFromCwd = (fileName: string, appPath: string) => {
const rel = relative(appPath, fileName);
// normalize to posix separators for portability / manifest stability
return rel.split(sep).join(posix.sep);
};
const collectServerlessFunctions = (program: Program, appPath: string) => {
const serverlessFunctions: ServerlessFunctionManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
try {
const { handlerName, configObject } = findHandlerAndConfig(sf);
const handlerPath = posixRelativeFromCwd(sf.fileName, appPath);
serverlessFunctions.push({
...configObject,
handlerPath,
handlerName,
});
} catch {
// Not a serverless file under the new format — ignore and continue scanning.
continue;
}
}
return serverlessFunctions;
};
const setNested = (root: Sources, parts: string[], value: string) => {
let cur: Sources = root;
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
if (i === parts.length - 1) {
cur[key] = value;
} else {
cur[key] = (cur[key] ?? {}) as Sources;
cur = cur[key] as Sources;
}
}
};
const loadFolderContentIntoJson = async (
program: Program,
appPath: string,
): Promise<Sources> => {
const sources: Sources = {};
// Iterate only files the TS program knows about.
for (const sf of program.getSourceFiles()) {
const abs = sf.fileName;
// Skip .d.ts and anything outside sourcePath
if (sf.isDeclarationFile) continue;
if (!abs.startsWith(appPath + sep) && abs !== appPath) continue;
// Keep only TS/TSX files
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
if (abs.includes(`${sep}node_modules${sep}`)) continue;
const relFromRoot = relative(appPath, abs);
const parts = relFromRoot.split(sep);
const content = await fs.readFile(abs, 'utf8');
setNested(sources, parts, content);
}
return sources;
};
export const extractTwentyAppConfig = (program: Program): Application => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts'))
continue;
let found: Application | undefined;
const visit = (node: any): void => {
// Look for "export default twentyAppConfig"
if (isExportAssignment(node) && isIdentifier(node.expression)) {
const varName = node.expression.text;
// find the corresponding variable declaration
for (const stmt of sf.statements) {
if (isVariableStatement(stmt)) {
for (const decl of stmt.declarationList.declarations) {
if (isIdentifier(decl.name) && decl.name.text === varName) {
if (
decl.initializer &&
isObjectLiteralExpression(decl.initializer)
) {
found = exprToValue(decl.initializer) as Application;
}
}
}
}
}
}
if (!found) forEachChild(node, visit);
};
visit(sf);
if (found) return found;
}
throw new Error('Could not find default exported ApplicationConfig');
};
const isGeneratedModuleUsedInProgram = (program: Program): boolean => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
let found = false;
const visit = (node: Node): void => {
if (found) return;
if (isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier;
if (isStringLiteralLike(moduleSpecifier)) {
const moduleText = moduleSpecifier.text;
// Match ../../generated, ../generated, ./foo/generated, etc.
const isGeneratedModule =
moduleText === GENERATED_FOLDER_NAME ||
moduleText.endsWith(`/${GENERATED_FOLDER_NAME}`);
if (isGeneratedModule && node.importClause) {
found = true;
return;
}
}
}
forEachChild(node, visit);
};
visit(sf);
if (found) return true;
}
return false;
};
export const loadManifest = async (
appPath: string,
): Promise<{
packageJson: PackageJson;
yarnLock: string;
manifest: AppManifest;
shouldGenerate: boolean;
}> => {
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
const yarnLock = await parseTextFile(
await findPathFile(appPath, 'yarn.lock'),
);
const { diagnostics, program } = await getTsProgramAndDiagnostics({
appPath,
});
formatAndWarnTsDiagnostics({
diagnostics,
});
const [objects, serverlessFunctions, application, sources] = [
collectObjects(program),
collectServerlessFunctions(program, appPath),
extractTwentyAppConfig(program),
await loadFolderContentIntoJson(program, appPath),
];
const shouldGenerate = isGeneratedModuleUsedInProgram(program);
return {
packageJson,
yarnLock,
manifest: {
application,
objects,
serverlessFunctions,
sources,
},
shouldGenerate,
};
};