diff --git a/packages/create-twenty-app/vite.config.ts b/packages/create-twenty-app/vite.config.ts index 2152d20dd8..b34d9f30df 100644 --- a/packages/create-twenty-app/vite.config.ts +++ b/packages/create-twenty-app/vite.config.ts @@ -75,6 +75,7 @@ export default defineConfig(() => { 'path', 'fs', 'child_process', + 'util', ], output: [ { diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 114f765496..6da9601ec5 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -32,6 +32,7 @@ "dependencies": { "@genql/cli": "^3.0.3", "@sniptt/guards": "^0.2.0", + "archiver": "^7.0.1", "axios": "^1.6.0", "chalk": "^5.3.0", "chokidar": "^4.0.0", @@ -49,9 +50,12 @@ "lodash.kebabcase": "^4.1.1", "lodash.startcase": "^4.4.0", "typescript": "^5.9.2", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "vite": "^7.0.0", + "vite-tsconfig-paths": "^4.2.1" }, "devDependencies": { + "@types/archiver": "^6.0.0", "@types/fs-extra": "^11.0.0", "@types/inquirer": "^9.0.0", "@types/jest": "^29.5.0", @@ -62,9 +66,7 @@ "@types/node": "^24.0.0", "jest": "^29.5.0", "tsx": "^4.7.0", - "vite": "^7.0.0", "vite-plugin-dts": "^4.5.4", - "vite-tsconfig-paths": "^4.2.1", "wait-on": "^7.2.0" }, "engines": { diff --git a/packages/twenty-sdk/src/cli/build/build-manifest-writer.ts b/packages/twenty-sdk/src/cli/build/build-manifest-writer.ts new file mode 100644 index 0000000000..016f8b692a --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/build-manifest-writer.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs-extra'; +import path from 'path'; +import { type ApplicationManifest } from 'twenty-shared/application'; +import { type BuiltFunctionInfo } from './types'; + +/** + * BuildManifestWriter creates the output manifest.json for a built application. + * + * The output manifest differs from the source manifest: + * - `serverlessFunctions[].handlerPath` points to built .js files + * - `sources` field is removed (replaced by built files) + */ +export class BuildManifestWriter { + /** + * Write the built manifest to the output directory. + * + * @param manifest - The original application manifest + * @param builtFunctions - Information about built functions with new paths + * @param outputDir - The output directory path + * @returns The path to the written manifest file + */ + async write(params: { + manifest: ApplicationManifest; + builtFunctions: BuiltFunctionInfo[]; + outputDir: string; + }): Promise { + const { manifest, builtFunctions, outputDir } = params; + + // Create a map of universalIdentifier -> built handler path + const builtPathMap = new Map(); + for (const fn of builtFunctions) { + builtPathMap.set(fn.universalIdentifier, fn.builtHandlerPath); + } + + // Create the output manifest with updated handler paths + const outputManifest: Omit = { + application: manifest.application, + objects: manifest.objects, + objectExtensions: manifest.objectExtensions, + serverlessFunctions: manifest.serverlessFunctions.map((fn) => { + const builtPath = builtPathMap.get(fn.universalIdentifier); + + if (!builtPath) { + // If function wasn't built, keep original path (shouldn't happen normally) + return fn; + } + + return { + ...fn, + // Update handler path to point to the built .js file + handlerPath: builtPath, + }; + }), + roles: manifest.roles, + }; + + const manifestPath = path.join(outputDir, 'manifest.json'); + + // Ensure the output directory exists + await fs.ensureDir(outputDir); + + // Write the manifest with pretty formatting + await fs.writeJSON(manifestPath, outputManifest, { spaces: 2 }); + + return manifestPath; + } +} diff --git a/packages/twenty-sdk/src/cli/build/build-watcher.ts b/packages/twenty-sdk/src/cli/build/build-watcher.ts new file mode 100644 index 0000000000..54f29a35c7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/build-watcher.ts @@ -0,0 +1,238 @@ +import * as chokidar from 'chokidar'; +import path from 'path'; +import { type BuildWatcherState, type RebuildDecision } from './types'; + +/** + * 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 src/assets/ + if (relativePath.startsWith('src/assets/') || relativePath.startsWith('src\\assets\\')) { + 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; + + 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 + if (normalizedPath.startsWith('src/assets/')) { + 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/assets/') && + !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 new file mode 100644 index 0000000000..6948f2b692 --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/build.service.ts @@ -0,0 +1,663 @@ +import path from 'path'; +import * as fs from 'fs-extra'; +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import { type ApiResponse } from '@/cli/types/api-response.types'; +import { + loadManifest, + type LoadManifestResult, +} from '@/cli/utils/load-manifest'; +import { ViteBuildRunner } from './vite-build-runner'; +import { BuildManifestWriter } from './build-manifest-writer'; +import { TarballService } from './tarball.service'; +import { BuildWatcher } from './build-watcher'; +import { + type BuildOptions, + type BuildResult, + type BuiltFunctionInfo, + type ViteBuildConfig, + type BuildWatchHandle, + type RebuildDecision, +} from './types'; + +/** + * BuildService orchestrates the build process for Twenty applications. + * + * Responsibilities: + * - Load the application manifest + * - Compute function entrypoints from serverlessFunctions[].handlerPath + * - Build each function using Vite + * - Build the generated/ folder if it exists + * - Copy assets + * - Write output manifest.json with updated paths + */ +export class BuildService { + private viteBuildRunner = new ViteBuildRunner(); + private manifestWriter = new BuildManifestWriter(); + private tarballService = new TarballService(); + + /** Cached state from the last successful build (used for incremental rebuilds) */ + private lastBuildState: { + manifestResult: LoadManifestResult; + builtFunctions: BuiltFunctionInfo[]; + outputDir: string; + } | null = null; + + private readonly OUTPUT_DIR = '.twenty/output'; + private readonly FUNCTIONS_DIR = 'functions'; + private readonly GENERATED_DIR = 'generated'; + private readonly ASSETS_DIR = 'assets'; + + /** + * Patterns to identify asset files that should be copied. + * Assets are static files needed at runtime but not TypeScript code. + */ + private readonly ASSET_PATTERNS = ['src/assets/**/*']; + + /** + * Files/patterns to exclude from asset copying. + */ + private readonly ASSET_IGNORE = [ + '**/node_modules/**', + '**/*.ts', + '**/*.tsx', + '**/.DS_Store', + '**/tsconfig.json', + '**/package.json', + '**/yarn.lock', + '**/.git/**', + ]; + + /** + * Perform a one-time build of the application. + */ + async build(options: BuildOptions): Promise> { + const { appPath, tarball } = options; + + try { + console.log(chalk.blue('šŸ“¦ Building Twenty Application')); + console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); + console.log(''); + + // Step 1: Load manifest + console.log(chalk.gray(' Loading manifest...')); + const manifestResult = await loadManifest(appPath); + + // Step 2: Prepare output directory + const outputDir = path.join(appPath, this.OUTPUT_DIR); + await this.prepareOutputDirectory(outputDir); + + // Step 3: Build all functions + console.log( + chalk.gray( + ` Building ${manifestResult.manifest.serverlessFunctions.length} function(s)...`, + ), + ); + const builtFunctions = await this.buildFunctions( + appPath, + outputDir, + manifestResult, + ); + + // Check for build failures + const failures = builtFunctions.filter( + (fn) => fn.builtHandlerPath === '', + ); + if (failures.length > 0) { + return { + success: false, + error: `Failed to build ${failures.length} function(s)`, + }; + } + + // Step 4: Build generated folder if it exists + const generatedPath = path.join(appPath, 'generated'); + if (await fs.pathExists(generatedPath)) { + console.log(chalk.gray(' Building generated client...')); + await this.buildGeneratedFolder(appPath, outputDir); + } + + // Step 5: Copy assets + const assetsCopied = await this.copyAssets(appPath, outputDir); + if (assetsCopied > 0) { + console.log(chalk.gray(` Copied ${assetsCopied} asset(s)`)); + } + + // Step 6: Write output manifest + console.log(chalk.gray(' Writing manifest...')); + await this.manifestWriter.write({ + manifest: manifestResult.manifest, + builtFunctions, + outputDir, + }); + + // Step 7: Create tarball if requested + let tarballPath: string | undefined; + if (tarball) { + console.log(chalk.gray(' Creating tarball...')); + tarballPath = await this.tarballService.create({ + sourceDir: outputDir, + outputPath: path.join( + appPath, + '.twenty', + `${manifestResult.manifest.application.displayName || 'app'}.tar.gz`, + ), + }); + } + + const buildResult: BuildResult = { + outputDir, + manifest: manifestResult.manifest, + builtFunctions, + tarballPath, + }; + + // Cache the build state for incremental rebuilds + this.lastBuildState = { + manifestResult, + builtFunctions, + outputDir, + }; + + console.log(''); + console.log(chalk.green('āœ… Build completed successfully')); + console.log(chalk.gray(` Output: ${outputDir}`)); + if (tarballPath) { + console.log(chalk.gray(` Tarball: ${tarballPath}`)); + } + + return { success: true, data: buildResult }; + } catch (error) { + console.error( + chalk.red('āŒ Build failed:'), + error instanceof Error ? error.message : error, + ); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 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. + * + * This is more efficient than a full rebuild because it only rebuilds + * the parts of the application that were affected by the changes. + */ + private async incrementalRebuild( + appPath: string, + decision: RebuildDecision, + ): Promise> { + try { + // If config changed or we don't have cached state, do a full rebuild + if (decision.configChanged || !this.lastBuildState) { + console.log(chalk.blue('šŸ”„ Config changed, performing full rebuild...')); + const result = await this.build({ appPath, tarball: false }); + if (result.success) { + return { success: true, data: undefined }; + } + return { success: false, error: result.error }; + } + + let { manifestResult, outputDir } = this.lastBuildState; + let { builtFunctions } = this.lastBuildState; + let rebuildCount = 0; + + // If manifest config changed, reload it + if (decision.manifestChanged) { + console.log(chalk.blue('šŸ”„ Manifest changed, regenerating...')); + manifestResult = await loadManifest(appPath); + rebuildCount++; + } + + // Determine which functions need rebuilding + const functionsToRebuild: string[] = []; + + if (decision.sharedFilesChanged) { + // Shared files changed - rebuild ALL functions + console.log( + chalk.blue('šŸ”„ Shared files changed, rebuilding all functions...'), + ); + functionsToRebuild.push( + ...manifestResult.manifest.serverlessFunctions.map( + (fn) => fn.handlerPath, + ), + ); + } else if (decision.affectedFunctions.length > 0) { + // Only specific functions changed + console.log( + chalk.blue( + `šŸ”„ Rebuilding ${decision.affectedFunctions.length} function(s)...`, + ), + ); + functionsToRebuild.push(...decision.affectedFunctions); + } + + // Rebuild affected functions + if (functionsToRebuild.length > 0) { + const rebuiltFunctions = await this.rebuildSpecificFunctions( + appPath, + outputDir, + manifestResult, + functionsToRebuild, + ); + + // Merge rebuilt functions into the existing list + builtFunctions = this.mergeBuiltFunctions( + builtFunctions, + rebuiltFunctions, + ); + rebuildCount += rebuiltFunctions.length; + } + + // Rebuild generated folder if needed + if (decision.rebuildGenerated) { + console.log(chalk.gray(' Rebuilding generated client...')); + await this.buildGeneratedFolder(appPath, outputDir); + rebuildCount++; + } + + // Copy assets if needed + if (decision.assetsChanged) { + const assetsCopied = await this.copyAssets(appPath, outputDir); + if (assetsCopied > 0) { + console.log(chalk.gray(` Copied ${assetsCopied} asset(s)`)); + rebuildCount++; + } + } + + // Update manifest after any rebuild + if (rebuildCount > 0) { + await this.manifestWriter.write({ + manifest: manifestResult.manifest, + builtFunctions, + outputDir, + }); + + // Update cached state + this.lastBuildState = { + manifestResult, + builtFunctions, + outputDir, + }; + + console.log(chalk.green('āœ… Incremental rebuild completed')); + } + + return { success: true, data: undefined }; + } catch (error) { + console.error( + chalk.red('āŒ Incremental rebuild failed:'), + error instanceof Error ? error.message : error, + ); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Rebuild only specific functions that changed. + */ + private async rebuildSpecificFunctions( + appPath: string, + outputDir: string, + manifestResult: LoadManifestResult, + handlerPaths: string[], + ): Promise { + const { manifest } = manifestResult; + const functionsOutputDir = path.join(outputDir, this.FUNCTIONS_DIR); + + // Normalize paths for comparison + const normalizedPaths = new Set( + handlerPaths.map((p) => p.replace(/\\/g, '/')), + ); + + // Find the functions to rebuild + const functionsToRebuild = manifest.serverlessFunctions.filter((fn) => { + const normalizedHandler = fn.handlerPath.replace(/\\/g, '/'); + return normalizedPaths.has(normalizedHandler); + }); + + if (functionsToRebuild.length === 0) { + return []; + } + + // Build configs for the affected functions + const buildConfigs: ViteBuildConfig[] = functionsToRebuild.map((fn) => { + const { relativePath, outputDir: fnOutputDir } = + this.computeFunctionOutputPath(fn.handlerPath); + const outputFileName = path.basename(relativePath); + const depth = fnOutputDir ? fnOutputDir.split('/').length + 1 : 1; + const generatedRelativePath = + '../'.repeat(depth) + this.GENERATED_DIR + '/index.js'; + + return { + appPath, + entryPath: fn.handlerPath, + outputDir: path.join(functionsOutputDir, fnOutputDir), + outputFileName, + generatedRelativePath, + }; + }); + + const results = + await this.viteBuildRunner.buildFunctionsParallel(buildConfigs); + + const builtFunctions: BuiltFunctionInfo[] = []; + + for (const fn of functionsToRebuild) { + const { relativePath } = this.computeFunctionOutputPath(fn.handlerPath); + const outputFileName = path.basename(relativePath); + const result = results.get(outputFileName); + + if (result?.success) { + console.log(chalk.gray(` āœ“ ${fn.name || fn.universalIdentifier}`)); + builtFunctions.push({ + name: fn.name || fn.universalIdentifier, + universalIdentifier: fn.universalIdentifier, + originalHandlerPath: fn.handlerPath, + builtHandlerPath: `${this.FUNCTIONS_DIR}/${relativePath}`, + sourceMapPath: result.sourceMapPath + ? `${this.FUNCTIONS_DIR}/${relativePath}.map` + : undefined, + }); + } else { + console.error( + chalk.red(` āœ— ${fn.name || fn.universalIdentifier}`), + result?.error?.message || 'Unknown error', + ); + builtFunctions.push({ + name: fn.name || fn.universalIdentifier, + universalIdentifier: fn.universalIdentifier, + originalHandlerPath: fn.handlerPath, + builtHandlerPath: '', // Empty indicates failure + }); + } + } + + return builtFunctions; + } + + /** + * Merge newly rebuilt functions into the existing list. + */ + private mergeBuiltFunctions( + existing: BuiltFunctionInfo[], + rebuilt: BuiltFunctionInfo[], + ): BuiltFunctionInfo[] { + const rebuiltMap = new Map( + rebuilt.map((fn) => [fn.universalIdentifier, fn]), + ); + + return existing.map((fn) => rebuiltMap.get(fn.universalIdentifier) || fn); + } + + /** + * Prepare the output directory by cleaning and recreating it. + */ + private async prepareOutputDirectory(outputDir: string): Promise { + await fs.remove(outputDir); + await fs.ensureDir(outputDir); + await fs.ensureDir(path.join(outputDir, this.FUNCTIONS_DIR)); + } + + /** + * Build all serverless functions from the manifest. + */ + private async buildFunctions( + appPath: string, + outputDir: string, + manifestResult: LoadManifestResult, + ): Promise { + const { manifest } = manifestResult; + const functionsOutputDir = path.join(outputDir, this.FUNCTIONS_DIR); + + // Compute output paths preserving directory structure + const functionOutputPaths = manifest.serverlessFunctions.map((fn) => + this.computeFunctionOutputPath(fn.handlerPath), + ); + + // Ensure all subdirectories exist + const uniqueDirs = new Set( + functionOutputPaths + .map((p) => path.dirname(p.relativePath)) + .filter(Boolean), + ); + for (const dir of uniqueDirs) { + await fs.ensureDir(path.join(functionsOutputDir, dir)); + } + + const buildConfigs: ViteBuildConfig[] = manifest.serverlessFunctions.map( + (fn, index) => { + const { relativePath, outputDir: fnOutputDir } = + functionOutputPaths[index]; + const outputFileName = path.basename(relativePath); + + // Compute the relative path from the function to the generated folder + // functions/lqq.function.js → ../generated/index.js + // functions/toto/lqq.function.js → ../../generated/index.js + const depth = fnOutputDir ? fnOutputDir.split('/').length + 1 : 1; + const generatedRelativePath = + '../'.repeat(depth) + this.GENERATED_DIR + '/index.js'; + + return { + appPath, + entryPath: fn.handlerPath, + outputDir: path.join(functionsOutputDir, fnOutputDir), + outputFileName, + generatedRelativePath, + }; + }, + ); + + const results = + await this.viteBuildRunner.buildFunctionsParallel(buildConfigs); + + const builtFunctions: BuiltFunctionInfo[] = []; + + for (let i = 0; i < manifest.serverlessFunctions.length; i++) { + const fn = manifest.serverlessFunctions[i]; + const { relativePath } = functionOutputPaths[i]; + const outputFileName = path.basename(relativePath); + const result = results.get(outputFileName); + + if (result?.success) { + console.log(chalk.gray(` āœ“ ${fn.name || fn.universalIdentifier}`)); + builtFunctions.push({ + name: fn.name || fn.universalIdentifier, + universalIdentifier: fn.universalIdentifier, + originalHandlerPath: fn.handlerPath, + builtHandlerPath: `${this.FUNCTIONS_DIR}/${relativePath}`, + sourceMapPath: result.sourceMapPath + ? `${this.FUNCTIONS_DIR}/${relativePath}.map` + : undefined, + }); + } else { + console.error( + chalk.red(` āœ— ${fn.name || fn.universalIdentifier}`), + result?.error?.message || 'Unknown error', + ); + builtFunctions.push({ + name: fn.name || fn.universalIdentifier, + universalIdentifier: fn.universalIdentifier, + originalHandlerPath: fn.handlerPath, + builtHandlerPath: '', // Empty indicates failure + }); + } + } + + return builtFunctions; + } + + /** + * Compute the output path for a function, preserving directory structure. + * + * Examples: + * - src/app/lqq.function.ts → { relativePath: 'lqq.function.js', outputDir: '' } + * - src/app/toto/lqq.function.ts → { relativePath: 'toto/lqq.function.js', outputDir: 'toto' } + */ + private computeFunctionOutputPath(handlerPath: string): { + relativePath: string; + outputDir: string; + } { + // Normalize path separators + const normalizedPath = handlerPath.replace(/\\/g, '/'); + + // Remove src/app/ prefix if present + let relativePath = normalizedPath; + if (relativePath.startsWith('src/app/')) { + relativePath = relativePath.slice('src/app/'.length); + } else if (relativePath.startsWith('src/')) { + relativePath = relativePath.slice('src/'.length); + } + + // Change extension from .ts to .js + relativePath = relativePath.replace(/\.ts$/, '.js'); + + // Get the directory part (empty string if no subdirectory) + const outputDir = path.dirname(relativePath); + const normalizedOutputDir = outputDir === '.' ? '' : outputDir; + + return { + relativePath, + outputDir: normalizedOutputDir, + }; + } + + /** + * Build the generated/ folder containing the GraphQL client. + */ + private async buildGeneratedFolder( + appPath: string, + outputDir: string, + ): Promise { + const generatedIndexPath = path.join(appPath, 'generated', 'index.ts'); + const generatedOutputDir = path.join(outputDir, this.GENERATED_DIR); + + if (!(await fs.pathExists(generatedIndexPath))) { + // No index.ts in generated folder, skip + return; + } + + await fs.ensureDir(generatedOutputDir); + + const result = await this.viteBuildRunner.buildGenerated({ + appPath, + entryPath: 'generated/index.ts', + outputDir: generatedOutputDir, + outputFileName: 'index.js', + }); + + if (result.success) { + console.log(chalk.gray(' āœ“ generated/index.js')); + } else { + console.error( + chalk.red(' āœ— generated/index.js'), + result.error?.message || 'Unknown error', + ); + } + } + + /** + * Copy static assets from the app to the output directory. + * + * Looks for assets in: + * - src/assets/ + * - assets/ + * + * @returns The number of files copied + */ + private async copyAssets( + appPath: string, + outputDir: string, + ): Promise { + const assetsOutputDir = path.join(outputDir, this.ASSETS_DIR); + + // Find all asset files + const assetFiles = await glob(this.ASSET_PATTERNS, { + cwd: appPath, + ignore: this.ASSET_IGNORE, + absolute: false, + onlyFiles: true, + }); + + if (assetFiles.length === 0) { + return 0; + } + + // Ensure the assets output directory exists + await fs.ensureDir(assetsOutputDir); + + // Copy each asset file, preserving directory structure + for (const assetFile of assetFiles) { + const sourcePath = path.join(appPath, assetFile); + + // Compute the relative path within assets/ + // Remove src/assets/ or assets/ prefix + let relativePath = assetFile; + if (relativePath.startsWith('src/assets/')) { + relativePath = relativePath.slice('src/assets/'.length); + } else if (relativePath.startsWith('assets/')) { + relativePath = relativePath.slice('assets/'.length); + } + + const destPath = path.join(assetsOutputDir, relativePath); + + // Ensure the destination directory exists + await fs.ensureDir(path.dirname(destPath)); + + // Copy the file + await fs.copy(sourcePath, destPath); + } + + return assetFiles.length; + } +} diff --git a/packages/twenty-sdk/src/cli/build/index.ts b/packages/twenty-sdk/src/cli/build/index.ts new file mode 100644 index 0000000000..2e7fc48451 --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export { BuildService } from './build.service'; +export { ViteBuildRunner } from './vite-build-runner'; +export { BuildManifestWriter } from './build-manifest-writer'; +export { BuildWatcher } from './build-watcher'; +export { TarballService } from './tarball.service'; diff --git a/packages/twenty-sdk/src/cli/build/tarball.service.ts b/packages/twenty-sdk/src/cli/build/tarball.service.ts new file mode 100644 index 0000000000..c0154f4c71 --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/tarball.service.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs-extra'; +import path from 'path'; +import archiver from 'archiver'; + +/** + * TarballService creates distributable .tar.gz archives from built applications. + */ +export class TarballService { + /** + * Create a tarball from the build output directory. + * + * @param sourceDir - The directory containing built files + * @param outputPath - The path for the output .tar.gz file + * @returns The absolute path to the created tarball + */ + async create(options: { + sourceDir: string; + outputPath: string; + }): Promise { + const { sourceDir, outputPath } = options; + + // Ensure the source directory exists + if (!(await fs.pathExists(sourceDir))) { + throw new Error(`Source directory does not exist: ${sourceDir}`); + } + + // Ensure the output directory exists + await fs.ensureDir(path.dirname(outputPath)); + + // Normalize the output path to have .tar.gz extension + const normalizedOutputPath = outputPath.endsWith('.tar.gz') + ? outputPath + : `${outputPath}.tar.gz`; + + return new Promise((resolve, reject) => { + const output = fs.createWriteStream(normalizedOutputPath); + const archive = archiver('tar', { + gzip: true, + gzipOptions: { level: 9 }, // Maximum compression + }); + + output.on('close', () => { + resolve(normalizedOutputPath); + }); + + archive.on('error', (err: Error) => { + reject(err); + }); + + archive.on('warning', (err: Error & { code?: string }) => { + if (err.code === 'ENOENT') { + // Log warnings about missing files + console.warn('Archive warning:', err.message); + } else { + reject(err); + } + }); + + archive.pipe(output); + + // Add the entire source directory to the archive + archive.directory(sourceDir, false); + + archive.finalize(); + }); + } +} diff --git a/packages/twenty-sdk/src/cli/build/types.ts b/packages/twenty-sdk/src/cli/build/types.ts new file mode 100644 index 0000000000..9773a83cc3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/types.ts @@ -0,0 +1,66 @@ +import { type ApplicationManifest } from 'twenty-shared/application'; + +export type BuildOptions = { + appPath: string; + watch?: boolean; + tarball?: boolean; +}; + +export type BuildResult = { + outputDir: string; + manifest: ApplicationManifest; + builtFunctions: BuiltFunctionInfo[]; + tarballPath?: string; +}; + +export type BuiltFunctionInfo = { + name: string; + universalIdentifier: string; + originalHandlerPath: string; + builtHandlerPath: string; + sourceMapPath?: string; +}; + +export type ViteBuildConfig = { + appPath: string; + entryPath: string; + outputDir: string; + outputFileName: string; + treeshake?: boolean; + external?: (string | RegExp)[]; + /** Relative path from the output file to the generated folder */ + generatedRelativePath?: string; +}; + +export type ViteBuildResult = { + success: boolean; + outputPath: string; + sourceMapPath?: string; + error?: Error; +}; + +export type BuildWatcherState = + | 'IDLE' + | 'ANALYZING' + | 'BUILDING' + | 'ERROR' + | 'SUCCESS'; + +export type RebuildDecision = { + shouldRebuild: boolean; + /** Specific function files that changed (only these need rebuilding) */ + affectedFunctions: string[]; + /** Shared utility files changed (requires rebuilding ALL functions) */ + sharedFilesChanged: boolean; + /** Build config files changed (requires full rebuild): package.json, tsconfig.json, .env */ + configChanged: boolean; + /** Manifest config changed (requires manifest regeneration only): application.config.ts */ + manifestChanged: boolean; + rebuildGenerated: boolean; + assetsChanged: boolean; + changedFiles: string[]; +}; + +export type BuildWatchHandle = { + stop: () => Promise; +}; diff --git a/packages/twenty-sdk/src/cli/build/vite-build-runner.ts b/packages/twenty-sdk/src/cli/build/vite-build-runner.ts new file mode 100644 index 0000000000..37a93fb695 --- /dev/null +++ b/packages/twenty-sdk/src/cli/build/vite-build-runner.ts @@ -0,0 +1,245 @@ +import { build, type InlineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import path from 'path'; +import * as fs from 'fs-extra'; +import { type ViteBuildConfig, type ViteBuildResult } from './types'; + +/** + * ViteBuildRunner handles the transpilation of TypeScript serverless functions + * into distributable JavaScript bundles using Vite's programmatic API. + * + * Key design decisions: + * - External node modules: Dependencies are installed on the server, not bundled + * - Source maps enabled: For debugging in production + * - No minification: Keeps code readable for debugging + * - ES module output: Modern module format for serverless environments + */ +export class ViteBuildRunner { + private defaultExternal: (string | RegExp)[] = [ + // Node.js built-ins + 'path', + 'fs', + 'crypto', + 'stream', + 'util', + 'os', + 'url', + 'http', + 'https', + 'events', + 'buffer', + 'querystring', + 'assert', + 'zlib', + 'net', + 'tls', + 'child_process', + 'worker_threads', + // Twenty SDK packages - these will be provided at runtime + /^twenty-sdk/, + /^twenty-shared/, + // Internal SDK path aliases (for development apps using SDK internals) + /^@\//, + // Generated folder - built separately as a module + // Matches: ../generated, ../../generated, ./generated, etc. + /(?:^|\/)generated(?:\/|$)/, + ]; + + /** + * Build a single serverless function entry point. + */ + async buildFunction(config: ViteBuildConfig): Promise { + const { + appPath, + entryPath, + outputDir, + outputFileName, + external, + generatedRelativePath, + } = config; + + const absoluteEntryPath = path.resolve(appPath, entryPath); + const outputFilePath = path.join(outputDir, outputFileName); + + // Verify the entry file exists + if (!(await fs.pathExists(absoluteEntryPath))) { + return { + success: false, + outputPath: outputFilePath, + error: new Error(`Entry file not found: ${absoluteEntryPath}`), + }; + } + + const viteConfig = this.createViteConfig({ + appPath, + entryPath: absoluteEntryPath, + outputDir, + outputFileName, + treeshake: true, + external: external ?? this.defaultExternal, + generatedRelativePath, + }); + + try { + await build(viteConfig); + + const sourceMapPath = `${outputFilePath}.map`; + const hasSourceMap = await fs.pathExists(sourceMapPath); + + return { + success: true, + outputPath: outputFilePath, + sourceMapPath: hasSourceMap ? sourceMapPath : undefined, + }; + } catch (error) { + return { + success: false, + outputPath: outputFilePath, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Build the generated/ folder (GraphQL client) without tree-shaking. + * The GraphQL client may use dynamic imports, so we preserve all exports. + */ + async buildGenerated(config: ViteBuildConfig): Promise { + const { appPath, entryPath, outputDir, outputFileName, external } = config; + + const absoluteEntryPath = path.resolve(appPath, entryPath); + const outputFilePath = path.join(outputDir, outputFileName); + + // Verify the entry file exists + if (!(await fs.pathExists(absoluteEntryPath))) { + return { + success: false, + outputPath: outputFilePath, + error: new Error(`Entry file not found: ${absoluteEntryPath}`), + }; + } + + // When building generated, don't externalize generated imports + const generatedExternal = + external ?? + this.defaultExternal.filter( + (ext) => + !(ext instanceof RegExp && ext.source.includes('generated')), + ); + + const viteConfig = this.createViteConfig({ + appPath, + entryPath: absoluteEntryPath, + outputDir, + outputFileName, + treeshake: false, // Preserve all exports for dynamic imports + external: generatedExternal, + }); + + try { + await build(viteConfig); + + const sourceMapPath = `${outputFilePath}.map`; + const hasSourceMap = await fs.pathExists(sourceMapPath); + + return { + success: true, + outputPath: outputFilePath, + sourceMapPath: hasSourceMap ? sourceMapPath : undefined, + }; + } catch (error) { + return { + success: false, + outputPath: outputFilePath, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Build multiple functions in parallel for improved performance. + */ + async buildFunctionsParallel( + configs: ViteBuildConfig[], + ): Promise> { + const results = new Map(); + + const buildPromises = configs.map(async (config) => { + const result = await this.buildFunction(config); + return { name: config.outputFileName, result }; + }); + + const buildResults = await Promise.all(buildPromises); + + for (const { name, result } of buildResults) { + results.set(name, result); + } + + return results; + } + + /** + * Create Vite configuration for building a single entry point. + */ + private createViteConfig(options: { + appPath: string; + entryPath: string; + outputDir: string; + outputFileName: string; + treeshake: boolean; + external: (string | RegExp)[]; + generatedRelativePath?: string; + }): InlineConfig { + const { + appPath, + entryPath, + outputDir, + outputFileName, + treeshake, + external, + generatedRelativePath, + } = options; + + return { + root: appPath, + plugins: [ + tsconfigPaths({ + root: appPath, + }), + ], + build: { + outDir: outputDir, + emptyOutDir: false, // Don't clear the output directory + lib: { + entry: entryPath, + formats: ['es'], + fileName: () => outputFileName, + }, + rollupOptions: { + external, + treeshake, + output: { + // Preserve named exports + preserveModules: false, + exports: 'named', + // Rewrite external import paths + paths: generatedRelativePath + ? (id: string) => { + // Rewrite generated imports to point to the correct location + if (/(?:^|\/)generated(?:\/|$)/.test(id)) { + return generatedRelativePath; + } + return id; + } + : undefined, + }, + }, + minify: false, + sourcemap: true, + }, + logLevel: 'warn', + // Ensure we're running in a clean environment + configFile: false, + }; + } +} diff --git a/packages/twenty-sdk/src/cli/commands/app-build.command.ts b/packages/twenty-sdk/src/cli/commands/app-build.command.ts new file mode 100644 index 0000000000..1210cb7f54 --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/app-build.command.ts @@ -0,0 +1,65 @@ +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/constants/current-execution-directory'; +import { type ApiResponse } from '@/cli/types/api-response.types'; +import { BuildService } from '@/cli/build/build.service'; +import { type BuildResult } from '@/cli/build/types'; + +export type BuildCommandOptions = { + appPath?: string; + watch?: boolean; + tarball?: boolean; +}; + +/** + * AppBuildCommand handles the `twenty app build` CLI command. + * + * This command transpiles TypeScript applications into distributable + * JavaScript bundles using Vite. + * + * Usage: + * - `npx twenty app build [appPath]` - One-time build + * - `npx twenty app build --watch [appPath]` - Watch mode with incremental rebuilds + * - `npx twenty app build --tarball [appPath]` - Build + create .tar.gz + */ +export class AppBuildCommand { + private buildService = new BuildService(); + + 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, + tarball: options.tarball, + }); + } + + private setupGracefulShutdown(stopFn: () => Promise): void { + process.on('SIGINT', async () => { + console.log('\nšŸ›‘ Stopping build watch mode...'); + await stopFn(); + process.exit(0); + }); + } +} diff --git a/packages/twenty-sdk/src/cli/commands/app.command.ts b/packages/twenty-sdk/src/cli/commands/app.command.ts index d357a10ff6..4b3181a61c 100644 --- a/packages/twenty-sdk/src/cli/commands/app.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app.command.ts @@ -11,6 +11,7 @@ import { AppSyncCommand } from '@/cli/commands/app-sync.command'; import { formatPath } from '@/cli/utils/format-path'; import { AppGenerateCommand } from '@/cli/commands/app-generate.command'; import { AppLogsCommand } from '@/cli/commands/app-logs.command'; +import { AppBuildCommand } from '@/cli/commands/app-build.command'; export class AppCommand { private devCommand = new AppDevCommand(); @@ -19,6 +20,7 @@ export class AppCommand { private addCommand = new AppAddCommand(); private generateCommand = new AppGenerateCommand(); private logsCommand = new AppLogsCommand(); + private buildCommand = new AppBuildCommand(); getCommand(): Command { const appCommand = new Command('app'); @@ -35,6 +37,25 @@ export class AppCommand { }); }); + appCommand + .command('build [appPath]') + .description('Build application for deployment') + .option('-w, --watch', 'Watch for changes and rebuild') + .option('-t, --tarball', 'Create a tarball after build') + .action(async (appPath, options) => { + try { + const result = await this.buildCommand.execute({ + ...options, + appPath: formatPath(appPath), + }); + if (!result.success) { + process.exit(1); + } + } catch { + process.exit(1); + } + }); + appCommand .command('sync [appPath]') .description('Sync application to Twenty') diff --git a/packages/twenty-sdk/src/cli/utils/config-loader.ts b/packages/twenty-sdk/src/cli/utils/config-loader.ts index 73d0c7ef2f..14ef887e7d 100644 --- a/packages/twenty-sdk/src/cli/utils/config-loader.ts +++ b/packages/twenty-sdk/src/cli/utils/config-loader.ts @@ -2,14 +2,79 @@ import { createJiti } from 'jiti'; import * as fs from 'fs-extra'; import path from 'path'; import { fileURLToPath } from 'url'; +import { parseJsoncFile } from './jsonc-parser'; -// Create a jiti instance for loading TypeScript config files -const createConfigLoader = () => { - return createJiti(fileURLToPath(import.meta.url), { +/** + * Read tsconfig.json paths and convert them to jiti aliases. + * Handles patterns like "generated/*": ["./generated/*"] + */ +const getTsconfigAliases = async ( + appPath: string, +): Promise> => { + const tsconfigPath = path.join(appPath, 'tsconfig.json'); + + if (!(await fs.pathExists(tsconfigPath))) { + return {}; + } + + try { + const tsconfig = await parseJsoncFile(tsconfigPath); + const paths = tsconfig?.compilerOptions?.paths as + | Record + | undefined; + const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.'; + + if (!paths) { + return {}; + } + + const aliases: Record = {}; + + for (const [pattern, targets] of Object.entries(paths)) { + if (targets.length === 0) continue; + + // Remove trailing /* from pattern and target + const aliasKey = pattern.replace(/\/\*$/, ''); + const targetPath = targets[0].replace(/\/\*$/, ''); + + // Resolve the target path relative to baseUrl and appPath + const resolvedTarget = path.resolve(appPath, baseUrl, targetPath); + aliases[aliasKey] = resolvedTarget; + } + + return aliases; + } catch { + return {}; + } +}; + +/** + * Create a jiti instance for loading TypeScript config files. + * When appPath is provided, jiti will use the app's tsconfig.json for path resolution. + */ +const createConfigLoader = async (appPath?: string) => { + const basePath = appPath ?? fileURLToPath(import.meta.url); + + const options: { + moduleCache?: boolean; + fsCache?: boolean; + interopDefault?: boolean; + alias?: Record; + } = { moduleCache: false, // Don't cache during dev for hot reload fsCache: false, interopDefault: true, - }); + }; + + // If appPath is provided, read tsconfig.json and set up aliases + if (appPath) { + const aliases = await getTsconfigAliases(appPath); + if (Object.keys(aliases).length > 0) { + options.alias = aliases; + } + } + + return createJiti(basePath, options); }; /** @@ -56,13 +121,19 @@ const findConfigExport = ( * - `export default { ... }` * - `export const anyName = { ... }` (any named export that is a plain object) * + * @param filepath - Absolute path to the config file + * @param appPath - Optional app path for tsconfig.json path resolution + * * @example * ```typescript - * const config = await loadConfig('/path/to/src/app/application.config.ts'); + * const config = await loadConfig('/path/to/src/app/application.config.ts', '/path/to/app'); * ``` */ -export const loadConfig = async (filepath: string): Promise => { - const jiti = createConfigLoader(); +export const loadConfig = async ( + filepath: string, + appPath?: string, +): Promise => { + const jiti = await createConfigLoader(appPath); try { const mod = (await jiti.import(filepath)) as Record; @@ -177,7 +248,7 @@ export const loadFunctionModule = async ( handlerName: string; handlerPath: string; }> => { - const jiti = createConfigLoader(); + const jiti = await createConfigLoader(appPath); try { const mod = (await jiti.import(filepath)) as Record; diff --git a/packages/twenty-sdk/src/cli/utils/load-manifest.ts b/packages/twenty-sdk/src/cli/utils/load-manifest.ts index ba986c9a2b..a64b742b21 100644 --- a/packages/twenty-sdk/src/cli/utils/load-manifest.ts +++ b/packages/twenty-sdk/src/cli/utils/load-manifest.ts @@ -70,7 +70,7 @@ const loadObjects = async (appPath: string): Promise => { for (const filepath of objectFiles) { try { - const manifest = await loadConfig(filepath); + const manifest = await loadConfig(filepath, appPath); objects.push(manifest); } catch (error) { @@ -99,7 +99,7 @@ const loadObjectExtensions = async ( for (const filepath of extensionFiles) { try { - const manifest = await loadConfig(filepath); + const manifest = await loadConfig(filepath, appPath); extensions.push(manifest); } catch (error) { @@ -163,7 +163,7 @@ const loadRoles = async (appPath: string): Promise => { for (const filepath of roleFiles) { try { - const config = await loadConfig(filepath); + const config = await loadConfig(filepath, appPath); roles.push(config); } catch (error) { const relPath = toPosixRelative(filepath, appPath); @@ -303,7 +303,7 @@ export const loadManifest = async ( 'app', 'application.config.ts', ); - const application = await loadConfig(applicationConfigPath); + const application = await loadConfig(applicationConfigPath, appPath); // Load all entities in parallel const [ diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/create-zip-file.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/create-zip-file.ts index b513c4e718..994f546a42 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/create-zip-file.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/create-zip-file.ts @@ -1,7 +1,6 @@ import fs from 'fs'; import { pipeline } from 'stream/promises'; -// @ts-expect-error legacy noImplicitAny import archiver from 'archiver'; export const createZipFile = async ( diff --git a/yarn.lock b/yarn.lock index d4e0413c0f..4a35cb628d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22818,6 +22818,15 @@ __metadata: languageName: node linkType: hard +"@types/archiver@npm:^6.0.0": + version: 6.0.4 + resolution: "@types/archiver@npm:6.0.4" + dependencies: + "@types/readdir-glob": "npm:*" + checksum: 10c0/ac971a5a72d55064b0a62ccebff643f220c957f109a68486c48b51c566df3a7fdbf5ddce8eb1bbd644c4c6486f882cf90044f5ebb08ce16dfe0b1cbb491f8c2c + languageName: node + linkType: hard + "@types/argparse@npm:1.0.38": version: 1.0.38 resolution: "@types/argparse@npm:1.0.38" @@ -24393,6 +24402,15 @@ __metadata: languageName: node linkType: hard +"@types/readdir-glob@npm:*": + version: 1.1.5 + resolution: "@types/readdir-glob@npm:1.1.5" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/46849136a3b5246105bca0303aab80552a9ff67e024e77ef1845a806a24c1a621dfcba0e4ee5a00ebad17f51edb80928f2dd6dc510a1d9897f3bc22ed64e5cbd + languageName: node + linkType: hard + "@types/resolve@npm:1.20.2": version: 1.20.2 resolution: "@types/resolve@npm:1.20.2" @@ -56891,6 +56909,7 @@ __metadata: dependencies: "@genql/cli": "npm:^3.0.3" "@sniptt/guards": "npm:^0.2.0" + "@types/archiver": "npm:^6.0.0" "@types/fs-extra": "npm:^11.0.0" "@types/inquirer": "npm:^9.0.0" "@types/jest": "npm:^29.5.0" @@ -56899,6 +56918,7 @@ __metadata: "@types/lodash.kebabcase": "npm:^4.1.7" "@types/lodash.startcase": "npm:^4" "@types/node": "npm:^24.0.0" + archiver: "npm:^7.0.1" axios: "npm:^1.6.0" chalk: "npm:^5.3.0" chokidar: "npm:^4.0.0"