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
+8 -5
View File
@@ -1,13 +1,16 @@
# Why Twenty CLI?
# Deprecated: twenty-cli
A command-line interface to easily scaffold, develop, and publish applications that extend Twenty CRM
## Installation
This package is deprecated. Please install and use twenty-sdk instead:
```bash
npm install -g twenty-cli
npm uninstall twenty-cli
npm install -g twenty-sdk
```
The command name remains the same: twenty.
A command-line interface to easily scaffold, develop, and publish applications that extend Twenty CRM (now provided by twenty-sdk).
## Requirements
- yarn >= 4.9.2
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
const message = `\nTwenty CLI (twenty-cli) is deprecated.\n\nPlease install and use the new package instead:\n npm install -g twenty-sdk\n\nThe command name remains the same: \"twenty\".\nMore info: https://www.npmjs.com/package/twenty-sdk\n`;
console.error(message);
process.exitCode = 1;
-111
View File
@@ -1,111 +0,0 @@
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import prettierPlugin from 'eslint-plugin-prettier';
export default [
js.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
// Browser globals that Node.js also has
URL: 'readonly',
URLSearchParams: 'readonly',
// Node.js types
NodeJS: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
prettier: prettierPlugin,
},
rules: {
...typescriptEslint.configs.recommended.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-useless-escape': 'off',
},
},
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
},
},
},
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
// Jest globals
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
jest: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
prettier: prettierPlugin,
},
rules: {
...typescriptEslint.configs.recommended.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-useless-escape': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**'],
},
];
-40
View File
@@ -1,40 +0,0 @@
const jestConfig = {
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: false },
},
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/cli.ts', // Exclude CLI entry point from coverage
],
coverageThreshold: {
global: {
statements: 1,
lines: 1,
functions: 1,
},
},
};
export default jestConfig;
-49
View File
@@ -1,49 +0,0 @@
import { type JestConfigWithTsJest } from 'ts-jest';
const jestConfig: JestConfigWithTsJest = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
displayName: 'twenty-cli-e2e',
silent: false,
errorOnDeprecated: true,
maxConcurrency: 1,
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testRegex: '\\.e2e-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalTeardown: '<rootDir>/src/__tests__/e2e/teardown.ts',
setupFilesAfterEnv: ['<rootDir>/src/__tests__/e2e/setupTest.ts'],
testTimeout: 30000, // 30 seconds timeout for e2e tests
maxWorkers: 1,
transform: {
'^.+\\.(t|j)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
baseUrl: '.',
paths: {
'src/*': ['./src/*'],
},
},
},
],
},
transformIgnorePatterns: [
'node_modules/(?!(chalk|inquirer|@inquirer|ansi-styles|strip-ansi|has-flag|supports-color|color-convert|color-name|wrap-ansi|string-width|is-fullwidth-code-point|emoji-regex|onetime|mimic-fn|signal-exit|yallist|lru-cache|p-limit|p-queue|p-timeout|p-finally|p-try|p-cancelable|p-locate|p-map|p-race|p-reduce|p-some|p-waterfall|p-defer|p-delay|p-retry|p-any|p-settle|p-all|p-map-series|p-map-concurrent|p-filter|p-reject|p-tap|p-log|p-debounce|p-throttle|p-forever|p-whilst|p-do-whilst|p-until|p-wait-for|p-min-delay)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
export default jestConfig;
+4 -58
View File
@@ -1,64 +1,10 @@
{
"name": "twenty-cli",
"version": "0.2.4",
"description": "Command-line interface for Twenty application development",
"main": "dist/cli.js",
"bin": {
"twenty": "dist/cli.js"
},
"files": [
"dist/**/*",
"!dist/**/*.e2e-spec.*",
"!dist/**/__tests__/**"
],
"version": "0.3.0",
"description": "[DEPRECATED] Use twenty-sdk instead: https://www.npmjs.com/package/twenty-sdk",
"scripts": {
"build": "echo 'use npx nx build'",
"dev": "tsx src/cli.ts",
"start": "node dist/cli.js"
"start": "echo 'deprecated'"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"license": "AGPL-3.0",
"dependencies": {
"@genql/cli": "^3.0.3",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
"commander": "^12.0.0",
"dotenv": "^16.4.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"inquirer": "^10.0.0",
"jsonc-parser": "^3.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.capitalize": "^4.2.1",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"typescript": "^5.9.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.5.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.capitalize": "^4",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"wait-on": "^7.2.0"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
"license": "AGPL-3.0"
}
-94
View File
@@ -1,94 +0,0 @@
{
"name": "twenty-cli",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"tags": ["scope:cli"],
"targets": {
"before-build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "packages/twenty-cli",
"commands": ["rimraf dist", "tsc --project tsconfig.lib.json"]
},
"dependsOn": ["^build", "typecheck"]
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "packages/twenty-cli",
"commands": [
"cp -R src/constants/base-application-project dist/constants"
]
},
"dependsOn": ["before-build"]
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-cli",
"command": "tsx src/cli.ts"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-cli",
"command": "node dist/cli.js"
}
},
"typecheck": {},
"lint": {
"options": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"configurations": {
"ci": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"fix": {}
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
}
}
},
"test:e2e": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"cwd": "packages/twenty-cli",
"commands": [
"npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && NODE_ENV=test npx jest --config ./jest.e2e.config.ts"
]
},
"parallel": false,
"dependsOn": [
"build",
{
"target": "database:reset",
"projects": "twenty-server"
},
{
"target": "start:ci-if-needed",
"projects": "twenty-server"
}
]
}
}
}
@@ -1,48 +0,0 @@
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);
});
},
);
@@ -1 +0,0 @@
export const COVERED_APPLICATION_FOLDERS = ['hello-world'] as const;
@@ -1 +0,0 @@
export const SERVER_URL = 'http://localhost:3000';
@@ -1,7 +0,0 @@
import { TwentyConfig } from '../../../types/config.types';
export const testConfig: TwentyConfig = {
apiUrl: 'http://localhost:3000',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
};
@@ -1,12 +0,0 @@
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();
});
});
@@ -1,12 +0,0 @@
import { ConfigService } from '../../services/config.service';
import { testConfig } from './constants/testConfig';
beforeAll(() => {
jest
.spyOn(ConfigService.prototype, 'getConfig')
.mockResolvedValue(testConfig);
});
afterAll(() => {
jest.restoreAllMocks();
});
@@ -1,14 +0,0 @@
import { exec } from 'child_process';
export default async function globalTeardown() {
return 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();
});
});
}
@@ -1,12 +0,0 @@
import path from 'path';
export const getTestedApplicationPath = (relativePath: string): string => {
const currentFileDir = __dirname;
const twentyAppsPath = path.resolve(
currentFileDir,
'../../../../../twenty-apps',
);
return path.join(twentyAppsPath, relativePath);
};
-53
View File
@@ -1,53 +0,0 @@
#!/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);
}
}
@@ -1,169 +0,0 @@
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 { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
import { convertToLabel } from '../utils/convert-to-label';
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
SERVERLESS_FUNCTION = 'serverlessFunction',
}
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.SERVERLESS_FUNCTION) {
const entityName = await this.getEntityName(entity);
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
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.SERVERLESS_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;
},
},
]);
}
}
@@ -1,86 +0,0 @@
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);
});
}
}
@@ -1,19 +0,0 @@
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;
}
}
}
@@ -1,120 +0,0 @@
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import * as path from 'path';
import { copyBaseApplicationProject } from '../utils/app-template';
import kebabCase from 'lodash.kebabcase';
import { convertToLabel } from '../utils/convert-to-label';
export class AppInitCommand {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
await this.validateDirectory(appDirectory);
this.logCreationInfo({ appDirectory, appName });
await fs.ensureDir(appDirectory);
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
this.logSuccess(appDirectory);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}> {
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name:',
when: () => !directory,
default: 'my-awesome-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
},
},
{
type: 'input',
name: 'displayName',
message: 'Application display name:',
default: (answers: any) => {
return convertToLabel(answers?.name ?? directory);
},
},
{
type: 'input',
name: 'description',
message: 'Application description (optional):',
default: '',
},
]);
const computedName = name ?? directory;
const appName = computedName.trim();
const appDisplayName = displayName.trim();
const appDescription = description.trim();
const appDirectory = directory
? path.join(process.cwd(), kebabCase(directory))
: path.join(process.cwd(), kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
}
const files = await fs.readdir(appDirectory);
if (files.length > 0) {
throw new Error(
`Directory ${appDirectory} already exists and is not empty`,
);
}
}
private logCreationInfo({
appDirectory,
appName,
}: {
appDirectory: string;
appName: string;
}): void {
console.log(chalk.blue('🎯 Creating Twenty Application'));
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
console.log(chalk.gray(`📝 Name: ${appName}`));
console.log('');
}
private logSuccess(appDirectory: string): void {
console.log(chalk.green('✅ Application created successfully!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(` cd ${appDirectory}`);
console.log(' twenty app dev');
}
}
@@ -1,63 +0,0 @@
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 { 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) {
console.error(
chalk.red('❌ Serverless functions Sync failed:'),
serverlessSyncResult.error,
);
} else {
console.log(chalk.green('✅ Serverless functions synced successfully'));
}
return serverlessSyncResult;
}
}
@@ -1,62 +0,0 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { 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) {
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;
}
}
@@ -1,132 +0,0 @@
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 { AppInitCommand } from './app-init.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 initCommand = new AppInitCommand();
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('init [directory]')
.description('Initialize a new Twenty application')
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await this.initCommand.execute(directory);
});
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;
}
}
@@ -1,162 +0,0 @@
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);
}
}
}
@@ -1,26 +0,0 @@
# Set environment values for your application here.
# Use the format: KEY=value
#
# These variables are automatically loaded when running your serverless functions.
# You can access them directly in your code using:
# const myValue = process.env.KEY;
#
# To make these variables available to your application, add them in application.config.ts file
#
# const config: ApplicationConfig = {
# ...
# applicationVariables: {
# KEY: {
# universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
# description: 'Description',
# isSecret: true,
# },
# },
# };
#
# Those environment variables will be provided to your serverless
# functions at runtime.
#
# Example:
# API_TOKEN=your-api-token
# TIMEOUT_MS=3000
@@ -1,5 +0,0 @@
# Duplicated with ./gitignore because npm publish does not include .gitignore
# https://github.com/npm/npm/issues/3763
.yarn/install-state.gz
.env
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -1,15 +0,0 @@
# {title}
{description}
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Install to your Twenty workspace
```bash
twenty auth login
twenty app sync
```
@@ -1,2 +0,0 @@
.yarn/install-state.gz
.env
@@ -1,17 +0,0 @@
{
"name": "my-application",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"twenty-sdk": "0.0.6"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,27 +0,0 @@
{
"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"]
}
@@ -1,38 +0,0 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@types/node@npm:^24.7.2":
version: 24.9.1
resolution: "@types/node@npm:24.9.1"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/c52f8168080ef9a7c3dc23d8ac6061fab5371aad89231a0f6f4c075869bc3de7e89b075b1f3e3171d9e5143d0dda1807c3dab8e32eac6d68f02e7480e7e78576
languageName: node
linkType: hard
"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -1,8 +0,0 @@
import { join } from 'path';
const BASE_PATH = join(__dirname, '../constants');
export const BASE_APPLICATION_PROJECT_PATH = join(
BASE_PATH,
'base-application-project',
);
@@ -1,2 +0,0 @@
export const CURRENT_EXECUTION_DIRECTORY =
process.env.INIT_CWD || process.cwd();
@@ -1,238 +0,0 @@
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;
}
}
}
@@ -1,109 +0,0 @@
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { 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] || {};
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',
};
}
}
@@ -1,61 +0,0 @@
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}`));
}
}
@@ -1,111 +0,0 @@
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;
@@ -1,10 +0,0 @@
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');
});
});
@@ -1,30 +0,0 @@
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/application';
@Object({
universalIdentifier: '4122a047-260f-4cf1-bf4f-a268579d7ddf',
nameSingular: 'name',
namePlural: 'names',
labelSingular: 'Name',
labelPlural: 'Names',
})
export class MyNewObject {}
`,
);
});
});
@@ -1,34 +0,0 @@
import { getServerlessFunctionBaseFile } from '../get-serverless-function-base-file';
describe('getServerlessFunctionBaseFile', () => {
it('should render proper file', () => {
expect(
getServerlessFunctionBaseFile({
name: 'serverless-function-name',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
}),
)
.toBe(`import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
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: ServerlessFunctionConfig = {
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
name: 'serverless-function-name',
timeoutSeconds: 5,
};
`);
});
});
@@ -1,419 +0,0 @@
import { ensureDirSync, removeSync, writeFileSync } from 'fs-extra';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { copyBaseApplicationProject } from '../app-template';
import { loadManifest } from '../load-manifest';
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/application' {
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 ServerlessFunctionConfig = 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 ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: any): Promise<any> => {
return {};
}
export const config: ServerlessFunctionConfig = {
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/application';
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;
}
`;
describe('loadManifest (integration)', () => {
const appName = 'my-app';
const appDisplayName = 'My App';
const appDescription = 'My app description';
const appDirectory = join(tmpdir(), 'twenty-manifest-');
beforeEach(async () => {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
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 file is generated by running ');
// application
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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 {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
universalIdentifier: _,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handlerPath: __,
triggers,
...otherInfo
} = serverlessFunction;
expect(otherInfo).toEqual({
handlerName: 'main',
name: 'hello',
timeoutSeconds: 2,
});
for (const trigger of triggers) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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);
});
});
@@ -1,107 +0,0 @@
import * as fs from 'fs-extra';
import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path';
import { writeJsoncFile } from '../utils/jsonc-parser';
import { join } from 'path';
import path from 'path';
import { v4 } from 'uuid';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
await fs.copy(BASE_APPLICATION_PROJECT_PATH, appDirectory);
await fs.rename(
join(appDirectory, 'gitignore'),
join(appDirectory, '.gitignore'),
);
await fs.copy(join(appDirectory, '.env.example'), join(appDirectory, '.env'));
await createBasePackageJson({
appName,
appDirectory,
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory,
});
await createReadmeContent({
displayName: appDisplayName,
appDescription,
appDirectory,
});
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
}: {
displayName: string;
description?: string;
appDirectory: string;
}) => {
const content = `import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
};
export default config;
`;
await fs.writeFile(path.join(appDirectory, 'application.config.ts'), content);
};
const createBasePackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const base = JSON.parse(await readBaseApplicationProjectFile('package.json'));
base['universalIdentifier'] = v4();
base['name'] = appName;
await writeJsoncFile(join(appDirectory, 'package.json'), base);
};
const createReadmeContent = async ({
displayName,
appDescription,
appDirectory,
}: {
displayName: string;
appDescription: string;
appDirectory: string;
}) => {
let readmeContent = await readBaseApplicationProjectFile('README.md');
readmeContent = readmeContent.replace(/\{title}/g, displayName);
readmeContent = readmeContent.replace(/\{description}/g, appDescription);
await fs.writeFile(path.join(appDirectory, 'README.md'), readmeContent);
};
const readBaseApplicationProjectFile = async (fileName: string) => {
return await fs.readFile(
join(BASE_APPLICATION_PROJECT_PATH, fileName),
'utf-8',
);
};
@@ -1,6 +0,0 @@
import { startCase } from 'lodash';
export const convertToLabel = (str: string) => {
const s = startCase(str).toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};
@@ -1,15 +0,0 @@
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}`);
};
@@ -1,20 +0,0 @@
import ts, { formatDiagnosticsWithColorAndContext, sys } from 'typescript';
export const formatAndWarnTsDiagnostics = ({
diagnostics,
}: {
diagnostics: ts.Diagnostic[];
}) => {
if (diagnostics.length > 0) {
const formattedDiagnostics = formatDiagnosticsWithColorAndContext(
diagnostics,
{
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
},
);
console.warn(formattedDiagnostics);
}
};
@@ -1,8 +0,0 @@
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;
};
@@ -1,25 +0,0 @@
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/application';
@Object({
${decoratorOptions}
})
export class ${className} {}
`;
};
@@ -1,35 +0,0 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
export const getServerlessFunctionBaseFile = ({
name,
universalIdentifier = v4(),
}: {
name: string;
universalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
return `import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
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: ServerlessFunctionConfig = {
universalIdentifier: '${universalIdentifier}',
name: '${kebabCaseName}',
timeoutSeconds: 5,
};
`;
};
@@ -1,58 +0,0 @@
import ts from 'typescript';
import { join } from 'path';
import {
createProgram,
formatDiagnosticsWithColorAndContext,
parseJsonConfigFileContent,
readConfigFile,
sys,
} 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: ts.Program; diagnostics: ts.Diagnostic[] }> => {
const program = getProgramFromTsconfig({
appPath,
tsconfigPath: 'tsconfig.json',
});
return {
diagnostics: [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
],
program,
};
};
@@ -1,72 +0,0 @@
import * as fs from 'fs-extra';
import { ParseError, parse as parseJsonc } from 'jsonc-parser';
export interface JsoncParseOptions {
allowTrailingComma?: boolean;
disallowComments?: boolean;
allowEmptyContent?: boolean;
}
export class JsoncParseError extends Error {
constructor(
message: string,
public readonly parseErrors: ParseError[],
public readonly filePath?: string,
) {
super(message);
this.name = 'JsoncParseError';
}
}
export const parseJsoncString = (
content: string,
options: JsoncParseOptions = {},
): any => {
const parseErrors: ParseError[] = [];
const result = parseJsonc(content, parseErrors, {
allowTrailingComma: options.allowTrailingComma ?? true,
disallowComments: options.disallowComments ?? false,
allowEmptyContent: options.allowEmptyContent ?? false,
});
if (parseErrors.length > 0) {
const errorMessages = parseErrors.map(
(error) => `Line ${error.offset}: ${error.error}`,
);
throw new JsoncParseError(
`JSONC parse errors:\n${errorMessages.join('\n')}`,
parseErrors,
);
}
return result;
};
export const 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');
};
@@ -1,17 +0,0 @@
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);
};
@@ -1,532 +0,0 @@
import * as fs from 'fs-extra';
import { posix, relative, sep } from 'path';
import {
Decorator,
Expression,
FunctionDeclaration,
Modifier,
Node,
Program,
SourceFile,
SyntaxKind,
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 {
AppManifest,
Application,
FieldMetadata,
ObjectManifest,
PackageJson,
ServerlessFunctionManifest,
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 ServerlessFunctionConfig)
* - 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 ServerlessFunctionConfig if present
const maybeVarDecl = configExport.declNode as VariableDeclaration;
if ('type' in maybeVarDecl && maybeVarDecl.type) {
const typeText = maybeVarDecl.type.getText(sf);
if (!/\bServerlessFunctionConfig\b/.test(typeText)) {
throw new Error(
`"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (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,
};
};
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist-e2e",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"types": ["jest", "node"]
},
"include": [
"src/**/*",
"src/**/__tests__/**/*.e2e-spec.ts"
],
"exclude": [
"node_modules",
"dist",
"dist-e2e",
"**/*.test.ts",
"**/*.spec.ts"
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"composite": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/__tests__/**"]
}
-21
View File
@@ -1,21 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/__tests__/**"]
}
-14
View File
@@ -1,14 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": [
"src/**/*",
"src/**/__tests__/**/*.spec.ts"
],
"exclude": [
"node_modules",
"dist",
]
}