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:
+48
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
+1
@@ -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);
|
||||
};
|
||||
@@ -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 it’s 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user