diff --git a/packages/twenty-sdk/src/cli/build/build-watcher.ts b/packages/twenty-sdk/src/cli/build/build-watcher.ts deleted file mode 100644 index 784126cf49..0000000000 --- a/packages/twenty-sdk/src/cli/build/build-watcher.ts +++ /dev/null @@ -1,245 +0,0 @@ -import * as chokidar from 'chokidar'; -import path from 'path'; -import { type BuildWatcherState, type RebuildDecision } from './types'; -import { ASSETS_DIR } from '@/cli/constants/assets-dir'; - -/** - * BuildWatcher monitors file changes and triggers rebuilds. - * - * State machine: - * - IDLE: Waiting for changes - * - ANALYZING: Determining which files changed and what to rebuild - * - BUILDING: Rebuild in progress - * - ERROR: Build failed (can recover) - * - SUCCESS: Build succeeded, returning to IDLE - */ -export class BuildWatcher { - private state: BuildWatcherState = 'IDLE'; - private watcher: chokidar.FSWatcher | null = null; - private pendingChanges: Set = new Set(); - private debounceTimer: NodeJS.Timeout | null = null; - private readonly debounceMs: number; - - constructor( - private readonly appPath: string, - debounceMs = 500, - ) { - this.debounceMs = debounceMs; - } - - /** - * Start watching for file changes. - */ - async start( - onRebuild: (decision: RebuildDecision) => Promise, - ): Promise { - this.watcher = chokidar.watch(this.appPath, { - ignored: [ - /node_modules/, - /\.git/, - /\.twenty\/output/, - /\.twenty\/.*\.tar\.gz$/, - /dist/, - ], - persistent: true, - ignoreInitial: true, - }); - - const handleChange = (filepath: string) => { - // Only watch TypeScript files and relevant config files - if (!this.isWatchedFile(filepath)) { - return; - } - - this.pendingChanges.add(filepath); - - // Debounce rapid changes - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - - this.debounceTimer = setTimeout(async () => { - const changedFiles = Array.from(this.pendingChanges); - this.pendingChanges.clear(); - - if (changedFiles.length === 0) { - return; - } - - this.state = 'ANALYZING'; - - const decision = this.analyzeChanges(changedFiles); - - this.state = 'BUILDING'; - - try { - await onRebuild(decision); - this.state = 'SUCCESS'; - } catch { - this.state = 'ERROR'; - } finally { - this.state = 'IDLE'; - } - }, this.debounceMs); - }; - - this.watcher.on('change', handleChange); - this.watcher.on('add', handleChange); - this.watcher.on('unlink', handleChange); - } - - /** - * Stop watching for file changes. - */ - async stop(): Promise { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - - if (this.watcher) { - await this.watcher.close(); - this.watcher = null; - } - - this.state = 'IDLE'; - } - - /** - * Check if a file should trigger a rebuild. - */ - private isWatchedFile(filepath: string): boolean { - const ext = path.extname(filepath); - const basename = path.basename(filepath); - const relativePath = path.relative(this.appPath, filepath); - - // Watch TypeScript files - if (ext === '.ts' || ext === '.tsx') { - return true; - } - - // Watch relevant config files - if ( - basename === 'package.json' || - basename === 'tsconfig.json' || - basename === '.env' - ) { - return true; - } - - // Watch asset files in assets/ (at the root of the application) - const assetsDirPrefix = `${ASSETS_DIR}/`; - const assetsDirPrefixWin = `${ASSETS_DIR}\\`; - if ( - relativePath.startsWith(assetsDirPrefix) || - relativePath.startsWith(assetsDirPrefixWin) - ) { - return true; - } - - return false; - } - - /** - * Analyze changed files to determine what needs to be rebuilt. - * - * Manifest is composed from: - * - src/app/application.config.ts - * - src/app/**\/*.object.ts - * - src/app/**\/*.object-extension.ts - * - src/app/**\/*.role.ts - * - src/app/**\/*.function.ts (also requires function rebuild) - */ - private analyzeChanges(changedFiles: string[]): RebuildDecision { - const affectedFunctions: string[] = []; - let rebuildGenerated = false; - let assetsChanged = false; - let sharedFilesChanged = false; - let configChanged = false; - let manifestChanged = false; - - const assetsDirPrefix = `${ASSETS_DIR}/`; - - for (const filepath of changedFiles) { - const relativePath = path.relative(this.appPath, filepath); - // Normalize path separators for cross-platform compatibility - const normalizedPath = relativePath.replace(/\\/g, '/'); - const basename = path.basename(normalizedPath); - - // Check if it's a build config file (requires full rebuild) - if ( - basename === 'package.json' || - basename === 'tsconfig.json' || - basename === '.env' - ) { - configChanged = true; - continue; - } - - // Check if it's an asset file (in root assets/ folder) - if (normalizedPath.startsWith(assetsDirPrefix)) { - assetsChanged = true; - continue; - } - - // Check if it's in the generated folder - if (normalizedPath.startsWith('generated/')) { - rebuildGenerated = true; - continue; - } - - // Check if it's a manifest-related file in src/app/ - if (normalizedPath.startsWith('src/app/')) { - // Function files: rebuild function AND regenerate manifest - if (normalizedPath.endsWith('.function.ts')) { - affectedFunctions.push(normalizedPath); - manifestChanged = true; - continue; - } - - // Other manifest files: only regenerate manifest (no function rebuild) - // - application.config.ts - // - *.object.ts - // - *.object-extension.ts - // - *.role.ts - if ( - basename === 'application.config.ts' || - normalizedPath.endsWith('.object.ts') || - normalizedPath.endsWith('.object-extension.ts') || - normalizedPath.endsWith('.role.ts') - ) { - manifestChanged = true; - continue; - } - } - - // Check if it's a shared file that affects all functions - if ( - normalizedPath.startsWith('src/') && - !normalizedPath.startsWith('src/app/') && - (normalizedPath.endsWith('.ts') || normalizedPath.endsWith('.tsx')) - ) { - // Shared utility file outside src/app/ - rebuild all functions - sharedFilesChanged = true; - } - } - - return { - shouldRebuild: true, - affectedFunctions, - sharedFilesChanged, - configChanged, - manifestChanged, - rebuildGenerated, - assetsChanged, - changedFiles, - }; - } - - /** - * Get the current watcher state. - */ - getState(): BuildWatcherState { - return this.state; - } -} diff --git a/packages/twenty-sdk/src/cli/build/build.service.ts b/packages/twenty-sdk/src/cli/build/build.service.ts index 6ded78e5d3..ded433022e 100644 --- a/packages/twenty-sdk/src/cli/build/build.service.ts +++ b/packages/twenty-sdk/src/cli/build/build.service.ts @@ -1,24 +1,22 @@ -import path from 'path'; -import * as fs from 'fs-extra'; -import chalk from 'chalk'; -import { glob } from 'fast-glob'; -import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; -import { TarballService } from '@/cli/utilities/file/utils/file-tarball'; -import { loadManifest, type LoadManifestResult } from '@/cli/utilities/manifest/utils/manifest-load'; -import { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer'; -import { ViteBuildRunner } from './vite-build-runner'; -import { BuildWatcher } from './build-watcher'; -import { - type BuildOptions, - type BuildResult, - type ViteBuildConfig, - type BuildWatchHandle, - type RebuildDecision, -} from './types'; import { ASSETS_DIR } from '@/cli/constants/assets-dir'; import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; import { GENERATED_DIR } from '@/cli/constants/generated-dir'; import { OUTPUT_DIR } from '@/cli/constants/output-dir'; +import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; +import { TarballService } from '@/cli/utilities/file/utils/file-tarball'; +import { buildManifest, type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build'; +import { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer'; +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import * as fs from 'fs-extra'; +import path from 'path'; +import { + type BuildOptions, + type BuildResult, + type RebuildDecision, + type ViteBuildConfig +} from './types'; +import { ViteBuildRunner } from './vite-build-runner'; /** * BuildService orchestrates the build process for Twenty applications. @@ -38,7 +36,7 @@ export class BuildService { /** Cached state from the last successful build (used for incremental rebuilds) */ private lastBuildState: { - manifestResult: LoadManifestResult; + manifestResult: BuildManifestResult; builtFunctions: BuiltFunctionInfo[]; outputDir: string; } | null = null; @@ -78,7 +76,7 @@ export class BuildService { // Step 1: Load manifest console.log(chalk.gray(' Loading manifest...')); - const manifestResult = await loadManifest(appPath); + const manifestResult = await buildManifest(appPath); // Step 2: Prepare output directory const outputDir = path.join(appPath, OUTPUT_DIR); @@ -176,50 +174,7 @@ export class BuildService { } } - /** - * Start watch mode for incremental rebuilds. - */ - async watch(options: BuildOptions): Promise { - const { appPath } = options; - console.log(chalk.blue('šŸ“¦ Starting build watch mode')); - console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); - console.log(''); - - // Perform initial build - const initialResult = await this.build({ ...options, tarball: false }); - if (!initialResult.success) { - console.error( - chalk.red('Initial build failed, starting watcher anyway...'), - ); - } - - // Start the watcher - const watcher = new BuildWatcher(appPath); - - await watcher.start(async (decision) => { - if (!decision.shouldRebuild) { - return; - } - - // Use incremental rebuild based on what changed - const result = await this.incrementalRebuild(appPath, decision); - - if (result.success) { - console.log( - chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), - ); - } - }); - - console.log( - chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), - ); - - return { - stop: () => watcher.stop(), - }; - } /** * Perform an incremental rebuild based on what files changed. @@ -251,7 +206,7 @@ export class BuildService { // If manifest config changed, reload it if (decision.manifestChanged) { console.log(chalk.blue('šŸ”„ Manifest changed, regenerating...')); - manifestResult = await loadManifest(appPath); + manifestResult = await buildManifest(appPath); rebuildCount++; } @@ -348,7 +303,7 @@ export class BuildService { private async rebuildSpecificFunctions( appPath: string, outputDir: string, - manifestResult: LoadManifestResult, + manifestResult: BuildManifestResult, handlerPaths: string[], ): Promise { const { manifest } = manifestResult; @@ -454,7 +409,7 @@ export class BuildService { private async buildFunctions( appPath: string, outputDir: string, - manifestResult: LoadManifestResult, + manifestResult: BuildManifestResult, ): Promise { const { manifest } = manifestResult; const functionsOutputDir = path.join(outputDir, FUNCTIONS_DIR); diff --git a/packages/twenty-sdk/src/cli/build/index.ts b/packages/twenty-sdk/src/cli/build/index.ts index 13db1b49ee..a2685d3736 100644 --- a/packages/twenty-sdk/src/cli/build/index.ts +++ b/packages/twenty-sdk/src/cli/build/index.ts @@ -1,8 +1,7 @@ -export * from './types'; export { BuildService } from './build.service'; +export * from './types'; export { ViteBuildRunner } from './vite-build-runner'; -export { BuildWatcher } from './build-watcher'; -// Re-export from utilities for backward compatibility -export { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer'; export { TarballService } from '@/cli/utilities/file/utils/file-tarball'; +export { BuildManifestWriter, type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer'; + diff --git a/packages/twenty-sdk/src/cli/commands/app.command.ts b/packages/twenty-sdk/src/cli/commands/app.command.ts index 79b3324d77..cb8cd04b8c 100644 --- a/packages/twenty-sdk/src/cli/commands/app.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app.command.ts @@ -56,11 +56,9 @@ export const registerCommands = (program: Command): void => { program .command('app:dev [appPath]') - .description('Start development mode: sync local application changes') - .option('-d, --debounce ', 'Debounce delay in milliseconds', '1000') - .action(async (appPath, options) => { + .description('Watch and sync local application changes') + .action(async (appPath) => { await devCommand.execute({ - ...options, appPath: formatPath(appPath), }); }); diff --git a/packages/twenty-sdk/src/cli/commands/app/app-build.ts b/packages/twenty-sdk/src/cli/commands/app/app-build.ts index 54b3221b3f..7c0db222be 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-build.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-build.ts @@ -1,7 +1,7 @@ -import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; -import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; import { BuildService } from '@/cli/build/build.service'; import { type BuildResult } from '@/cli/build/types'; +import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; export type BuildCommandOptions = { appPath?: string; @@ -15,28 +15,6 @@ export class AppBuildCommand { async execute(options: BuildCommandOptions): Promise> { const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - if (options.watch) { - // Watch mode - this runs indefinitely - const watchHandle = await this.buildService.watch({ - appPath, - watch: true, - tarball: options.tarball, - }); - - // Setup graceful shutdown - this.setupGracefulShutdown(watchHandle.stop); - - // Return success immediately - the watch loop is running - return { - success: true, - data: { - outputDir: `${appPath}/.twenty/output`, - manifest: {} as any, // Will be populated during build - builtFunctions: [], - }, - }; - } - // One-time build return this.buildService.build({ appPath, diff --git a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts index 2dbb42db04..bea421eeda 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,112 +1,138 @@ -import { ApiService } from '@/cli/utilities/api/services/api.service'; +import { OUTPUT_DIR } from '@/cli/constants/output-dir'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; +import { type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build'; import { displayEntitySummary, displayErrors, displayWarnings, } from '@/cli/utilities/manifest/utils/manifest-display'; -import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load'; +import { + createManifestPlugin, + type ManifestBuildError, +} from '@/cli/utilities/vite-plugin/vite-manifest-plugin'; import chalk from 'chalk'; -import * as chokidar from 'chokidar'; +import * as fs from 'fs-extra'; +import path from 'path'; +import { createServer, type ViteDevServer } from 'vite'; + +export type AppDevOptions = { + appPath?: string; +}; export class AppDevCommand { - private apiService = new ApiService(); + private server: ViteDevServer | null = null; + private appPath: string = ''; - async execute(options: { - appPath?: string; - debounce: string; - }): Promise { - const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; + async execute(options: AppDevOptions): Promise { + this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - const debounceMs = parseInt(options.debounce, 10); + this.logStartupInfo(this.appPath); - this.logStartupInfo(appPath, debounceMs); + this.server = await this.createViteDevServer(this.appPath); - await this.synchronize(appPath); + await this.server.listen(); - const watcher = this.setupFileWatcher(appPath, debounceMs); - - this.setupGracefulShutdown(watcher); - } - - private async synchronize(appPath: string) { - try { - const { manifest, packageJson, yarnLock, warnings } = - await loadManifest(appPath); - - displayEntitySummary(manifest); - - displayWarnings(warnings); - - await this.apiService.syncApplication({ - manifest, - packageJson, - yarnLock, - }); - - console.log(chalk.green(' āœ“ Synced with server')); - } catch (error) { - if (error instanceof ManifestValidationError) { - displayErrors(error); - } else { - console.error( - chalk.red(' āœ— Sync failed:'), - error instanceof Error ? error.message : error, - ); - } - } - } - - 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.synchronize(appPath); - - console.log( - chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), - ); - }, debounceMs); - }; - - watcher.on('change', () => { - debouncedSync(); - }); + this.setupGracefulShutdown(); console.log( chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), ); - - return watcher; } - private setupGracefulShutdown(watcher: chokidar.FSWatcher): void { - process.on('SIGINT', () => { + private logStartupInfo(appPath: string): void { + console.log(chalk.blue('šŸš€ Starting Twenty Application Development Mode')); + console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); + console.log(''); + } + + private async createViteDevServer(appPath: string): Promise { + const manifestPlugin = createManifestPlugin({ + appPath, + onBuildStart: () => { + console.log(chalk.blue('šŸ”„ Building manifest...')); + }, + onBuildSuccess: (result: BuildManifestResult) => { + this.handleBuildSuccess(result); + }, + onBuildError: (error: ManifestBuildError) => { + this.handleBuildError(error); + }, + }); + + return createServer({ + root: appPath, + plugins: [manifestPlugin], + server: { + watch: { + ignored: ['**/node_modules/**', '**/.twenty/**', '**/dist/**'], + }, + port: 0, + open: false, + hmr: false, + }, + optimizeDeps: { + noDiscovery: true, + }, + logLevel: 'silent', + publicDir: false, + build: { + watch: { + include: [path.join(appPath, 'src/**')], + }, + }, + }); + } + + private handleBuildSuccess(result: BuildManifestResult): void { + displayEntitySummary(result.manifest); + + displayWarnings(result.warnings); + + this.writeManifestToOutput(result); + } + + private handleBuildError(error: ManifestBuildError): void { + if (error.errors) { + displayErrors(new ManifestValidationError(error.errors)); + } else { + console.error(chalk.red(' āœ— Build failed:'), error.message); + } + } + + private async writeManifestToOutput( + result: BuildManifestResult, + ): Promise { + try { + const outputDir = path.join(this.appPath, OUTPUT_DIR); + + await fs.ensureDir(outputDir); + + const manifestPath = path.join(outputDir, 'manifest.json'); + + await fs.writeJSON(manifestPath, result.manifest, { spaces: 2 }); + + console.log(chalk.green(` āœ“ Manifest written to ${manifestPath}`)); + console.log(''); + console.log( + chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), + ); + } catch (error) { + console.error( + chalk.red(' āœ— Failed to write manifest:'), + error instanceof Error ? error.message : error, + ); + } + } + + private setupGracefulShutdown(): void { + process.on('SIGINT', async () => { console.log(chalk.yellow('\nšŸ›‘ Stopping development mode...')); - watcher.close(); + + if (this.server) { + await this.server.close(); + } + process.exit(0); }); } diff --git a/packages/twenty-sdk/src/cli/commands/app/app-logs.ts b/packages/twenty-sdk/src/cli/commands/app/app-logs.ts index 6b1cbdfdfb..ebaaec6348 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-logs.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-logs.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import { ApiService } from '@/cli/utilities/api/services/api.service'; -import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load'; +import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; export class AppLogsCommand { private apiService = new ApiService(); @@ -16,7 +16,7 @@ export class AppLogsCommand { functionName?: string; }): Promise { try { - const { manifest } = await loadManifest(appPath); + const { manifest } = await buildManifest(appPath); this.logWatchInfo({ appName: manifest.application.displayName, functionUniversalIdentifier, diff --git a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts b/packages/twenty-sdk/src/cli/commands/app/app-sync.ts index 2ea19bbb2f..83f742d4af 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-sync.ts @@ -3,12 +3,12 @@ import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import { GenerateService } from '@/cli/utilities/generate/services/generate.service'; import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; +import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; import { displayEntitySummary, displayErrors, displayWarnings, } from '@/cli/utilities/manifest/utils/manifest-display'; -import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load'; import chalk from 'chalk'; export class AppSyncCommand { @@ -36,7 +36,7 @@ export class AppSyncCommand { private async synchronize({ appPath }: { appPath: string }) { try { const { manifest, packageJson, yarnLock, shouldGenerate, warnings } = - await loadManifest(appPath); + await buildManifest(appPath); displayEntitySummary(manifest); @@ -51,7 +51,7 @@ export class AppSyncCommand { if (shouldGenerate) { await this.generateService.generateClient(appPath); - const { manifest: manifestWithClient } = await loadManifest(appPath); + const { manifest: manifestWithClient } = await buildManifest(appPath); serverlessSyncResult = await this.apiService.syncApplication({ manifest: manifestWithClient, diff --git a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts index 6bb9c1b0ed..14963d75b8 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts @@ -3,7 +3,7 @@ import inquirer from 'inquirer'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; import { ApiService } from '@/cli/utilities/api/services/api.service'; import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; -import { loadManifest } from '@/cli/utilities/manifest/utils/manifest-load'; +import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; export class AppUninstallCommand { private apiService = new ApiService(); @@ -25,7 +25,7 @@ export class AppUninstallCommand { process.exit(1); } - const { manifest } = await loadManifest(appPath); + const { manifest } = await buildManifest(appPath); const result = await this.apiService.uninstallApplication( manifest.application.universalIdentifier, diff --git a/packages/twenty-sdk/src/cli/utilities/__tests__/convert-to-label.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/convert-to-label.spec.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/__tests__/convert-to-label.spec.ts rename to packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/convert-to-label.spec.ts diff --git a/packages/twenty-sdk/src/cli/utilities/__tests__/get-function-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-function-base-file.spec.ts similarity index 88% rename from packages/twenty-sdk/src/cli/utilities/__tests__/get-function-base-file.spec.ts rename to packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-function-base-file.spec.ts index 38fdbbc276..54604a2fbd 100644 --- a/packages/twenty-sdk/src/cli/utilities/__tests__/get-function-base-file.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-function-base-file.spec.ts @@ -7,11 +7,9 @@ describe('getFunctionBaseFile', () => { universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', }); - // Verify it uses defineFunction expect(result).toContain("import { defineFunction } from 'twenty-sdk'"); expect(result).toContain('export default defineFunction({'); - // Verify function properties expect(result).toContain( "universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4'", ); @@ -20,10 +18,8 @@ describe('getFunctionBaseFile', () => { expect(result).toContain('handler,'); expect(result).toContain('triggers: ['); - // Verify handler is exported expect(result).toContain('export const handler = async'); - // Verify description is included expect(result).toContain( "description: 'Add a description for your function'", ); @@ -34,7 +30,6 @@ describe('getFunctionBaseFile', () => { name: 'auto-uuid-function', }); - // Verify it has a universalIdentifier (UUID format) expect(result).toMatch( /universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, ); @@ -54,7 +49,6 @@ describe('getFunctionBaseFile', () => { name: 'example-function', }); - // Verify trigger examples are included as comments expect(result).toContain("type: 'route'"); expect(result).toContain("type: 'cron'"); expect(result).toContain("type: 'databaseEvent'"); diff --git a/packages/twenty-sdk/src/cli/utilities/__tests__/get-new-object-file-content.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-new-object-file-content.spec.ts similarity index 92% rename from packages/twenty-sdk/src/cli/utilities/__tests__/get-new-object-file-content.spec.ts rename to packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-new-object-file-content.spec.ts index 1a23757b0b..99c01dd6b2 100644 --- a/packages/twenty-sdk/src/cli/utilities/__tests__/get-new-object-file-content.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-new-object-file-content.spec.ts @@ -12,11 +12,9 @@ describe('getNewObjectFileContent', () => { name: 'company', }); - // Verify it uses defineObject expect(result).toContain("import { defineObject } from 'twenty-sdk'"); expect(result).toContain('export default defineObject({'); - // Verify object properties expect(result).toContain("nameSingular: 'company'"); expect(result).toContain("namePlural: 'companies'"); expect(result).toContain("labelSingular: 'Company'"); @@ -24,7 +22,6 @@ describe('getNewObjectFileContent', () => { expect(result).toContain("icon: 'IconBox'"); expect(result).toContain('fields: ['); - // Verify it has a universalIdentifier (UUID format) expect(result).toMatch( /universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, ); @@ -51,7 +48,6 @@ describe('getNewObjectFileContent', () => { name: 'person', }); - // Extract UUIDs const uuidRegex = /universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/; const uuid1 = result1.match(uuidRegex)?.[1]; diff --git a/packages/twenty-sdk/src/cli/utilities/__tests__/get-role-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-role-base-file.spec.ts similarity index 90% rename from packages/twenty-sdk/src/cli/utilities/__tests__/get-role-base-file.spec.ts rename to packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-role-base-file.spec.ts index b09798d357..f5ae797ab5 100644 --- a/packages/twenty-sdk/src/cli/utilities/__tests__/get-role-base-file.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/utils/__tests__/get-role-base-file.spec.ts @@ -7,11 +7,9 @@ describe('getRoleBaseFile', () => { universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4', }); - // Verify it uses defineRole expect(result).toContain("import { defineRole } from 'twenty-sdk'"); expect(result).toContain('export default defineRole({'); - // Verify role properties expect(result).toContain( "universalIdentifier: MY_ROLE_ROLE_UNIVERSAL_IDENTIFIER", ); @@ -21,7 +19,6 @@ describe('getRoleBaseFile', () => { expect(result).toContain("label: 'my-role'"); expect(result).toContain("description: 'Add a description for your role'"); - // Verify permission defaults expect(result).toContain('canReadAllObjectRecords: true'); expect(result).toContain('canUpdateAllObjectRecords: true'); expect(result).toContain('canSoftDeleteAllObjectRecords: true'); @@ -33,7 +30,6 @@ describe('getRoleBaseFile', () => { name: 'auto-uuid-role', }); - // Verify it has a universalIdentifier (UUID format) expect(result).toMatch( /'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, ); @@ -63,7 +59,6 @@ describe('getRoleBaseFile', () => { name: 'role-v2', }); - // kebab-case separates numbers with underscore when converted to constant expect(result).toContain('export const ROLE_V_2_ROLE_UNIVERSAL_IDENTIFIER'); expect(result).toContain("label: 'role-v2'"); }); diff --git a/packages/twenty-sdk/src/cli/utilities/__tests__/load-manifest.spec.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/build-manifest.spec.ts similarity index 89% rename from packages/twenty-sdk/src/cli/utilities/__tests__/load-manifest.spec.ts rename to packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/build-manifest.spec.ts index de32d438ec..900e73792c 100644 --- a/packages/twenty-sdk/src/cli/utilities/__tests__/load-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/build-manifest.spec.ts @@ -1,22 +1,22 @@ -import { join } from 'path'; -import { loadManifest, type LoadManifestResult } from '@/cli/utilities/manifest/utils/manifest-load'; import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role'; import { - POST_CARD_EXTENSION_PRIORITY_FIELD_ID, POST_CARD_EXTENSION_CATEGORY_FIELD_ID, + POST_CARD_EXTENSION_PRIORITY_FIELD_ID, } from '@/cli/__tests__/test-app/src/app/postCard.object-extension'; +import { buildManifest, type BuildManifestResult } from '@/cli/utilities/manifest/utils/manifest-build'; +import { join } from 'path'; -const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app'); +const TEST_APP_PATH = join(__dirname, '../../../../__tests__/test-app'); -describe('loadManifest with test-app', () => { - let manifest: LoadManifestResult['manifest']; - let packageJson: LoadManifestResult['packageJson']; - let yarnLock: LoadManifestResult['yarnLock']; - let warnings: LoadManifestResult['warnings']; - let shouldGenerate: LoadManifestResult['shouldGenerate']; +describe('buildManifest with test-app', () => { + let manifest: BuildManifestResult['manifest']; + let packageJson: BuildManifestResult['packageJson']; + let yarnLock: BuildManifestResult['yarnLock']; + let warnings: BuildManifestResult['warnings']; + let shouldGenerate: BuildManifestResult['shouldGenerate']; beforeAll(async () => { - const result = await loadManifest(TEST_APP_PATH); + const result = await buildManifest(TEST_APP_PATH); manifest = result.manifest; packageJson = result.packageJson; @@ -26,17 +26,13 @@ describe('loadManifest with test-app', () => { }, 15_000); it('should load manifest from test-app directory', async () => { - // Check package.json loaded correctly expect(packageJson.name).toBe('test-app'); expect(packageJson.version).toBe('0.0.1'); - // Check yarn.lock exists (can be empty) expect(yarnLock).toBeDefined(); - // Check no warnings expect(warnings).toEqual([]); - // Check application config expect(manifest.application).toBeDefined(); expect(manifest.application.universalIdentifier).toBe( '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', @@ -57,7 +53,6 @@ describe('loadManifest with test-app', () => { expect(postCard.labelPlural).toBe('Post cards'); expect(postCard.icon).toBe('IconMail'); - // Check fields expect(postCard.fields).toHaveLength(5); const contentField = postCard.fields?.find( @@ -87,7 +82,6 @@ describe('loadManifest with test-app', () => { expect(testFunction.handlerName).toBe('handler'); expect(testFunction.handlerPath).toBe('src/app/test-function.function.ts'); - // Check triggers expect(testFunction.triggers).toHaveLength(3); const routeTrigger = testFunction.triggers.find( @@ -113,7 +107,6 @@ describe('loadManifest with test-app', () => { expect(dbEventTrigger).toBeDefined(); expect(dbEventTrigger?.eventName).toBe('person.created'); - // Second function const testFunction2 = manifest.serverlessFunctions[0]; expect(testFunction2.universalIdentifier).toBe( 'eb3ffc98-88ec-45d4-9b4a-56833b219ccb', @@ -134,13 +127,11 @@ describe('loadManifest with test-app', () => { expect(role.canReadAllObjectRecords).toBe(false); expect(role.canUpdateAllObjectRecords).toBe(false); - // Check object permissions expect(role.objectPermissions).toHaveLength(1); expect(role.objectPermissions![0].objectNameSingular).toBe('postCard'); expect(role.objectPermissions![0].canReadObjectRecords).toBe(true); expect(role.objectPermissions![0].canUpdateObjectRecords).toBe(true); - // Check field permissions expect(role.fieldPermissions).toHaveLength(1); expect(role.fieldPermissions![0].objectNameSingular).toBe('postCard'); expect(role.fieldPermissions![0].fieldName).toBe('content'); @@ -149,7 +140,6 @@ describe('loadManifest with test-app', () => { expect(manifest.sources).toBeDefined(); expect(manifest.sources['src']).toBeDefined(); - // Check that the source files are loaded const srcSources = manifest.sources['src'] as Record; const appSources = srcSources['app'] as Record; expect(appSources['application.config.ts']).toBeDefined(); @@ -157,7 +147,6 @@ describe('loadManifest with test-app', () => { expect(appSources['test-function.function.ts']).toBeDefined(); expect(appSources['default-function.role.ts']).toBeDefined(); - // Verify source content contains expected code expect(appSources['application.config.ts']).toContain('defineApp'); expect(appSources['postCard.object.ts']).toContain('defineObject'); expect(appSources['test-function.function.ts']).toContain('defineFunction'); @@ -166,7 +155,6 @@ describe('loadManifest with test-app', () => { 'extendObject', ); - // Check object extensions expect(manifest.objectExtensions).toBeDefined(); expect(manifest.objectExtensions).toHaveLength(1); diff --git a/packages/twenty-sdk/src/cli/utilities/__tests__/validate-manifest.spec.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/validate-manifest.spec.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/__tests__/validate-manifest.spec.ts rename to packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/validate-manifest.spec.ts diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-load.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts similarity index 97% rename from packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-load.ts rename to packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts index 1a70222f7e..7bf8257665 100644 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-load.ts +++ b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts @@ -1,5 +1,10 @@ -import * as fs from 'fs-extra'; +import { type FunctionConfig } from '@/application/functions/function-config'; +import { type RoleConfig } from '@/application/role-config'; +import { loadConfig, loadFunctionModule } from '@/cli/utilities/file/utils/file-config-loader'; +import { findPathFile } from '@/cli/utilities/file/utils/file-find'; +import { parseJsoncFile, parseTextFile } from '@/cli/utilities/file/utils/file-jsonc'; import { glob } from 'fast-glob'; +import * as fs from 'fs-extra'; import path, { posix, relative, sep } from 'path'; import { type Application, @@ -11,16 +16,11 @@ import { type ServerlessFunctionManifest, } from 'twenty-shared/application'; import { type Sources } from 'twenty-shared/types'; -import { type FunctionConfig } from '@/application/functions/function-config'; -import { type RoleConfig } from '@/application/role-config'; -import { loadConfig, loadFunctionModule } from '@/cli/utilities/file/utils/file-config-loader'; -import { findPathFile } from '@/cli/utilities/file/utils/file-find'; -import { parseJsoncFile, parseTextFile } from '@/cli/utilities/file/utils/file-jsonc'; -import { validateManifest } from './manifest-validate'; import { ManifestValidationError, type ValidationWarning, } from '../types/manifest.types'; +import { validateManifest } from './manifest-validate'; /** * Validate that the required folder structure exists. @@ -236,7 +236,7 @@ const checkShouldGenerate = async (appPath: string): Promise => { return false; }; -export type LoadManifestResult = { +export type BuildManifestResult = { packageJson: PackageJson; yarnLock: string; manifest: ApplicationManifest; @@ -245,11 +245,11 @@ export type LoadManifestResult = { }; /** - * Load an application manifest using the folder structure with jiti runtime evaluation. + * Build an application manifest using the folder structure with jiti runtime evaluation. */ -export const loadManifest = async ( +export const buildManifest = async ( appPath: string, -): Promise => { +): Promise => { // Validate folder structure await validateFolderStructure(appPath); diff --git a/packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts b/packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts new file mode 100644 index 0000000000..d60aff5f87 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/vite-plugin/vite-manifest-plugin.ts @@ -0,0 +1,71 @@ +import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; +import { + buildManifest, + type BuildManifestResult, +} from '@/cli/utilities/manifest/utils/manifest-build'; +import { type Plugin } from 'vite'; + +const PLUGIN_NAME = 'twenty-manifest'; + +export type ManifestBuildError = { + message: string; + errors?: Array<{ path: string; message: string }>; +}; + +export type ManifestPluginOptions = { + appPath: string; + onBuildStart?: () => void; + onBuildSuccess?: (result: BuildManifestResult) => void; + onBuildError?: (error: ManifestBuildError) => void; +}; + +/** + * Creates a Vite plugin that builds the application manifest on startup + * and rebuilds it when source files change. + */ +export const createManifestPlugin = ( + options: ManifestPluginOptions, +): Plugin => { + const { appPath, onBuildStart, onBuildSuccess, onBuildError } = options; + + const runBuild = async (): Promise => { + onBuildStart?.(); + + try { + const result = await buildManifest(appPath); + + onBuildSuccess?.(result); + } catch (error) { + const buildError: ManifestBuildError = { + message: error instanceof Error ? error.message : String(error), + }; + + if (error instanceof ManifestValidationError) { + buildError.errors = error.errors; + } + + onBuildError?.(buildError); + } + }; + + return { + name: PLUGIN_NAME, + + buildStart: async () => { + await runBuild(); + }, + + handleHotUpdate: async ({ file }) => { + const relevantExtensions = ['.ts', '.json']; + const isRelevantFile = relevantExtensions.some((ext) => + file.endsWith(ext), + ); + + if (isRelevantFile) { + await runBuild(); + } + + return []; + }, + }; +};