diff --git a/packages/twenty-sdk/src/cli/build/build.service.ts b/packages/twenty-sdk/src/cli/build/build.service.ts deleted file mode 100644 index ded433022e..0000000000 --- a/packages/twenty-sdk/src/cli/build/build.service.ts +++ /dev/null @@ -1,675 +0,0 @@ -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. - * - * 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: BuildManifestResult; - builtFunctions: BuiltFunctionInfo[]; - outputDir: string; - } | null = null; - - /** - * Get patterns to identify asset files that should be copied. - * Assets are static files needed at runtime but not TypeScript code. - */ - private get ASSET_PATTERNS(): string[] { - return [`${ASSETS_DIR}/**/*`]; - } - - /** - * 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 buildManifest(appPath); - - // Step 2: Prepare output directory - const outputDir = path.join(appPath, 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), - }; - } - } - - - - /** - * 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 buildManifest(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: BuildManifestResult, - handlerPaths: string[], - ): Promise { - const { manifest } = manifestResult; - const functionsOutputDir = path.join(outputDir, 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) + 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: `${FUNCTIONS_DIR}/${relativePath}`, - sourceMapPath: result.sourceMapPath - ? `${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, FUNCTIONS_DIR)); - } - - /** - * Build all serverless functions from the manifest. - */ - private async buildFunctions( - appPath: string, - outputDir: string, - manifestResult: BuildManifestResult, - ): Promise { - const { manifest } = manifestResult; - const functionsOutputDir = path.join(outputDir, 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) + 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: `${FUNCTIONS_DIR}/${relativePath}`, - sourceMapPath: result.sourceMapPath - ? `${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, 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. - * Also removes files from the output that no longer exist in the source. - * - * Looks for assets in: - * - assets/ (at the root of the application) - * - * @returns The number of files copied - */ - private async copyAssets( - appPath: string, - outputDir: string, - ): Promise { - const assetsOutputDir = path.join(outputDir, ASSETS_DIR); - - // Find all asset files in source - const assetFiles = await glob(this.ASSET_PATTERNS, { - cwd: appPath, - ignore: this.ASSET_IGNORE, - absolute: false, - onlyFiles: true, - }); - - // Compute the set of expected relative paths in output - const assetsDirPrefix = `${ASSETS_DIR}/`; - const expectedOutputFiles = new Set( - assetFiles.map((file) => { - let relativePath = file; - if (relativePath.startsWith(assetsDirPrefix)) { - relativePath = relativePath.slice(assetsDirPrefix.length); - } - return relativePath.replace(/\\/g, '/'); - }), - ); - - // Clean up: remove files from output that no longer exist in source - if (await fs.pathExists(assetsOutputDir)) { - const existingOutputFiles = await glob('**/*', { - cwd: assetsOutputDir, - absolute: false, - onlyFiles: true, - }); - - for (const existingFile of existingOutputFiles) { - const normalizedPath = existingFile.replace(/\\/g, '/'); - if (!expectedOutputFiles.has(normalizedPath)) { - const fileToRemove = path.join(assetsOutputDir, existingFile); - await fs.remove(fileToRemove); - } - } - - // Clean up empty directories - await this.removeEmptyDirectories(assetsOutputDir); - } - - if (assetFiles.length === 0) { - // Remove the assets directory entirely if no source assets exist - if (await fs.pathExists(assetsOutputDir)) { - await fs.remove(assetsOutputDir); - } - 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 assets dir prefix - let relativePath = assetFile; - if (relativePath.startsWith(assetsDirPrefix)) { - relativePath = relativePath.slice(assetsDirPrefix.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; - } - - /** - * Recursively remove empty directories starting from the given path. - */ - private async removeEmptyDirectories(dirPath: string): Promise { - if (!(await fs.pathExists(dirPath))) { - return; - } - - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - - // First, recursively clean subdirectories - for (const entry of entries) { - if (entry.isDirectory()) { - await this.removeEmptyDirectories(path.join(dirPath, entry.name)); - } - } - - // Re-read directory after cleaning subdirectories - const remainingEntries = await fs.readdir(dirPath); - if (remainingEntries.length === 0) { - await fs.rmdir(dirPath); - } - } -} diff --git a/packages/twenty-sdk/src/cli/build/index.ts b/packages/twenty-sdk/src/cli/build/index.ts deleted file mode 100644 index a2685d3736..0000000000 --- a/packages/twenty-sdk/src/cli/build/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { BuildService } from './build.service'; -export * from './types'; -export { ViteBuildRunner } from './vite-build-runner'; - -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/build/types.ts b/packages/twenty-sdk/src/cli/build/types.ts deleted file mode 100644 index fda0e572fe..0000000000 --- a/packages/twenty-sdk/src/cli/build/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { type ApplicationManifest } from 'twenty-shared/application'; -import { type BuiltFunctionInfo } from '@/cli/utilities/manifest/utils/manifest-writer'; - -export type BuildOptions = { - appPath: string; - watch?: boolean; - tarball?: boolean; -}; - -export type BuildResult = { - outputDir: string; - manifest: ApplicationManifest; - builtFunctions: BuiltFunctionInfo[]; - tarballPath?: 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 deleted file mode 100644 index 37a93fb695..0000000000 --- a/packages/twenty-sdk/src/cli/build/vite-build-runner.ts +++ /dev/null @@ -1,245 +0,0 @@ -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/app-build.ts b/packages/twenty-sdk/src/cli/commands/app/app-build.ts index 7c0db222be..21e6e92abf 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-build.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-build.ts @@ -1,32 +1,28 @@ -import { BuildService } from '@/cli/build/build.service'; -import { type BuildResult } from '@/cli/build/types'; +import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; +import chalk from 'chalk'; export type BuildCommandOptions = { appPath?: string; - watch?: boolean; - tarball?: boolean; }; export class AppBuildCommand { - private buildService = new BuildService(); - - async execute(options: BuildCommandOptions): Promise> { + async execute(options: BuildCommandOptions): Promise> { const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; - // One-time build - return this.buildService.build({ - appPath, - tarball: options.tarball, - }); - } + console.log(chalk.blue('šŸš€ Building Twenty Application')); + console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); + console.log(''); - private setupGracefulShutdown(stopFn: () => Promise): void { - process.on('SIGINT', async () => { - console.log('\nšŸ›‘ Stopping build watch mode...'); - await stopFn(); - process.exit(0); - }); + const manifest = await runManifestBuild(appPath); + + if (!manifest) { + return { success: false, error: 'Build failed' }; + } + + console.log(chalk.green('āœ… Build completed successfully')); + + return { success: true, data: null }; } } 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 e1b3b91ffc..cca024625d 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,27 +1,28 @@ -import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; -import { OUTPUT_DIR } from '@/cli/constants/output-dir'; +import { FrontComponentsWatcher } from '@/cli/utilities/build/front-components/front-component-watcher'; +import { FunctionsWatcher } from '@/cli/utilities/build/functions/function-watcher'; +import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; -import { - cleanupOldFunctions, - createDevWatcher, - createManifestPlugin, - runManifestBuild, - type BuildWatcher, - type ManifestPluginState, -} from '@/cli/utilities/vite'; import chalk from 'chalk'; -import * as fs from 'fs-extra'; -import path from 'path'; +import { type ApplicationManifest } from 'twenty-shared/application'; export type AppDevOptions = { appPath?: string; }; +type AppDevState = { + manifest: ApplicationManifest | null; +}; + export class AppDevCommand { - private watcher: BuildWatcher | null = null; + private manifestWatcher: ManifestWatcher | null = null; + private functionsWatcher: FunctionsWatcher | null = null; + private frontComponentsWatcher: FrontComponentsWatcher | null = null; + private appPath: string = ''; - private isRestarting: boolean = false; - private manifestState: ManifestPluginState = { currentEntryPoints: [] }; + private state: AppDevState = { + manifest: null, + }; async execute(options: AppDevOptions): Promise { this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; @@ -30,108 +31,84 @@ export class AppDevCommand { console.log(chalk.gray(`šŸ“ App Path: ${this.appPath}`)); console.log(''); - await this.ensureOutputDirs(); - await this.startWatcher(); + await this.startWatchers(); this.setupGracefulShutdown(); } - private async ensureOutputDirs(): Promise { - const outputDir = path.join(this.appPath, OUTPUT_DIR); - const functionsDir = path.join(outputDir, FUNCTIONS_DIR); - await fs.ensureDir(functionsDir); - } + private async startWatchers(): Promise { + const manifest = await runManifestBuild(this.appPath); - private async startWatcher(): Promise { - const functionInput = await runManifestBuild( - this.appPath, - this.manifestState, - ); - - await cleanupOldFunctions(this.appPath, this.manifestState.currentEntryPoints); - - const hasFunctions = Object.keys(functionInput).length > 0; - - if (hasFunctions) { - console.log(chalk.blue(' šŸ“¦ Building functions...')); - } else { - console.log(chalk.gray(' No functions to build')); + if (!manifest) { + return; } - const manifestPlugin = createManifestPlugin( - this.appPath, - this.manifestState, - { - onEntryPointsChange: (newEntryPoints) => { - console.log(chalk.yellow(`šŸ”„ Entry points changed: ${JSON.stringify(newEntryPoints)}`)); - this.scheduleRestart(); + this.state.manifest = manifest; + + await this.startManifestWatcher(); + await this.startFunctionsWatcher(manifest); + await this.startFrontComponentsWatcher(manifest); + } + + private async startManifestWatcher(): Promise { + this.manifestWatcher = new ManifestWatcher({ + appPath: this.appPath, + callbacks: { + onBuildSuccess: (manifest) => { + this.state.manifest = manifest; + + if (this.functionsWatcher?.shouldRestart(manifest)) { + this.functionsWatcher.restart(manifest); + } + + if (this.frontComponentsWatcher?.shouldRestart(manifest)) { + this.frontComponentsWatcher.restart(manifest); + } }, }, - ); + }); - this.watcher = await createDevWatcher({ + await this.manifestWatcher.start(); + } + + private async startFunctionsWatcher(manifest: ApplicationManifest): Promise { + this.functionsWatcher = new FunctionsWatcher({ appPath: this.appPath, - functionInput, - plugins: [manifestPlugin], + manifest, }); - this.watcher.on('event', (event) => { - if (event.code === 'END') { - if (hasFunctions) { - console.log(chalk.green(' āœ“ Functions built')); - } - console.log(''); - console.log( - chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)'), - ); - } else if (event.code === 'ERROR') { - console.error(chalk.red(' āœ— Build error:'), event.error?.message); - } - }); + await this.functionsWatcher.start(); } - private scheduleRestart(): void { - if (this.isRestarting) { - return; - } - - setImmediate(() => { - this.restartWatcher(); + private async startFrontComponentsWatcher(manifest: ApplicationManifest): Promise { + this.frontComponentsWatcher = new FrontComponentsWatcher({ + appPath: this.appPath, + manifest, }); - } - private async restartWatcher(): Promise { - if (this.isRestarting) { - return; - } - - this.isRestarting = true; - - try { - console.log( - chalk.yellow('šŸ”„ Function entry points changed, restarting watcher...'), - ); - - if (this.watcher) { - await this.watcher.close(); - } - - await this.startWatcher(); - - console.log(chalk.green('āœ“ Watcher restarted with new entry points')); - } finally { - this.isRestarting = false; - } + await this.frontComponentsWatcher.start(); } private setupGracefulShutdown(): void { process.on('SIGINT', async () => { console.log(chalk.yellow('\nšŸ›‘ Stopping development mode...')); - if (this.watcher) { - await this.watcher.close(); + const closePromises: Promise[] = []; + + if (this.manifestWatcher) { + closePromises.push(this.manifestWatcher.close()); } + if (this.functionsWatcher) { + closePromises.push(this.functionsWatcher.close()); + } + + if (this.frontComponentsWatcher) { + closePromises.push(this.frontComponentsWatcher.close()); + } + + await Promise.all(closePromises); + process.exit(0); }); } 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 83f742d4af..90b2018f42 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-sync.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-sync.ts @@ -1,80 +1,38 @@ import { ApiService } from '@/cli/utilities/api/services/api.service'; 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 { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; import chalk from 'chalk'; export class AppSyncCommand { private apiService = new ApiService(); - private generateService = new GenerateService(); async execute( appPath: string = CURRENT_EXECUTION_DIRECTORY, ): Promise> { - try { - console.log(chalk.blue('šŸš€ Syncing Twenty Application')); - console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); - console.log(''); + console.log(chalk.blue('šŸš€ Syncing Twenty Application')); + console.log(chalk.gray(`šŸ“ App Path: ${appPath}`)); + console.log(''); - return await this.synchronize({ appPath }); - } catch (error) { + const manifest = await runManifestBuild(appPath, { writeOutput: false }); + + if (!manifest) { + return { success: false, error: 'Build failed' }; + } + + const serverlessSyncResult = await this.apiService.syncApplication({ + manifest, + }); + + if (serverlessSyncResult.success === false) { console.error( - chalk.red('Sync failed:'), - error instanceof Error ? error.message : error, + chalk.red('āŒ Application Sync failed:'), + serverlessSyncResult.error, ); - throw error; + } else { + console.log(chalk.green('āœ… Application synced successfully')); } - } - private async synchronize({ appPath }: { appPath: string }) { - try { - const { manifest, packageJson, yarnLock, shouldGenerate, warnings } = - await buildManifest(appPath); - - displayEntitySummary(manifest); - - displayWarnings(warnings); - - let serverlessSyncResult = await this.apiService.syncApplication({ - manifest, - packageJson, - yarnLock, - }); - - if (shouldGenerate) { - await this.generateService.generateClient(appPath); - - const { manifest: manifestWithClient } = await buildManifest(appPath); - - serverlessSyncResult = await this.apiService.syncApplication({ - manifest: manifestWithClient, - packageJson, - yarnLock, - }); - } - - if (serverlessSyncResult.success === false) { - console.error( - chalk.red('āŒ Application Sync failed:'), - serverlessSyncResult.error, - ); - } else { - console.log(chalk.green('āœ… Application synced successfully')); - } - - return serverlessSyncResult; - } catch (error) { - if (error instanceof ManifestValidationError) { - displayErrors(error); - } - throw error; - } + return serverlessSyncResult; } } 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 14963d75b8..9293a81892 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 { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; +import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; export class AppUninstallCommand { private apiService = new ApiService(); @@ -25,7 +25,11 @@ export class AppUninstallCommand { process.exit(1); } - const { manifest } = await buildManifest(appPath); + const manifest = await runManifestBuild(appPath, { display: false, writeOutput: false }); + + if (!manifest) { + return { success: false, error: 'Build failed' }; + } const result = await this.apiService.uninstallApplication( manifest.application.universalIdentifier, diff --git a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts index 9c396b0a8c..0e00346d74 100644 --- a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-execute.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 { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; +import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; +import chalk from 'chalk'; import { type ApplicationManifest } from 'twenty-shared/application'; export class FunctionExecuteCommand { @@ -29,7 +29,12 @@ export class FunctionExecuteCommand { process.exit(1); } - const { manifest } = await buildManifest(appPath); + const manifest = await runManifestBuild(appPath); + + if (!manifest) { + console.error(chalk.red('Failed to build manifest.')); + process.exit(1); + } const functionsResult = await this.apiService.findServerlessFunctions(); if (!functionsResult.success) { diff --git a/packages/twenty-sdk/src/cli/commands/function/function-logs.ts b/packages/twenty-sdk/src/cli/commands/function/function-logs.ts index ecd94d3522..08ee38a3cd 100644 --- a/packages/twenty-sdk/src/cli/commands/function/function-logs.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-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 { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; +import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; export class FunctionLogsCommand { private apiService = new ApiService(); @@ -16,7 +16,12 @@ export class FunctionLogsCommand { functionName?: string; }): Promise { try { - const { manifest } = await buildManifest(appPath); + const manifest = await runManifestBuild(appPath, { display: false, writeOutput: false }); + + if (!manifest) { + process.exit(1); + } + this.logWatchInfo({ appName: manifest.application.displayName, functionUniversalIdentifier, diff --git a/packages/twenty-sdk/src/cli/constants/functions-dir.ts b/packages/twenty-sdk/src/cli/constants/functions-dir.ts deleted file mode 100644 index cb4878d437..0000000000 --- a/packages/twenty-sdk/src/cli/constants/functions-dir.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Directory name for compiled serverless functions in the build output. - */ -export const FUNCTIONS_DIR = 'functions'; diff --git a/packages/twenty-sdk/src/cli/constants/generated-dir.ts b/packages/twenty-sdk/src/cli/constants/generated-dir.ts deleted file mode 100644 index e924dbc0f1..0000000000 --- a/packages/twenty-sdk/src/cli/constants/generated-dir.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Directory name for the generated GraphQL client in the build output. - */ -export const GENERATED_DIR = 'generated'; diff --git a/packages/twenty-sdk/src/cli/constants/output-dir.ts b/packages/twenty-sdk/src/cli/constants/output-dir.ts deleted file mode 100644 index b01620b87d..0000000000 --- a/packages/twenty-sdk/src/cli/constants/output-dir.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Output directory for built Twenty applications. - * Contains the compiled functions, generated client, and assets. - */ -export const OUTPUT_DIR = '.twenty/output'; diff --git a/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts b/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts index be36aac61d..bc62e3108a 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts @@ -1,20 +1,17 @@ +import { ConfigService } from '@/cli/utilities/config/services/config.service'; import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; import chalk from 'chalk'; +import * as fs from 'fs'; +import { createClient } from 'graphql-sse'; import { buildClientSchema, getIntrospectionQuery, printSchema, } from 'graphql/index'; -import { createClient } from 'graphql-sse'; -import * as fs from 'fs'; import * as path from 'path'; -import { type ApiResponse } from '../types/api-response.types'; -import { ConfigService } from '@/cli/utilities/config/services/config.service'; -import { - type PackageJson, - type ApplicationManifest, -} from 'twenty-shared/application'; +import { type ApplicationManifest } from 'twenty-shared/application'; import { type FileFolder } from 'twenty-shared/types'; +import { type ApiResponse } from '../types/api-response.types'; export class ApiService { private client: AxiosInstance; @@ -91,25 +88,19 @@ export class ApiService { } async syncApplication({ - packageJson, - yarnLock, manifest, }: { - packageJson: PackageJson; - yarnLock: string; manifest: ApplicationManifest; }): Promise { try { const mutation = ` - mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) { - syncApplication(manifest: $manifest, packageJson: $packageJson, yarnLock: $yarnLock) + mutation SyncApplication($manifest: JSON!) { + syncApplication(manifest: $manifest) } `; const variables = { manifest, - yarnLock, - packageJson, }; const response: AxiosResponse = await this.client.post( @@ -136,7 +127,7 @@ export class ApiService { return { success: true, data: response.data.data.syncApplication, - message: `Successfully synced application: ${packageJson.name}`, + message: `Successfully synced application: ${manifest.packageJson.name}`, }; } catch (error) { return { diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/constants.ts b/packages/twenty-sdk/src/cli/utilities/build/common/constants.ts new file mode 100644 index 0000000000..d9736721f0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/constants.ts @@ -0,0 +1,2 @@ +export const OUTPUT_DIR = '.twenty/output'; +export const GENERATED_DIR = 'generated'; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/display.ts b/packages/twenty-sdk/src/cli/utilities/build/common/display.ts new file mode 100644 index 0000000000..12d34e033d --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/display.ts @@ -0,0 +1,6 @@ +import chalk from 'chalk'; + +export const printWatchingMessage = (): void => { + console.log(''); + console.log(chalk.gray('šŸ‘€ Watching for changes... (Press Ctrl+C to stop)')); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/watcher.ts new file mode 100644 index 0000000000..97e9dfa44c --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/watcher.ts @@ -0,0 +1,17 @@ +import { type ApplicationManifest } from 'twenty-shared/application'; + + +export interface RestartableWatcher { + restart(manifest: ApplicationManifest): Promise; + start(): Promise; + close(): Promise; + shouldRestart( + oldManifest: ApplicationManifest | null, + newManifest: ApplicationManifest, + ): boolean; +} + +export type RestartableWatcherOptions = { + appPath: string; + manifest: ApplicationManifest | null; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts new file mode 100644 index 0000000000..90ecb017dc --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/front-components/constants.ts @@ -0,0 +1 @@ +export const FRONT_COMPONENTS_DIR = 'front-components'; diff --git a/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts new file mode 100644 index 0000000000..8f1f2ce50f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/front-components/front-component-watcher.ts @@ -0,0 +1,185 @@ +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import path from 'path'; +import type { ApplicationManifest, FrontComponentManifest } from 'twenty-shared/application'; +import { build, type InlineConfig, type Rollup } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { OUTPUT_DIR } from '../common/constants'; +import { printWatchingMessage } from '../common/display'; +import { + type RestartableWatcher, + type RestartableWatcherOptions, +} from '../common/watcher'; +import { FRONT_COMPONENTS_DIR } from './constants'; + +export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', +]; + +const computeOutputPath = (sourcePath: string): string => { + const normalizedPath = sourcePath.replace(/\\/g, '/'); + + let relativePath = normalizedPath; + if (relativePath.startsWith('src/app/')) { + relativePath = relativePath.slice('src/app/'.length); + } else if (relativePath.startsWith('src/')) { + relativePath = relativePath.slice('src/'.length); + } + + return relativePath.replace(/\.tsx?$/, '.js'); +}; + +const buildFrontComponentEntries = ( + appPath: string, + components: FrontComponentManifest[], +): Record => { + const entries: Record = {}; + + for (const component of components) { + const relativePath = computeOutputPath(component.componentPath); + const chunkName = relativePath.replace(/\.js$/, ''); + entries[chunkName] = path.join(appPath, component.componentPath); + } + + return entries; +}; + +export class FrontComponentsWatcher implements RestartableWatcher { + private appPath: string; + private entries: Record; + private innerWatcher: Rollup.RollupWatcher | null = null; + private isRestarting = false; + + constructor(options: RestartableWatcherOptions) { + this.appPath = options.appPath; + this.entries = buildFrontComponentEntries( + options.appPath, + options.manifest?.frontComponents ?? [], + ); + } + + shouldRestart(manifest: ApplicationManifest): boolean { + const newEntries = buildFrontComponentEntries(this.appPath, manifest.frontComponents ?? []); + const currentKeys = Object.keys(this.entries).sort(); + const newKeys = Object.keys(newEntries).sort(); + + if (currentKeys.length !== newKeys.length) { + return true; + } + + for (let i = 0; i < currentKeys.length; i++) { + if (currentKeys[i] !== newKeys[i]) { + return true; + } + } + + return false; + } + + async start(): Promise { + const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); + await fs.ensureDir(outputDir); + + if (this.hasEntries()) { + console.log(chalk.blue(' šŸŽØ Building front components...')); + this.innerWatcher = await this.createWatcher(); + } else { + console.log(chalk.gray(' No front components to build')); + printWatchingMessage(); + } + } + + async close(): Promise { + await this.innerWatcher?.close(); + } + + async restart(manifest: ApplicationManifest): Promise { + if (this.isRestarting) { + return; + } + + this.isRestarting = true; + + try { + console.log(chalk.yellow('šŸ”„ Restarting front components watcher...')); + await this.innerWatcher?.close(); + this.innerWatcher = null; + + this.entries = buildFrontComponentEntries(this.appPath, manifest.frontComponents ?? []); + + if (this.hasEntries()) { + console.log(chalk.blue(' šŸŽØ Building front components...')); + this.innerWatcher = await this.createWatcher(); + } else { + console.log(chalk.gray(' No front components to build')); + printWatchingMessage(); + } + + console.log(chalk.green('āœ“ Front components watcher restarted')); + } finally { + this.isRestarting = false; + } + } + + private hasEntries(): boolean { + return Object.keys(this.entries).length > 0; + } + + private async createWatcher(): Promise { + const config = this.createConfig(); + const watcher = await build(config) as Rollup.RollupWatcher; + + watcher.on('event', (event) => { + if (event.code === 'END') { + console.log(chalk.green(' āœ“ Front components built')); + printWatchingMessage(); + } else if (event.code === 'ERROR') { + console.error(chalk.red(' āœ— Front component build error:'), event.error?.message); + } + }); + + return watcher; + } + + private createConfig(): InlineConfig { + const frontComponentsOutputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR); + + return { + root: this.appPath, + plugins: [ + tsconfigPaths({ root: this.appPath }), + ], + esbuild: { + jsx: 'automatic', + }, + build: { + outDir: frontComponentsOutputDir, + emptyOutDir: false, + watch: { + include: ['src/**/*.tsx', 'src/**/*.ts', 'src/**/*.json'], + exclude: ['node_modules/**', '.twenty/**', 'dist/**'], + }, + lib: { + entry: this.entries, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.js`, + }, + rollupOptions: { + external: FRONT_COMPONENT_EXTERNAL_MODULES, + treeshake: true, + output: { + preserveModules: false, + exports: 'named', + }, + }, + minify: false, + sourcemap: true, + }, + logLevel: 'silent', + configFile: false, + }; + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/vite/__tests__/function-paths.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/vite/__tests__/function-paths.spec.ts rename to packages/twenty-sdk/src/cli/utilities/build/functions/__tests__/function-paths.spec.ts diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts new file mode 100644 index 0000000000..816055d358 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/constants.ts @@ -0,0 +1 @@ +export const FUNCTIONS_DIR = 'functions'; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts similarity index 77% rename from packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts rename to packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts index 982a0fe09f..7e79d08a71 100644 --- a/packages/twenty-sdk/src/cli/utilities/vite/function-paths.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/function-paths.ts @@ -1,10 +1,8 @@ -import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; -import { OUTPUT_DIR } from '@/cli/constants/output-dir'; +import { OUTPUT_DIR } from '../common/constants'; +import { FUNCTIONS_DIR } from './constants'; import * as fs from 'fs-extra'; import path from 'path'; -// src/app/hello.function.ts → { relativePath: 'hello.function.js', outputDir: '' } -// src/app/utils/greet.function.ts → { relativePath: 'utils/greet.function.js', outputDir: 'utils' } export const computeFunctionOutputPath = ( handlerPath: string, ): { relativePath: string; outputDir: string } => { @@ -28,19 +26,19 @@ export const computeFunctionOutputPath = ( }; }; -export const buildFunctionInput = ( +export const buildFunctionEntries = ( appPath: string, handlerPaths: Array<{ handlerPath: string }>, ): Record => { - const input: Record = {}; + const entries: Record = {}; for (const fn of handlerPaths) { const { relativePath } = computeFunctionOutputPath(fn.handlerPath); const chunkName = relativePath.replace(/\.js$/, ''); - input[chunkName] = path.join(appPath, fn.handlerPath); + entries[chunkName] = path.join(appPath, fn.handlerPath); } - return input; + return entries; }; export const cleanupOldFunctions = async ( @@ -66,7 +64,7 @@ export const cleanupOldFunctions = async ( expectedFilesWithMaps.add(`${file}.map`); } - const removeOrphans = async (dir: string, relativeBase: string = ''): Promise => { + const removeOrphanedFiles = async (dir: string, relativeBase: string = ''): Promise => { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { @@ -74,7 +72,7 @@ export const cleanupOldFunctions = async ( const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; if (entry.isDirectory()) { - await removeOrphans(fullPath, relativePath); + await removeOrphanedFiles(fullPath, relativePath); const remaining = await fs.readdir(fullPath); if (remaining.length === 0) { await fs.remove(fullPath); @@ -87,5 +85,5 @@ export const cleanupOldFunctions = async ( } }; - await removeOrphans(functionsDir); + await removeOrphanedFiles(functionsDir); }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts new file mode 100644 index 0000000000..cab82c4074 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/functions/function-watcher.ts @@ -0,0 +1,161 @@ +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import path from 'path'; +import type { ApplicationManifest } from 'twenty-shared/application'; +import { build, type InlineConfig, type Rollup } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { GENERATED_DIR, OUTPUT_DIR } from '../common/constants'; +import { printWatchingMessage } from '../common/display'; +import { + type RestartableWatcher, + type RestartableWatcherOptions, +} from '../common/watcher'; +import { FUNCTIONS_DIR } from './constants'; +import { buildFunctionEntries } from './function-paths'; + +export const FUNCTION_EXTERNAL_MODULES: (string | RegExp)[] = [ + 'path', 'fs', 'crypto', 'stream', 'util', 'os', 'url', 'http', 'https', + 'events', 'buffer', 'querystring', 'assert', 'zlib', 'net', 'tls', + 'child_process', 'worker_threads', + /^twenty-sdk/, /^twenty-shared/, /^@\//, /(?:^|\/)generated(?:\/|$)/, +]; + +export class FunctionsWatcher implements RestartableWatcher { + private appPath: string; + private entries: Record; + private innerWatcher: Rollup.RollupWatcher | null = null; + private isRestarting = false; + + constructor(options: RestartableWatcherOptions) { + this.appPath = options.appPath; + this.entries = buildFunctionEntries( + options.appPath, + options.manifest?.serverlessFunctions ?? [], + ); + } + + shouldRestart(manifest: ApplicationManifest): boolean { + const newEntries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions); + const currentKeys = Object.keys(this.entries).sort(); + const newKeys = Object.keys(newEntries).sort(); + + if (currentKeys.length !== newKeys.length) { + return true; + } + + for (let i = 0; i < currentKeys.length; i++) { + if (currentKeys[i] !== newKeys[i]) { + return true; + } + } + + return false; + } + + async start(): Promise { + const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); + await fs.ensureDir(outputDir); + + if (this.hasEntries()) { + console.log(chalk.blue(' šŸ“¦ Building functions...')); + this.innerWatcher = await this.createWatcher(); + } else { + console.log(chalk.gray(' No functions to build')); + printWatchingMessage(); + } + } + + async close(): Promise { + await this.innerWatcher?.close(); + } + + async restart(manifest: ApplicationManifest): Promise { + if (this.isRestarting) { + return; + } + + this.isRestarting = true; + + try { + console.log(chalk.yellow('šŸ”„ Restarting functions watcher...')); + await this.innerWatcher?.close(); + this.innerWatcher = null; + + this.entries = buildFunctionEntries(this.appPath, manifest.serverlessFunctions); + + if (this.hasEntries()) { + console.log(chalk.blue(' šŸ“¦ Building functions...')); + this.innerWatcher = await this.createWatcher(); + } else { + console.log(chalk.gray(' No functions to build')); + printWatchingMessage(); + } + + console.log(chalk.green('āœ“ Functions watcher restarted')); + } finally { + this.isRestarting = false; + } + } + + private hasEntries(): boolean { + return Object.keys(this.entries).length > 0; + } + + private async createWatcher(): Promise { + const config = this.createConfig(); + const watcher = await build(config) as Rollup.RollupWatcher; + + watcher.on('event', (event) => { + if (event.code === 'END') { + console.log(chalk.green(' āœ“ Functions built')); + printWatchingMessage(); + } else if (event.code === 'ERROR') { + console.error(chalk.red(' āœ— Function build error:'), event.error?.message); + } + }); + + return watcher; + } + + private createConfig(): InlineConfig { + const functionsOutputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR); + + return { + root: this.appPath, + plugins: [ + tsconfigPaths({ root: this.appPath }), + ], + build: { + outDir: functionsOutputDir, + emptyOutDir: false, + watch: { + include: ['src/**/*.ts', 'src/**/*.json'], + exclude: ['node_modules/**', '.twenty/**', 'dist/**'], + }, + lib: { + entry: this.entries, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.js`, + }, + rollupOptions: { + external: FUNCTION_EXTERNAL_MODULES, + treeshake: true, + output: { + preserveModules: false, + exports: 'named', + paths: (id: string) => { + if (/(?:^|\/)generated(?:\/|$)/.test(id)) { + return `../${GENERATED_DIR}/index.js`; + } + return id; + }, + }, + }, + minify: false, + sourcemap: true, + }, + logLevel: 'silent', + configFile: false, + }; + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/build-manifest.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/build-manifest.spec.ts similarity index 90% rename from packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/build-manifest.spec.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/build-manifest.spec.ts index 900e73792c..625490e3cf 100644 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/build-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/build-manifest.spec.ts @@ -3,35 +3,28 @@ import { 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 { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build'; +import { type ApplicationManifest } from 'twenty-shared/application'; import { join } from 'path'; const TEST_APP_PATH = join(__dirname, '../../../../__tests__/test-app'); -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']; +describe('runManifestBuild with test-app', () => { + let manifest: ApplicationManifest; beforeAll(async () => { - const result = await buildManifest(TEST_APP_PATH); + const result = await runManifestBuild(TEST_APP_PATH, { display: false, writeOutput: false }); - manifest = result.manifest; - packageJson = result.packageJson; - yarnLock = result.yarnLock; - warnings = result.warnings; - shouldGenerate = result.shouldGenerate; + if (!result) { + throw new Error('Failed to build manifest'); + } + + manifest = result; }, 15_000); it('should load manifest from test-app directory', async () => { - expect(packageJson.name).toBe('test-app'); - expect(packageJson.version).toBe('0.0.1'); - - expect(yarnLock).toBeDefined(); - - expect(warnings).toEqual([]); + expect(manifest.packageJson.name).toBe('test-app'); + expect(manifest.packageJson.version).toBe('0.0.1'); expect(manifest.application).toBeDefined(); expect(manifest.application.universalIdentifier).toBe( @@ -186,8 +179,6 @@ describe('buildManifest with test-app', () => { expect(categoryField?.label).toBe('Category'); expect((categoryField as any)?.options).toHaveLength(3); - expect(shouldGenerate).toBe(true); - const expectedRoleId = DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER; expect(manifest.application.functionRoleUniversalIdentifier).toBe( diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/validate-manifest.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/validate-manifest.spec.ts similarity index 99% rename from packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/validate-manifest.spec.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/validate-manifest.spec.ts index dd99e6b738..48f9fe2976 100644 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/__tests__/validate-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/validate-manifest.spec.ts @@ -1,4 +1,4 @@ -import { validateManifest } from '@/cli/utilities/manifest/utils/manifest-validate'; +import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate'; import { FieldMetadataType } from 'twenty-shared/types'; import { type Application, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts new file mode 100644 index 0000000000..40240514ef --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/application.ts @@ -0,0 +1,62 @@ +import chalk from 'chalk'; +import path from 'path'; +import { type Application, type ApplicationManifest } from 'twenty-shared/application'; +import { extractManifestFromFile } from '../manifest-file-extractor'; +import { type ValidationError } from '../manifest.types'; + +export const buildApplication = async (appPath: string): Promise => { + const applicationConfigPath = path.join(appPath, 'src', 'app', 'application.config.ts'); + + return extractManifestFromFile(applicationConfigPath, appPath); +}; + +export const validateApplication = ( + application: Application | undefined, + errors: ValidationError[], +): void => { + if (!application) { + errors.push({ + path: 'application', + message: 'Application config is required', + }); + return; + } + + if (!application.universalIdentifier) { + errors.push({ + path: 'application', + message: 'Application must have a universalIdentifier', + }); + } +}; + +export const displayApplication = (manifest: ApplicationManifest): void => { + const appName = manifest.application.displayName ?? 'Application'; + console.log(chalk.green(` āœ“ Loaded "${appName}"`)); +}; + +export const collectApplicationIds = ( + application: Application | undefined, +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + if (application?.universalIdentifier) { + ids.push({ + id: application.universalIdentifier, + location: 'application', + }); + } + + if (application?.applicationVariables) { + for (const [name, variable] of Object.entries(application.applicationVariables)) { + if (variable.universalIdentifier) { + ids.push({ + id: variable.universalIdentifier, + location: `application.variables.${name}`, + }); + } + } + } + + return ids; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts new file mode 100644 index 0000000000..3e8d700b92 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/front-component.ts @@ -0,0 +1,87 @@ +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import { posix, relative, sep } from 'path'; +import { type FrontComponentManifest } from 'twenty-shared/application'; +import { extractManifestFromFile } from '../manifest-file-extractor'; +import { type ValidationError } from '../manifest.types'; + +const toPosixRelative = (filepath: string, appPath: string): string => { + const rel = relative(appPath, filepath); + return rel.split(sep).join(posix.sep); +}; + +export const buildFrontComponents = async ( + appPath: string, +): Promise => { + const componentFiles = await glob(['src/app/**/*.front-component.tsx'], { + cwd: appPath, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); + + const frontComponentManifests: FrontComponentManifest[] = []; + + for (const filepath of componentFiles) { + try { + frontComponentManifests.push( + await extractManifestFromFile( + filepath, + appPath, + { entryProperty: 'component', jsx: true }, + ), + ); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load front component from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return frontComponentManifests; +}; + +export const validateFrontComponents = ( + components: FrontComponentManifest[], + errors: ValidationError[], +): void => { + for (const component of components) { + const componentPath = `front-components/${component.name ?? component.componentName ?? 'unknown'}`; + + if (!component.universalIdentifier) { + errors.push({ + path: componentPath, + message: 'Front component must have a universalIdentifier', + }); + } + } +}; + +export const displayFrontComponents = (components: FrontComponentManifest[]): void => { + console.log(chalk.green(` āœ“ Found ${components.length} front component(s)`)); + + if (components.length > 0) { + console.log(chalk.gray(` šŸ“ Front component entry points:`)); + for (const component of components) { + const name = component.name || component.universalIdentifier; + console.log(chalk.gray(` - ${name} (${component.componentPath})`)); + } + } +}; + +export const collectFrontComponentIds = ( + components: FrontComponentManifest[], +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + for (const component of components) { + if (component.universalIdentifier) { + ids.push({ + id: component.universalIdentifier, + location: `front-components/${component.name ?? component.componentName}`, + }); + } + } + + return ids; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts new file mode 100644 index 0000000000..2bf64cb3ce --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/function.ts @@ -0,0 +1,149 @@ +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import { posix, relative, sep } from 'path'; +import { type ServerlessFunctionManifest } from 'twenty-shared/application'; +import { extractManifestFromFile } from '../manifest-file-extractor'; +import { type ValidationError } from '../manifest.types'; + +const toPosixRelative = (filepath: string, appPath: string): string => { + const rel = relative(appPath, filepath); + return rel.split(sep).join(posix.sep); +}; + +export const buildFunctions = async ( + appPath: string, +): Promise => { + const functionFiles = await glob(['src/app/**/*.function.ts'], { + cwd: appPath, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); + + const functionManifests: ServerlessFunctionManifest[] = []; + + for (const filepath of functionFiles) { + try { + functionManifests.push( + await extractManifestFromFile( + filepath, + appPath, + { entryProperty: 'handler' }, + ), + ); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return functionManifests; +}; + +export const validateFunctions = ( + functions: ServerlessFunctionManifest[], + errors: ValidationError[], +): void => { + for (const fn of functions) { + const fnPath = `functions/${fn.name ?? fn.handlerName ?? 'unknown'}`; + + if (!fn.universalIdentifier) { + errors.push({ + path: fnPath, + message: 'Function must have a universalIdentifier', + }); + } + + for (const trigger of fn.triggers ?? []) { + const triggerPath = `${fnPath}.triggers.${trigger.type ?? 'unknown'}`; + + if (!trigger.universalIdentifier) { + errors.push({ + path: triggerPath, + message: 'Trigger must have a universalIdentifier', + }); + } + + if (!trigger.type) { + errors.push({ + path: triggerPath, + message: 'Trigger must have a type', + }); + continue; + } + + switch (trigger.type) { + case 'route': + if (!trigger.path) { + errors.push({ + path: triggerPath, + message: 'Route trigger must have a path', + }); + } + if (!trigger.httpMethod) { + errors.push({ + path: triggerPath, + message: 'Route trigger must have an httpMethod', + }); + } + break; + + case 'cron': + if (!trigger.pattern) { + errors.push({ + path: triggerPath, + message: 'Cron trigger must have a pattern', + }); + } + break; + + case 'databaseEvent': + if (!trigger.eventName) { + errors.push({ + path: triggerPath, + message: 'Database event trigger must have an eventName', + }); + } + break; + } + } + } +}; + +export const displayFunctions = (functions: ServerlessFunctionManifest[]): void => { + console.log(chalk.green(` āœ“ Found ${functions.length} function(s)`)); + + if (functions.length > 0) { + console.log(chalk.gray(` šŸ“ Function entry points:`)); + for (const fn of functions) { + const name = fn.name || fn.universalIdentifier; + console.log(chalk.gray(` - ${name} (${fn.handlerPath})`)); + } + } +}; + +export const collectFunctionIds = ( + functions: ServerlessFunctionManifest[], +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + for (const fn of functions) { + if (fn.universalIdentifier) { + ids.push({ + id: fn.universalIdentifier, + location: `functions/${fn.name ?? fn.handlerName}`, + }); + } + for (const trigger of fn.triggers ?? []) { + if (trigger.universalIdentifier) { + ids.push({ + id: trigger.universalIdentifier, + location: `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`, + }); + } + } + } + + return ids; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts new file mode 100644 index 0000000000..1e0dfc97ea --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object-extension.ts @@ -0,0 +1,143 @@ +import { glob } from 'fast-glob'; +import { posix, relative, sep } from 'path'; +import { type ObjectExtensionManifest } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { extractManifestFromFile } from '../manifest-file-extractor'; +import { type ValidationError } from '../manifest.types'; + +const toPosixRelative = (filepath: string, appPath: string): string => { + const rel = relative(appPath, filepath); + return rel.split(sep).join(posix.sep); +}; + +export const buildObjectExtensions = async ( + appPath: string, +): Promise => { + const extensionFiles = await glob(['src/app/**/*.object-extension.ts'], { + cwd: appPath, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); + + const objectExtensionManifests: ObjectExtensionManifest[] = []; + + for (const filepath of extensionFiles) { + try { + objectExtensionManifests.push( + await extractManifestFromFile(filepath, appPath), + ); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return objectExtensionManifests; +}; + +export const validateObjectExtensions = ( + extensions: ObjectExtensionManifest[], + errors: ValidationError[], +): void => { + for (const ext of extensions) { + const targetName = + ext.targetObject?.nameSingular ?? + ext.targetObject?.universalIdentifier ?? + 'unknown'; + const extPath = `object-extensions/${targetName}`; + + if (!ext.targetObject) { + errors.push({ + path: extPath, + message: 'Object extension must have a targetObject', + }); + continue; + } + + const { nameSingular, universalIdentifier } = ext.targetObject; + + if (!nameSingular && !universalIdentifier) { + errors.push({ + path: extPath, + message: + 'Object extension targetObject must have either nameSingular or universalIdentifier', + }); + } + + if (nameSingular && universalIdentifier) { + errors.push({ + path: extPath, + message: + 'Object extension targetObject cannot have both nameSingular and universalIdentifier', + }); + } + + if (!ext.fields || ext.fields.length === 0) { + errors.push({ + path: extPath, + message: 'Object extension must have at least one field', + }); + } + + for (const field of ext.fields ?? []) { + const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`; + + if (!field.universalIdentifier) { + errors.push({ + path: fieldPath, + message: 'Field must have a universalIdentifier', + }); + } + + if (!field.type) { + errors.push({ + path: fieldPath, + message: 'Field must have a type', + }); + } + + if (!field.label) { + errors.push({ + path: fieldPath, + message: 'Field must have a label', + }); + } + + if ( + (field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT) && + (!Array.isArray(field.options) || field.options.length === 0) + ) { + errors.push({ + path: fieldPath, + message: 'SELECT/MULTI_SELECT field must have options', + }); + } + } + } +}; + +export const collectObjectExtensionIds = ( + extensions: ObjectExtensionManifest[], +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + for (const ext of extensions) { + const targetName = + ext.targetObject?.nameSingular ?? + ext.targetObject?.universalIdentifier ?? + 'unknown'; + for (const field of ext.fields ?? []) { + if (field.universalIdentifier) { + ids.push({ + id: field.universalIdentifier, + location: `object-extensions/${targetName}.fields.${field.label}`, + }); + } + } + } + + return ids; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts new file mode 100644 index 0000000000..3e73a37323 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/object.ts @@ -0,0 +1,132 @@ +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import { posix, relative, sep } from 'path'; +import { type ObjectManifest } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { extractManifestFromFile } from '../manifest-file-extractor'; +import { type ValidationError } from '../manifest.types'; + +const toPosixRelative = (filepath: string, appPath: string): string => { + const rel = relative(appPath, filepath); + return rel.split(sep).join(posix.sep); +}; + +export const buildObjects = async (appPath: string): Promise => { + const objectFiles = await glob(['src/app/**/*.object.ts'], { + cwd: appPath, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); + + const objectManifests: ObjectManifest[] = []; + + for (const filepath of objectFiles) { + try { + objectManifests.push( + await extractManifestFromFile(filepath, appPath), + ); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return objectManifests; +}; + +export const validateObjects = ( + objects: ObjectManifest[], + errors: ValidationError[], +): void => { + for (const obj of objects) { + const objPath = `objects/${obj.nameSingular ?? 'unknown'}`; + + if (!obj.universalIdentifier) { + errors.push({ + path: objPath, + message: 'Object must have a universalIdentifier', + }); + } + + if (!obj.nameSingular) { + errors.push({ + path: objPath, + message: 'Object must have a nameSingular', + }); + } + + if (!obj.namePlural) { + errors.push({ + path: objPath, + message: 'Object must have a namePlural', + }); + } + + for (const field of obj.fields ?? []) { + const fieldPath = `${objPath}.fields.${field.label ?? 'unknown'}`; + + if (!field.universalIdentifier) { + errors.push({ + path: fieldPath, + message: 'Field must have a universalIdentifier', + }); + } + + if (!field.type) { + errors.push({ + path: fieldPath, + message: 'Field must have a type', + }); + } + + if (!field.label) { + errors.push({ + path: fieldPath, + message: 'Field must have a label', + }); + } + + if ( + (field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT) && + (!Array.isArray(field.options) || field.options.length === 0) + ) { + errors.push({ + path: fieldPath, + message: 'SELECT/MULTI_SELECT field must have options', + }); + } + } + } +}; + +export const displayObjects = (objects: ObjectManifest[]): void => { + console.log(chalk.green(` āœ“ Found ${objects.length} object(s)`)); +}; + +export const collectObjectIds = ( + objects: ObjectManifest[], +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + for (const obj of objects) { + if (obj.universalIdentifier) { + ids.push({ + id: obj.universalIdentifier, + location: `objects/${obj.nameSingular}`, + }); + } + for (const field of obj.fields ?? []) { + if (field.universalIdentifier) { + ids.push({ + id: field.universalIdentifier, + location: `objects/${obj.nameSingular}.fields.${field.label}`, + }); + } + } + } + + return ids; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts new file mode 100644 index 0000000000..62f18ed023 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/entities/role.ts @@ -0,0 +1,80 @@ +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import { posix, relative, sep } from 'path'; +import { type RoleManifest } from 'twenty-shared/application'; +import { extractManifestFromFile } from '../manifest-file-extractor'; +import { type ValidationError } from '../manifest.types'; + +const toPosixRelative = (filepath: string, appPath: string): string => { + const rel = relative(appPath, filepath); + return rel.split(sep).join(posix.sep); +}; + +export const buildRoles = async (appPath: string): Promise => { + const roleFiles = await glob(['src/app/**/*.role.ts'], { + cwd: appPath, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); + + const roleManifests: RoleManifest[] = []; + + for (const filepath of roleFiles) { + try { + roleManifests.push( + await extractManifestFromFile(filepath, appPath), + ); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return roleManifests; +}; + +export const validateRoles = ( + roles: RoleManifest[], + errors: ValidationError[], +): void => { + for (const role of roles) { + const rolePath = `roles/${role.label ?? 'unknown'}`; + + if (!role.universalIdentifier) { + errors.push({ + path: rolePath, + message: 'Role must have a universalIdentifier', + }); + } + + if (!role.label) { + errors.push({ + path: rolePath, + message: 'Role must have a label', + }); + } + } +}; + +export const displayRoles = (roles: RoleManifest[]): void => { + console.log(chalk.green(` āœ“ Found ${roles?.length ?? 'no'} role(s)`)); +}; + +export const collectRoleIds = ( + roles: RoleManifest[], +): Array<{ id: string; location: string }> => { + const ids: Array<{ id: string; location: string }> = []; + + for (const role of roles) { + if (role.universalIdentifier) { + ids.push({ + id: role.universalIdentifier, + location: `roles/${role.label}`, + }); + } + } + + return ids; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts new file mode 100644 index 0000000000..770e292a36 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -0,0 +1,177 @@ +import { findPathFile } from '@/cli/utilities/file/utils/file-find'; +import { parseJsoncFile } from '@/cli/utilities/file/utils/file-jsonc'; +import chalk from 'chalk'; +import { glob } from 'fast-glob'; +import * as fs from 'fs-extra'; +import path, { relative, sep } from 'path'; +import { type ApplicationManifest } from 'twenty-shared/application'; +import { type Sources } from 'twenty-shared/types'; +import { OUTPUT_DIR } from '../common/constants'; +import { buildApplication } from './entities/application'; +import { buildFrontComponents } from './entities/front-component'; +import { buildFunctions } from './entities/function'; +import { buildObjects } from './entities/object'; +import { buildObjectExtensions } from './entities/object-extension'; +import { buildRoles } from './entities/role'; +import { displayEntitySummary, displayErrors, displayWarnings } from './manifest-display'; +import { validateManifest } from './manifest-validate'; +import { ManifestValidationError } from './manifest.types'; + +const validateFolderStructure = async (appPath: string): Promise => { + const appFolder = path.join(appPath, 'src', 'app'); + + if (!(await fs.pathExists(appFolder))) { + throw new Error( + `Missing src/app/ folder in ${appPath}.\n` + + 'Create it with: mkdir -p src/app', + ); + } + + const configFile = path.join(appPath, 'src', 'app', 'application.config.ts'); + if (!(await fs.pathExists(configFile))) { + throw new Error('Missing src/app/application.config.ts'); + } +}; + +const loadSources = async (appPath: string): Promise => { + const sources: Sources = {}; + + const tsFiles = await glob(['src/**/*.ts', 'generated/**/*.ts'], { + cwd: appPath, + absolute: true, + ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], + }); + + for (const filepath of tsFiles) { + const relPath = relative(appPath, filepath); + const parts = relPath.split(sep); + const content = await fs.readFile(filepath, 'utf8'); + + let current: Sources = sources; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + current[part] = content; + } else { + current[part] = (current[part] ?? {}) as Sources; + current = current[part] as Sources; + } + } + } + + return sources; +}; + +const writeManifestToOutput = async ( + appPath: string, + manifest: ApplicationManifest, +): Promise => { + try { + const outputDir = path.join(appPath, OUTPUT_DIR); + await fs.ensureDir(outputDir); + + const manifestPath = path.join(outputDir, 'manifest.json'); + await fs.writeJSON(manifestPath, manifest, { spaces: 2 }); + + console.log(chalk.green(` āœ“ Manifest written to ${manifestPath}`)); + } catch (error) { + console.error( + chalk.red(' āœ— Failed to write manifest:'), + error instanceof Error ? error.message : error, + ); + } +}; + +export type RunManifestBuildOptions = { + display?: boolean; + writeOutput?: boolean; +}; + +export const runManifestBuild = async ( + appPath: string, + options: RunManifestBuildOptions = {}, +): Promise => { + const { display = true, writeOutput = true } = options; + + if (display) { + console.log(chalk.blue('šŸ”„ Building manifest...')); + } + + try { + await validateFolderStructure(appPath); + + const packageJson = await parseJsoncFile( + await findPathFile(appPath, 'package.json'), + ); + + const [ + application, + objectManifests, + objectExtensionManifests, + functionManifests, + frontComponentManifests, + roleManifests, + sources, + ] = await Promise.all([ + buildApplication(appPath), + buildObjects(appPath), + buildObjectExtensions(appPath), + buildFunctions(appPath), + buildFrontComponents(appPath), + buildRoles(appPath), + loadSources(appPath), + ]); + + const manifest: ApplicationManifest = { + application, + objects: objectManifests, + objectExtensions: + objectExtensionManifests.length > 0 ? objectExtensionManifests : undefined, + serverlessFunctions: functionManifests, + frontComponents: + frontComponentManifests.length > 0 ? frontComponentManifests : undefined, + roles: roleManifests, + sources, + packageJson, + }; + + const validation = validateManifest({ + application, + objects: objectManifests, + objectExtensions: objectExtensionManifests, + serverlessFunctions: functionManifests, + frontComponents: frontComponentManifests, + roles: roleManifests, + packageJson, + }); + + if (!validation.isValid) { + throw new ManifestValidationError(validation.errors); + } + + if (display) { + displayEntitySummary(manifest); + if (validation.warnings.length > 0) { + displayWarnings(validation.warnings); + } + } + + if (writeOutput) { + await writeManifestToOutput(appPath, manifest); + } + + return manifest; + } catch (error) { + if (display) { + if (error instanceof ManifestValidationError) { + displayErrors(error); + } else { + console.error( + chalk.red(' āœ— Build failed:'), + error instanceof Error ? error.message : error, + ); + } + } + return null; + } +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts new file mode 100644 index 0000000000..cefb0d71b3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-display.ts @@ -0,0 +1,36 @@ +import chalk from 'chalk'; +import { type ApplicationManifest } from 'twenty-shared/application'; +import { displayApplication } from './entities/application'; +import { displayFrontComponents } from './entities/front-component'; +import { displayFunctions } from './entities/function'; +import { displayObjects } from './entities/object'; +import { displayRoles } from './entities/role'; +import { type ManifestValidationError, type ValidationWarning } from './manifest.types'; + +export const displayEntitySummary = (manifest: ApplicationManifest): void => { + displayApplication(manifest); + displayObjects(manifest.objects); + displayFunctions(manifest.serverlessFunctions); + displayFrontComponents(manifest.frontComponents ?? []); + displayRoles(manifest.roles ?? []); +}; + +export const displayErrors = (error: ManifestValidationError): void => { + console.log(chalk.red('\n āœ— Manifest validation failed:\n')); + for (const err of error.errors) { + console.log(chalk.red(` • ${err.path}: ${err.message}`)); + } + console.log(''); +}; + +export const displayWarnings = (warnings: ValidationWarning[]): void => { + if (warnings.length === 0) { + return; + } + + console.log(''); + for (const warning of warnings) { + const path = warning.path ? `${warning.path}: ` : ''; + console.log(chalk.yellow(` ⚠ ${path}${warning.message}`)); + } +}; diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-file-extractor.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-file-extractor.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-file-extractor.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-file-extractor.ts diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts new file mode 100644 index 0000000000..4d89941fc7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -0,0 +1,85 @@ +import { type ApplicationManifest } from 'twenty-shared/application'; +import { collectApplicationIds, validateApplication } from './entities/application'; +import { collectFrontComponentIds, validateFrontComponents } from './entities/front-component'; +import { collectFunctionIds, validateFunctions } from './entities/function'; +import { collectObjectExtensionIds, validateObjectExtensions } from './entities/object-extension'; +import { collectObjectIds, validateObjects } from './entities/object'; +import { collectRoleIds, validateRoles } from './entities/role'; +import { + type ValidationError, + type ValidationResult, + type ValidationWarning, +} from './manifest.types'; + +const collectAllIds = ( + manifest: Omit, +): Array<{ id: string; location: string }> => { + return [ + ...collectApplicationIds(manifest.application), + ...collectObjectIds(manifest.objects ?? []), + ...collectObjectExtensionIds(manifest.objectExtensions ?? []), + ...collectFunctionIds(manifest.serverlessFunctions ?? []), + ...collectRoleIds(manifest.roles ?? []), + ...collectFrontComponentIds(manifest.frontComponents ?? []), + ]; +}; + +const findDuplicates = ( + ids: Array<{ id: string; location: string }>, +): Array<{ id: string; locations: string[] }> => { + const seen = new Map(); + + for (const { id, location } of ids) { + const locations = seen.get(id) ?? []; + locations.push(location); + seen.set(id, locations); + } + + return Array.from(seen.entries()) + .filter(([_, locations]) => locations.length > 1) + .map(([id, locations]) => ({ id, locations })); +}; + +export const validateManifest = ( + manifest: Omit, +): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + validateApplication(manifest.application, errors); + validateObjects(manifest.objects ?? [], errors); + validateObjectExtensions(manifest.objectExtensions ?? [], errors); + validateFunctions(manifest.serverlessFunctions ?? [], errors); + validateRoles(manifest.roles ?? [], errors); + validateFrontComponents(manifest.frontComponents ?? [], errors); + + const allIds = collectAllIds(manifest); + const duplicates = findDuplicates(allIds); + for (const dup of duplicates) { + errors.push({ + path: dup.locations.join(', '), + message: `Duplicate universalIdentifier: ${dup.id}`, + }); + } + + if (!manifest.objects || manifest.objects.length === 0) { + warnings.push({ + message: 'No objects defined in src/app/objects/', + }); + } + + if ( + !manifest.serverlessFunctions || + manifest.serverlessFunctions.length === 0 + ) { + warnings.push({ + message: 'No functions defined in src/app/functions/', + }); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts new file mode 100644 index 0000000000..f127259600 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts @@ -0,0 +1,116 @@ +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import path from 'path'; +import { type ApplicationManifest } from 'twenty-shared/application'; +import { build, type InlineConfig, type Plugin, type Rollup } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { OUTPUT_DIR } from '../common/constants'; +import { printWatchingMessage } from '../common/display'; +import { type RestartableWatcher } from '../common/watcher'; +import { runManifestBuild } from './manifest-build'; + +export type ManifestWatcherCallbacks = { + onBuildSuccess?: (manifest: ApplicationManifest) => void; +}; + +export type ManifestWatcherOptions = { + appPath: string; + callbacks?: ManifestWatcherCallbacks; +}; + +export class ManifestWatcher implements RestartableWatcher { + private appPath: string; + private callbacks: ManifestWatcherCallbacks; + private innerWatcher: Rollup.RollupWatcher | null = null; + + constructor(options: ManifestWatcherOptions) { + this.appPath = options.appPath; + this.callbacks = options.callbacks ?? {}; + } + restart(_manifest: ApplicationManifest): Promise { + throw new Error('Method not implemented.'); + } + + shouldRestart(_oldManifest: ApplicationManifest | null, _newManifest: ApplicationManifest): boolean { + throw new Error('Method not implemented.'); + } + + async start(): Promise { + const config = this.createConfig(); + this.innerWatcher = await build(config) as Rollup.RollupWatcher; + + this.innerWatcher.on('event', (event) => { + if (event.code === 'ERROR') { + console.error(chalk.red(' āœ— Manifest watcher error:'), event.error?.message); + } + }); + + console.log(chalk.gray(' šŸ“‚ Manifest watcher started')); + } + + async close(): Promise { + await this.innerWatcher?.close(); + const tmpDir = path.join(this.appPath, OUTPUT_DIR, 'manifest-watcher-tmp'); + await fs.remove(tmpDir); + } + + private createManifestBuildPlugin(): Plugin { + let isFirstBuild = true; + + return { + name: 'manifest-build-plugin', + writeBundle: async () => { + if (isFirstBuild) { + isFirstBuild = false; + return; + } + + const manifest = await runManifestBuild(this.appPath); + + if (manifest) { + printWatchingMessage(); + this.callbacks.onBuildSuccess?.(manifest); + } + }, + }; + } + + private createConfig(): InlineConfig { + const outputDir = path.join(this.appPath, OUTPUT_DIR, 'manifest-watcher-tmp'); + const entryPath = path.join(this.appPath, 'src/app/application.config.ts'); + + return { + root: this.appPath, + plugins: [ + tsconfigPaths({ root: this.appPath }), + this.createManifestBuildPlugin(), + ], + build: { + outDir: outputDir, + emptyOutDir: true, + watch: { + include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], + exclude: ['node_modules/**', '.twenty/**', 'dist/**'], + }, + lib: { + entry: { __manifest_watch__: entryPath }, + formats: ['es'], + fileName: () => '__manifest_watch__.js', + }, + rollupOptions: { + external: (id) => { + if (id === entryPath || id.endsWith('application.config.ts')) { + return false; + } + return true; + }, + treeshake: false, + }, + minify: false, + sourcemap: false, + }, + logLevel: 'silent', + configFile: false, + }; + } +} diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/types/manifest.types.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest.types.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/manifest/types/manifest.types.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/manifest.types.ts diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts deleted file mode 100644 index add65ff75d..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts +++ /dev/null @@ -1,316 +0,0 @@ -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, - type ApplicationManifest, - type FrontComponentManifest, - type ObjectExtensionManifest, - type ObjectManifest, - type PackageJson, - type RoleManifest, - type ServerlessFunctionManifest, -} from 'twenty-shared/application'; -import { type Sources } from 'twenty-shared/types'; -import { - ManifestValidationError, - type ValidationWarning, -} from '../types/manifest.types'; -import { extractManifestFromFile } from './manifest-file-extractor'; -import { validateManifest } from './manifest-validate'; - -const validateFolderStructure = async (appPath: string): Promise => { - const appFolder = path.join(appPath, 'src', 'app'); - - if (!(await fs.pathExists(appFolder))) { - throw new Error( - `Missing src/app/ folder in ${appPath}.\n` + - 'Create it with: mkdir -p src/app', - ); - } - - const configFile = path.join(appPath, 'src', 'app', 'application.config.ts'); - if (!(await fs.pathExists(configFile))) { - throw new Error('Missing src/app/application.config.ts'); - } -}; - -const toPosixRelative = (filepath: string, appPath: string): string => { - const rel = relative(appPath, filepath); - return rel.split(sep).join(posix.sep); -}; - -const loadFiles = async ( - patterns: string[], - cwd: string, -): Promise => { - return glob(patterns, { - cwd, - absolute: true, - ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], - }); -}; - -const loadObjectManifests = async ( - appPath: string, -): Promise => { - const objectFiles = await loadFiles(['src/app/**/*.object.ts'], appPath); - - const objectManifests: ObjectManifest[] = []; - - for (const filepath of objectFiles) { - try { - objectManifests.push( - await extractManifestFromFile(filepath, appPath), - ); - } catch (error) { - const relPath = toPosixRelative(filepath, appPath); - throw new Error( - `Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return objectManifests; -}; - -const loadObjectExtensionManifests = async ( - appPath: string, -): Promise => { - const extensionFiles = await loadFiles( - ['src/app/**/*.object-extension.ts'], - appPath, - ); - - const objectExtensionManifests: ObjectExtensionManifest[] = []; - - for (const filepath of extensionFiles) { - try { - objectExtensionManifests.push( - await extractManifestFromFile(filepath, appPath), - ); - } catch (error) { - const relPath = toPosixRelative(filepath, appPath); - throw new Error( - `Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return objectExtensionManifests; -}; - -const loadFunctionManifests = async ( - appPath: string, -): Promise => { - const functionFiles = await loadFiles(['src/app/**/*.function.ts'], appPath); - - const functionManifests: ServerlessFunctionManifest[] = []; - - for (const filepath of functionFiles) { - try { - functionManifests.push( - await extractManifestFromFile( - filepath, - appPath, - { entryProperty: 'handler' }, - ), - ); - } catch (error) { - const relPath = toPosixRelative(filepath, appPath); - throw new Error( - `Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return functionManifests; -}; - -const loadRoleManifests = async (appPath: string): Promise => { - const roleFiles = await loadFiles(['src/app/**/*.role.ts'], appPath); - - const roleManifests: RoleManifest[] = []; - - for (const filepath of roleFiles) { - try { - roleManifests.push( - await extractManifestFromFile(filepath, appPath), - ); - } catch (error) { - const relPath = toPosixRelative(filepath, appPath); - throw new Error( - `Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return roleManifests; -}; - -const loadFrontComponentManifests = async ( - appPath: string, -): Promise => { - const componentFiles = await loadFiles( - ['src/app/**/*.front-component.tsx'], - appPath, - ); - - const frontComponentManifests: FrontComponentManifest[] = []; - - for (const filepath of componentFiles) { - try { - frontComponentManifests.push( - await extractManifestFromFile( - filepath, - appPath, - { entryProperty: 'component', jsx: true }, - ), - ); - } catch (error) { - const relPath = toPosixRelative(filepath, appPath); - throw new Error( - `Failed to load front component from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return frontComponentManifests; -}; - -const loadSources = async (appPath: string): Promise => { - const sources: Sources = {}; - - const tsFiles = await loadFiles( - ['src/**/*.ts', 'generated/**/*.ts'], - appPath, - ); - - for (const filepath of tsFiles) { - const relPath = relative(appPath, filepath); - const parts = relPath.split(sep); - const content = await fs.readFile(filepath, 'utf8'); - - let current: Sources = sources; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (i === parts.length - 1) { - current[part] = content; - } else { - current[part] = (current[part] ?? {}) as Sources; - current = current[part] as Sources; - } - } - } - - return sources; -}; - -const checkShouldGenerate = async (appPath: string): Promise => { - const tsFiles = await loadFiles(['src/**/*.ts'], appPath); - - const esmImportPattern = - /from\s+['"][^'"]*\/generated(?:\/[^'"]*)?['"]|from\s+['"]generated['"]/; - - const commonJsRequirePattern = - /require\s*\(\s*['"][^'"]*\/generated(?:\/[^'"]*)?['"]\s*\)|require\s*\(\s*['"]generated['"]\s*\)/; - - for (const filepath of tsFiles) { - const content = await fs.readFile(filepath, 'utf8'); - - if (esmImportPattern.test(content) || commonJsRequirePattern.test(content)) { - return true; - } - } - - return false; -}; - -export type BuildManifestResult = { - packageJson: PackageJson; - yarnLock: string; - manifest: ApplicationManifest; - shouldGenerate: boolean; - warnings: ValidationWarning[]; -}; - -export const buildManifest = async ( - appPath: string, -): Promise => { - await validateFolderStructure(appPath); - - const packageJson = await parseJsoncFile( - await findPathFile(appPath, 'package.json'), - ); - - const yarnLock = await parseTextFile( - await findPathFile(appPath, 'yarn.lock'), - ); - - const applicationConfigPath = path.join( - appPath, - 'src', - 'app', - 'application.config.ts', - ); - const application = await extractManifestFromFile( - applicationConfigPath, - appPath, - ); - - const [ - objectManifests, - objectExtensionManifests, - functionManifests, - frontComponentManifests, - roleManifests, - sources, - shouldGenerate, - ] = await Promise.all([ - loadObjectManifests(appPath), - loadObjectExtensionManifests(appPath), - loadFunctionManifests(appPath), - loadFrontComponentManifests(appPath), - loadRoleManifests(appPath), - loadSources(appPath), - checkShouldGenerate(appPath), - ]); - - const manifest: ApplicationManifest = { - application, - objects: objectManifests, - objectExtensions: - objectExtensionManifests.length > 0 ? objectExtensionManifests : undefined, - serverlessFunctions: functionManifests, - frontComponents: - frontComponentManifests.length > 0 ? frontComponentManifests : undefined, - roles: roleManifests, - sources, - }; - - const validation = validateManifest({ - application, - objects: objectManifests, - objectExtensions: objectExtensionManifests, - serverlessFunctions: functionManifests, - frontComponents: frontComponentManifests, - roles: roleManifests, - }); - - if (!validation.isValid) { - throw new ManifestValidationError(validation.errors); - } - - return { - packageJson, - yarnLock, - manifest, - shouldGenerate, - warnings: validation.warnings, - }; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-display.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-display.ts deleted file mode 100644 index 1233ffa4b5..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-display.ts +++ /dev/null @@ -1,43 +0,0 @@ -import chalk from 'chalk'; -import { type ApplicationManifest } from 'twenty-shared/application'; -import { - type ManifestValidationError, - type ValidationWarning, -} from '../types/manifest.types'; - -export const displayEntitySummary = (manifest: ApplicationManifest): void => { - const appName = manifest.application.displayName ?? 'Application'; - console.log(chalk.green(` āœ“ Loaded "${appName}"`)); - console.log(chalk.green(` āœ“ Found ${manifest.objects.length} object(s)`)); - console.log( - chalk.green(` āœ“ Found ${manifest.serverlessFunctions.length} function(s)`), - ); - console.log( - chalk.green( - ` āœ“ Found ${manifest.frontComponents?.length ?? 0} front component(s)`, - ), - ); - console.log( - chalk.green(` āœ“ Found ${manifest.roles?.length ?? 'no'} role(s)`), - ); -}; - -export const displayErrors = (error: ManifestValidationError): void => { - console.log(chalk.red('\n āœ— Manifest validation failed:\n')); - for (const err of error.errors) { - console.log(chalk.red(` • ${err.path}: ${err.message}`)); - } - console.log(''); -}; - -export const displayWarnings = (warnings?: ValidationWarning[]): void => { - if (!warnings || warnings.length === 0) { - return; - } - - console.log(''); - for (const warning of warnings) { - const path = warning.path ? `${warning.path}: ` : ''; - console.log(chalk.yellow(` ⚠ ${path}${warning.message}`)); - } -}; diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-validate.ts deleted file mode 100644 index 7ff887fa5b..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-validate.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { - type ApplicationManifest, - type FrontComponentManifest, - type ServerlessFunctionManifest, - type ObjectExtensionManifest, - type ObjectManifest, - type RoleManifest, - type Application, -} from 'twenty-shared/application'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { - type ValidationError, - type ValidationResult, - type ValidationWarning, -} from '../types/manifest.types'; - -/** - * Collect all universalIdentifiers from the manifest for duplicate checking. - */ -const collectAllIds = ( - manifest: Omit, -): Array<{ id: string; location: string }> => { - const ids: Array<{ id: string; location: string }> = []; - - // Application - if (manifest.application?.universalIdentifier) { - ids.push({ - id: manifest.application.universalIdentifier, - location: 'application', - }); - } - - // Application variables - if (manifest.application?.applicationVariables) { - for (const [name, variable] of Object.entries( - manifest.application.applicationVariables, - )) { - if (variable.universalIdentifier) { - ids.push({ - id: variable.universalIdentifier, - location: `application.variables.${name}`, - }); - } - } - } - - // Objects - for (const obj of manifest.objects ?? []) { - if (obj.universalIdentifier) { - ids.push({ - id: obj.universalIdentifier, - location: `objects/${obj.nameSingular}`, - }); - } - // Object fields - for (const field of obj.fields ?? []) { - if (field.universalIdentifier) { - ids.push({ - id: field.universalIdentifier, - location: `objects/${obj.nameSingular}.fields.${field.label}`, - }); - } - } - } - - for (const ext of manifest.objectExtensions ?? []) { - const targetName = - ext.targetObject?.nameSingular ?? - ext.targetObject?.universalIdentifier ?? - 'unknown'; - // Extension fields - for (const field of ext.fields ?? []) { - if (field.universalIdentifier) { - ids.push({ - id: field.universalIdentifier, - location: `object-extensions/${targetName}.fields.${field.label}`, - }); - } - } - } - - // Functions - for (const fn of manifest.serverlessFunctions ?? []) { - if (fn.universalIdentifier) { - ids.push({ - id: fn.universalIdentifier, - location: `functions/${fn.name ?? fn.handlerName}`, - }); - } - // Function triggers - for (const trigger of fn.triggers ?? []) { - if (trigger.universalIdentifier) { - ids.push({ - id: trigger.universalIdentifier, - location: `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`, - }); - } - } - } - - // Roles - for (const role of manifest.roles ?? []) { - if (role.universalIdentifier) { - ids.push({ - id: role.universalIdentifier, - location: `roles/${role.label}`, - }); - } - } - - // Front Components - for (const component of manifest.frontComponents ?? []) { - if (component.universalIdentifier) { - ids.push({ - id: component.universalIdentifier, - location: `front-components/${component.name ?? component.componentName}`, - }); - } - } - - return ids; -}; - -/** - * Find duplicate universalIdentifiers. - */ -const findDuplicates = ( - ids: Array<{ id: string; location: string }>, -): Array<{ id: string; locations: string[] }> => { - const seen = new Map(); - - for (const { id, location } of ids) { - const locations = seen.get(id) ?? []; - locations.push(location); - seen.set(id, locations); - } - - return Array.from(seen.entries()) - .filter(([_, locations]) => locations.length > 1) - .map(([id, locations]) => ({ id, locations })); -}; - -/** - * Validate an application config. - */ -const validateApplication = ( - application: Application | undefined, - errors: ValidationError[], -): void => { - if (!application) { - errors.push({ - path: 'application', - message: 'Application config is required', - }); - return; - } - - if (!application.universalIdentifier) { - errors.push({ - path: 'application', - message: 'Application must have a universalIdentifier', - }); - } -}; - -/** - * Validate objects and their fields. - */ -const validateObjects = ( - objects: ObjectManifest[], - errors: ValidationError[], -): void => { - for (const obj of objects) { - const objPath = `objects/${obj.nameSingular ?? 'unknown'}`; - - if (!obj.universalIdentifier) { - errors.push({ - path: objPath, - message: 'Object must have a universalIdentifier', - }); - } - - if (!obj.nameSingular) { - errors.push({ - path: objPath, - message: 'Object must have a nameSingular', - }); - } - - if (!obj.namePlural) { - errors.push({ - path: objPath, - message: 'Object must have a namePlural', - }); - } - - // Validate fields - for (const field of obj.fields ?? []) { - const fieldPath = `${objPath}.fields.${field.label ?? 'unknown'}`; - - if (!field.universalIdentifier) { - errors.push({ - path: fieldPath, - message: 'Field must have a universalIdentifier', - }); - } - - if (!field.type) { - errors.push({ - path: fieldPath, - message: 'Field must have a type', - }); - } - - if (!field.label) { - errors.push({ - path: fieldPath, - message: 'Field must have a label', - }); - } - - // Check SELECT/MULTI_SELECT fields have options - if ( - (field.type === FieldMetadataType.SELECT || - field.type === FieldMetadataType.MULTI_SELECT) && - (!Array.isArray(field.options) || field.options.length === 0) - ) { - errors.push({ - path: fieldPath, - message: 'SELECT/MULTI_SELECT field must have options', - }); - } - } - } -}; - -/** - * Validate object extensions and their fields. - */ -const validateObjectExtensions = ( - extensions: ObjectExtensionManifest[], - errors: ValidationError[], -): void => { - for (const ext of extensions) { - const targetName = - ext.targetObject?.nameSingular ?? - ext.targetObject?.universalIdentifier ?? - 'unknown'; - const extPath = `object-extensions/${targetName}`; - - if (!ext.targetObject) { - errors.push({ - path: extPath, - message: 'Object extension must have a targetObject', - }); - continue; - } - - const { nameSingular, universalIdentifier } = ext.targetObject; - - if (!nameSingular && !universalIdentifier) { - errors.push({ - path: extPath, - message: - 'Object extension targetObject must have either nameSingular or universalIdentifier', - }); - } - - if (nameSingular && universalIdentifier) { - errors.push({ - path: extPath, - message: - 'Object extension targetObject cannot have both nameSingular and universalIdentifier', - }); - } - - if (!ext.fields || ext.fields.length === 0) { - errors.push({ - path: extPath, - message: 'Object extension must have at least one field', - }); - } - - // Validate fields - for (const field of ext.fields ?? []) { - const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`; - - if (!field.universalIdentifier) { - errors.push({ - path: fieldPath, - message: 'Field must have a universalIdentifier', - }); - } - - if (!field.type) { - errors.push({ - path: fieldPath, - message: 'Field must have a type', - }); - } - - if (!field.label) { - errors.push({ - path: fieldPath, - message: 'Field must have a label', - }); - } - - // Check SELECT/MULTI_SELECT fields have options - if ( - (field.type === FieldMetadataType.SELECT || - field.type === FieldMetadataType.MULTI_SELECT) && - (!Array.isArray(field.options) || field.options.length === 0) - ) { - errors.push({ - path: fieldPath, - message: 'SELECT/MULTI_SELECT field must have options', - }); - } - } - } -}; - -/** - * Validate serverless functions. - */ -const validateFunctions = ( - functions: ServerlessFunctionManifest[], - errors: ValidationError[], -): void => { - for (const fn of functions) { - const fnPath = `functions/${fn.name ?? fn.handlerName ?? 'unknown'}`; - - if (!fn.universalIdentifier) { - errors.push({ - path: fnPath, - message: 'Function must have a universalIdentifier', - }); - } - - // Validate triggers - for (const trigger of fn.triggers ?? []) { - const triggerPath = `${fnPath}.triggers.${trigger.type ?? 'unknown'}`; - - if (!trigger.universalIdentifier) { - errors.push({ - path: triggerPath, - message: 'Trigger must have a universalIdentifier', - }); - } - - if (!trigger.type) { - errors.push({ - path: triggerPath, - message: 'Trigger must have a type', - }); - continue; - } - - switch (trigger.type) { - case 'route': - if (!trigger.path) { - errors.push({ - path: triggerPath, - message: 'Route trigger must have a path', - }); - } - if (!trigger.httpMethod) { - errors.push({ - path: triggerPath, - message: 'Route trigger must have an httpMethod', - }); - } - break; - - case 'cron': - if (!trigger.pattern) { - errors.push({ - path: triggerPath, - message: 'Cron trigger must have a pattern', - }); - } - break; - - case 'databaseEvent': - if (!trigger.eventName) { - errors.push({ - path: triggerPath, - message: 'Database event trigger must have an eventName', - }); - } - break; - } - } - } -}; - -/** - * Validate roles. - */ -const validateRoles = ( - roles: RoleManifest[], - errors: ValidationError[], -): void => { - for (const role of roles) { - const rolePath = `roles/${role.label ?? 'unknown'}`; - - if (!role.universalIdentifier) { - errors.push({ - path: rolePath, - message: 'Role must have a universalIdentifier', - }); - } - - if (!role.label) { - errors.push({ - path: rolePath, - message: 'Role must have a label', - }); - } - } -}; - -/** - * Validate front components. - */ -const validateFrontComponents = ( - components: FrontComponentManifest[], - errors: ValidationError[], -): void => { - for (const component of components) { - const componentPath = `front-components/${component.name ?? component.componentName ?? 'unknown'}`; - - if (!component.universalIdentifier) { - errors.push({ - path: componentPath, - message: 'Front component must have a universalIdentifier', - }); - } - } -}; - -/** - * Validate a complete application manifest. - */ -export const validateManifest = ( - manifest: Omit, -): ValidationResult => { - const errors: ValidationError[] = []; - const warnings: ValidationWarning[] = []; - - // Validate application - validateApplication(manifest.application, errors); - - // Validate objects - validateObjects(manifest.objects ?? [], errors); - - // Validate object extensions - validateObjectExtensions(manifest.objectExtensions ?? [], errors); - - // Validate functions - validateFunctions(manifest.serverlessFunctions ?? [], errors); - - // Validate roles - validateRoles(manifest.roles ?? [], errors); - - // Validate front components - validateFrontComponents(manifest.frontComponents ?? [], errors); - - // Check for duplicate universalIdentifiers - const allIds = collectAllIds(manifest); - const duplicates = findDuplicates(allIds); - for (const dup of duplicates) { - errors.push({ - path: dup.locations.join(', '), - message: `Duplicate universalIdentifier: ${dup.id}`, - }); - } - - // Warnings - if (!manifest.objects || manifest.objects.length === 0) { - warnings.push({ - message: 'No objects defined in src/app/objects/', - }); - } - - if ( - !manifest.serverlessFunctions || - manifest.serverlessFunctions.length === 0 - ) { - warnings.push({ - message: 'No functions defined in src/app/functions/', - }); - } - - return { - isValid: errors.length === 0, - errors, - warnings, - }; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-writer.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-writer.ts deleted file mode 100644 index e7f5e66452..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-writer.ts +++ /dev/null @@ -1,74 +0,0 @@ -import * as fs from 'fs-extra'; -import path from 'path'; -import { type ApplicationManifest } from 'twenty-shared/application'; - -export type BuiltFunctionInfo = { - name: string; - universalIdentifier: string; - originalHandlerPath: string; - builtHandlerPath: string; - sourceMapPath?: string; -}; - -/** - * 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/utilities/vite/__tests__/entry-points.spec.ts b/packages/twenty-sdk/src/cli/utilities/vite/__tests__/entry-points.spec.ts deleted file mode 100644 index d261f8735d..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/vite/__tests__/entry-points.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - extractFunctionEntryPoints, - haveFunctionEntryPointsChanged, -} from '../entry-points'; - -describe('extractFunctionEntryPoints', () => { - it('should return empty array for no functions', () => { - const result = extractFunctionEntryPoints([]); - - expect(result).toEqual([]); - }); - - it('should extract handler paths from serverless functions', () => { - const functions = [ - { handlerPath: 'src/app/hello.function.ts' }, - { handlerPath: 'src/app/goodbye.function.ts' }, - ]; - - const result = extractFunctionEntryPoints(functions); - - expect(result).toEqual([ - 'src/app/goodbye.function.ts', - 'src/app/hello.function.ts', - ]); - }); - - it('should return sorted array', () => { - const functions = [ - { handlerPath: 'src/app/zebra.function.ts' }, - { handlerPath: 'src/app/alpha.function.ts' }, - { handlerPath: 'src/app/middle.function.ts' }, - ]; - - const result = extractFunctionEntryPoints(functions); - - expect(result).toEqual([ - 'src/app/alpha.function.ts', - 'src/app/middle.function.ts', - 'src/app/zebra.function.ts', - ]); - }); -}); - -describe('haveFunctionEntryPointsChanged', () => { - it('should return true for empty current and non-empty new', () => { - const current: string[] = []; - const newPoints = ['src/app/hello.function.ts']; - - const result = haveFunctionEntryPointsChanged(current, newPoints); - - expect(result).toBe(true); - }); - - it('should return true for non-empty current and empty new', () => { - const current = ['src/app/hello.function.ts']; - const newPoints: string[] = []; - - const result = haveFunctionEntryPointsChanged(current, newPoints); - - expect(result).toBe(true); - }); - - it('should return false for identical arrays', () => { - const current = ['src/app/alpha.function.ts', 'src/app/beta.function.ts']; - const newPoints = [ - 'src/app/alpha.function.ts', - 'src/app/beta.function.ts', - ]; - - const result = haveFunctionEntryPointsChanged(current, newPoints); - - expect(result).toBe(false); - }); - - it('should return true when an entry point is added', () => { - const current = ['src/app/alpha.function.ts']; - const newPoints = [ - 'src/app/alpha.function.ts', - 'src/app/beta.function.ts', - ]; - - const result = haveFunctionEntryPointsChanged(current, newPoints); - - expect(result).toBe(true); - }); - - it('should return true when an entry point is removed', () => { - const current = ['src/app/alpha.function.ts', 'src/app/beta.function.ts']; - const newPoints = ['src/app/alpha.function.ts']; - - const result = haveFunctionEntryPointsChanged(current, newPoints); - - expect(result).toBe(true); - }); - - it('should return true when entry points differ in content', () => { - const current = ['src/app/alpha.function.ts']; - const newPoints = ['src/app/beta.function.ts']; - - const result = haveFunctionEntryPointsChanged(current, newPoints); - - expect(result).toBe(true); - }); - - it('should return false for both empty arrays', () => { - const result = haveFunctionEntryPointsChanged([], []); - - expect(result).toBe(false); - }); -}); diff --git a/packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts b/packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts deleted file mode 100644 index d82e111864..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/vite/dev-watcher.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 path from 'path'; -import { build, type InlineConfig, type Rollup } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; - -export const EXTERNAL_MODULES: (string | RegExp)[] = [ - 'path', 'fs', 'crypto', 'stream', 'util', 'os', 'url', 'http', 'https', - 'events', 'buffer', 'querystring', 'assert', 'zlib', 'net', 'tls', - 'child_process', 'worker_threads', - /^twenty-sdk/, /^twenty-shared/, /^@\//, /(?:^|\/)generated(?:\/|$)/, -]; - -export type DevWatcherOptions = { - appPath: string; - functionInput: Record; - plugins?: InlineConfig['plugins']; -}; - -export type BuildWatcher = Rollup.RollupWatcher; - -export const createDevWatcherConfig = (options: DevWatcherOptions): InlineConfig => { - const { appPath, functionInput, plugins = [] } = options; - - const outputDir = path.join(appPath, OUTPUT_DIR); - const functionsOutputDir = path.join(outputDir, FUNCTIONS_DIR); - const hasFunctions = Object.keys(functionInput).length > 0; - - // Use application.config.ts as placeholder when no functions exist - const entry = hasFunctions - ? functionInput - : { __placeholder__: path.join(appPath, 'src/app/application.config.ts') }; - - return { - root: appPath, - plugins: [ - tsconfigPaths({ root: appPath }), - ...plugins, - ], - build: { - outDir: functionsOutputDir, - emptyOutDir: false, - watch: { - include: ['src/**/*.ts', 'src/**/*.json'], - exclude: ['node_modules/**', '.twenty/**', 'dist/**'], - }, - lib: { - entry, - formats: ['es'], - fileName: (_, entryName) => `${entryName}.js`, - }, - rollupOptions: { - external: hasFunctions ? EXTERNAL_MODULES : [/.*/], - treeshake: hasFunctions, - output: { - preserveModules: false, - exports: 'named', - paths: (id: string) => { - if (/(?:^|\/)generated(?:\/|$)/.test(id)) { - return `../${GENERATED_DIR}/index.js`; - } - return id; - }, - }, - }, - minify: false, - sourcemap: hasFunctions, - }, - logLevel: 'silent', - configFile: false, - }; -}; - -export const createDevWatcher = async ( - options: DevWatcherOptions, -): Promise => { - const config = createDevWatcherConfig(options); - const watcher = await build(config); - return watcher as BuildWatcher; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts b/packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts deleted file mode 100644 index f074f227ba..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/vite/entry-points.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const extractFunctionEntryPoints = ( - serverlessFunctions: Array<{ handlerPath: string }>, -): string[] => { - return serverlessFunctions.map((fn) => fn.handlerPath).sort(); -}; - -export const haveFunctionEntryPointsChanged = ( - currentEntryPoints: string[], - newEntryPoints: string[], -): boolean => { - if (currentEntryPoints.length !== newEntryPoints.length) { - return true; - } - - return newEntryPoints.some( - (entryPoint, index) => entryPoint !== currentEntryPoints[index], - ); -}; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/index.ts b/packages/twenty-sdk/src/cli/utilities/vite/index.ts deleted file mode 100644 index 9a59b405d3..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/vite/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export { - createDevWatcher, - createDevWatcherConfig, - EXTERNAL_MODULES, - type BuildWatcher, - type DevWatcherOptions -} from './dev-watcher'; - -export { - extractFunctionEntryPoints, - haveFunctionEntryPointsChanged -} from './entry-points'; - -export { - buildFunctionInput, - cleanupOldFunctions, - computeFunctionOutputPath -} from './function-paths'; - -export { - createManifestPlugin, - runManifestBuild, - type ManifestPluginCallbacks, - type ManifestPluginState -} from './manifest-plugin'; diff --git a/packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts b/packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts deleted file mode 100644 index 56a5ebe5a8..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/vite/manifest-plugin.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { OUTPUT_DIR } from '@/cli/constants/output-dir'; -import { ManifestValidationError } from '@/cli/utilities/manifest/types/manifest.types'; -import { - buildManifest, - type BuildManifestResult, -} from '@/cli/utilities/manifest/utils/manifest-build'; -import { - displayEntitySummary, - displayErrors, - displayWarnings, -} from '@/cli/utilities/manifest/utils/manifest-display'; -import chalk from 'chalk'; -import * as fs from 'fs-extra'; -import path from 'path'; -import { type Plugin } from 'vite'; - -import { - extractFunctionEntryPoints, - haveFunctionEntryPointsChanged, -} from './entry-points'; -import { buildFunctionInput } from './function-paths'; - -export type ManifestPluginCallbacks = { - onEntryPointsChange?: (entryPoints: string[]) => void; -}; - -export type ManifestPluginState = { - currentEntryPoints: string[]; -}; - -export const createManifestPlugin = ( - appPath: string, - state: ManifestPluginState, - callbacks: ManifestPluginCallbacks = {}, -): Plugin => { - const isRelevantFile = (file: string): boolean => { - return ['.ts', '.tsx', '.json'].some((ext) => file.endsWith(ext)); - }; - - return { - name: 'twenty-manifest', - watchChange: async (id) => { - if (isRelevantFile(id)) { - await runManifestBuild(appPath, state, callbacks); - } - }, - }; -}; - -export const runManifestBuild = async ( - appPath: string, - state: ManifestPluginState, - callbacks: ManifestPluginCallbacks = {}, -): Promise> => { - console.log(chalk.blue('šŸ”„ Building manifest...')); - - try { - const result = await buildManifest(appPath); - - displayEntitySummary(result.manifest); - displayWarnings(result.warnings); - - const functions = result.manifest.serverlessFunctions; - if (functions.length > 0) { - console.log(chalk.gray(` šŸ“ Function entry points:`)); - for (const fn of functions) { - const name = fn.name || fn.universalIdentifier; - console.log(chalk.gray(` - ${name} (${fn.handlerPath})`)); - } - } - - const frontComponents = result.manifest.frontComponents; - if (frontComponents && frontComponents.length > 0) { - console.log(chalk.gray(` šŸ“ Front component entry points:`)); - for (const component of frontComponents) { - const name = component.name || component.universalIdentifier; - console.log(chalk.gray(` - ${name} (${component.componentPath})`)); - } - } - - await writeManifestToOutput(appPath, result); - - const newEntryPoints = extractFunctionEntryPoints(functions); - const entryPointsChanged = haveFunctionEntryPointsChanged( - state.currentEntryPoints, - newEntryPoints, - ); - - const isInitialBuild = state.currentEntryPoints.length === 0; - state.currentEntryPoints = newEntryPoints; - - if (entryPointsChanged && !isInitialBuild && callbacks.onEntryPointsChange) { - callbacks.onEntryPointsChange(newEntryPoints); - } - - return buildFunctionInput(appPath, functions); - } catch (error) { - if (error instanceof ManifestValidationError) { - displayErrors(error); - } else { - console.error( - chalk.red(' āœ— Build failed:'), - error instanceof Error ? error.message : error, - ); - } - return {}; - } -}; - -const writeManifestToOutput = async ( - appPath: string, - result: BuildManifestResult, -): Promise => { - try { - const outputDir = path.join(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}`)); - } catch (error) { - console.error( - chalk.red(' āœ— Failed to write manifest:'), - error instanceof Error ? error.message : error, - ); - } -}; diff --git a/packages/twenty-shared/src/application/applicationManifestType.ts b/packages/twenty-shared/src/application/applicationManifestType.ts index f3214d9baa..6779416eed 100644 --- a/packages/twenty-shared/src/application/applicationManifestType.ts +++ b/packages/twenty-shared/src/application/applicationManifestType.ts @@ -1,7 +1,8 @@ import { + PackageJson, + type Application, type ObjectManifest, type ServerlessFunctionManifest, - type Application, } from '@/application'; import { type FrontComponentManifest } from '@/application/frontComponentManifestType'; import { type ObjectExtensionManifest } from '@/application/objectExtensionManifestType'; @@ -16,4 +17,5 @@ export type ApplicationManifest = { frontComponents?: FrontComponentManifest[]; roles?: RoleManifest[]; sources: Sources; + packageJson: PackageJson; };