From 3958664e87524ce3e60b83237626744db03fdccc Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 19 Jan 2026 16:01:54 +0100 Subject: [PATCH] Move assets folder at root level (#17238) fix assets management --- .../twenty-sdk/src/cli/build/build-watcher.ts | 17 ++- .../twenty-sdk/src/cli/build/build.service.ts | 117 +++++++++++++----- .../src/cli/constants/assets-dir.ts | 5 + .../src/cli/constants/functions-dir.ts | 4 + .../src/cli/constants/generated-dir.ts | 4 + .../src/cli/constants/output-dir.ts | 5 + 6 files changed, 119 insertions(+), 33 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/constants/assets-dir.ts create mode 100644 packages/twenty-sdk/src/cli/constants/functions-dir.ts create mode 100644 packages/twenty-sdk/src/cli/constants/generated-dir.ts create mode 100644 packages/twenty-sdk/src/cli/constants/output-dir.ts diff --git a/packages/twenty-sdk/src/cli/build/build-watcher.ts b/packages/twenty-sdk/src/cli/build/build-watcher.ts index 54f29a35c7..784126cf49 100644 --- a/packages/twenty-sdk/src/cli/build/build-watcher.ts +++ b/packages/twenty-sdk/src/cli/build/build-watcher.ts @@ -1,6 +1,7 @@ import * as chokidar from 'chokidar'; import path from 'path'; import { type BuildWatcherState, type RebuildDecision } from './types'; +import { ASSETS_DIR } from '@/cli/constants/assets-dir'; /** * BuildWatcher monitors file changes and triggers rebuilds. @@ -126,8 +127,13 @@ export class BuildWatcher { return true; } - // Watch asset files in src/assets/ - if (relativePath.startsWith('src/assets/') || relativePath.startsWith('src\\assets\\')) { + // Watch asset files in assets/ (at the root of the application) + const assetsDirPrefix = `${ASSETS_DIR}/`; + const assetsDirPrefixWin = `${ASSETS_DIR}\\`; + if ( + relativePath.startsWith(assetsDirPrefix) || + relativePath.startsWith(assetsDirPrefixWin) + ) { return true; } @@ -152,6 +158,8 @@ export class BuildWatcher { let configChanged = false; let manifestChanged = false; + const assetsDirPrefix = `${ASSETS_DIR}/`; + for (const filepath of changedFiles) { const relativePath = path.relative(this.appPath, filepath); // Normalize path separators for cross-platform compatibility @@ -168,8 +176,8 @@ export class BuildWatcher { continue; } - // Check if it's an asset file - if (normalizedPath.startsWith('src/assets/')) { + // Check if it's an asset file (in root assets/ folder) + if (normalizedPath.startsWith(assetsDirPrefix)) { assetsChanged = true; continue; } @@ -208,7 +216,6 @@ export class BuildWatcher { // Check if it's a shared file that affects all functions if ( normalizedPath.startsWith('src/') && - !normalizedPath.startsWith('src/assets/') && !normalizedPath.startsWith('src/app/') && (normalizedPath.endsWith('.ts') || normalizedPath.endsWith('.tsx')) ) { diff --git a/packages/twenty-sdk/src/cli/build/build.service.ts b/packages/twenty-sdk/src/cli/build/build.service.ts index 59b2a9f122..6ded78e5d3 100644 --- a/packages/twenty-sdk/src/cli/build/build.service.ts +++ b/packages/twenty-sdk/src/cli/build/build.service.ts @@ -15,6 +15,10 @@ import { type BuildWatchHandle, type RebuildDecision, } from './types'; +import { ASSETS_DIR } from '@/cli/constants/assets-dir'; +import { FUNCTIONS_DIR } from '@/cli/constants/functions-dir'; +import { GENERATED_DIR } from '@/cli/constants/generated-dir'; +import { OUTPUT_DIR } from '@/cli/constants/output-dir'; /** * BuildService orchestrates the build process for Twenty applications. @@ -39,16 +43,13 @@ export class BuildService { outputDir: string; } | null = null; - private readonly OUTPUT_DIR = '.twenty/output'; - private readonly FUNCTIONS_DIR = 'functions'; - private readonly GENERATED_DIR = 'generated'; - private readonly ASSETS_DIR = 'assets'; - /** - * Patterns to identify asset files that should be copied. + * Get patterns to identify asset files that should be copied. * Assets are static files needed at runtime but not TypeScript code. */ - private readonly ASSET_PATTERNS = ['src/assets/**/*']; + private get ASSET_PATTERNS(): string[] { + return [`${ASSETS_DIR}/**/*`]; + } /** * Files/patterns to exclude from asset copying. @@ -80,7 +81,7 @@ export class BuildService { const manifestResult = await loadManifest(appPath); // Step 2: Prepare output directory - const outputDir = path.join(appPath, this.OUTPUT_DIR); + const outputDir = path.join(appPath, OUTPUT_DIR); await this.prepareOutputDirectory(outputDir); // Step 3: Build all functions @@ -233,7 +234,9 @@ export class BuildService { 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...')); + 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 }; @@ -349,7 +352,7 @@ export class BuildService { handlerPaths: string[], ): Promise { const { manifest } = manifestResult; - const functionsOutputDir = path.join(outputDir, this.FUNCTIONS_DIR); + const functionsOutputDir = path.join(outputDir, FUNCTIONS_DIR); // Normalize paths for comparison const normalizedPaths = new Set( @@ -373,7 +376,7 @@ export class BuildService { const outputFileName = path.basename(relativePath); const depth = fnOutputDir ? fnOutputDir.split('/').length + 1 : 1; const generatedRelativePath = - '../'.repeat(depth) + this.GENERATED_DIR + '/index.js'; + '../'.repeat(depth) + GENERATED_DIR + '/index.js'; return { appPath, @@ -400,9 +403,9 @@ export class BuildService { name: fn.name || fn.universalIdentifier, universalIdentifier: fn.universalIdentifier, originalHandlerPath: fn.handlerPath, - builtHandlerPath: `${this.FUNCTIONS_DIR}/${relativePath}`, + builtHandlerPath: `${FUNCTIONS_DIR}/${relativePath}`, sourceMapPath: result.sourceMapPath - ? `${this.FUNCTIONS_DIR}/${relativePath}.map` + ? `${FUNCTIONS_DIR}/${relativePath}.map` : undefined, }); } else { @@ -442,7 +445,7 @@ export class BuildService { private async prepareOutputDirectory(outputDir: string): Promise { await fs.remove(outputDir); await fs.ensureDir(outputDir); - await fs.ensureDir(path.join(outputDir, this.FUNCTIONS_DIR)); + await fs.ensureDir(path.join(outputDir, FUNCTIONS_DIR)); } /** @@ -454,7 +457,7 @@ export class BuildService { manifestResult: LoadManifestResult, ): Promise { const { manifest } = manifestResult; - const functionsOutputDir = path.join(outputDir, this.FUNCTIONS_DIR); + const functionsOutputDir = path.join(outputDir, FUNCTIONS_DIR); // Compute output paths preserving directory structure const functionOutputPaths = manifest.serverlessFunctions.map((fn) => @@ -482,7 +485,7 @@ export class BuildService { // functions/toto/lqq.function.js → ../../generated/index.js const depth = fnOutputDir ? fnOutputDir.split('/').length + 1 : 1; const generatedRelativePath = - '../'.repeat(depth) + this.GENERATED_DIR + '/index.js'; + '../'.repeat(depth) + GENERATED_DIR + '/index.js'; return { appPath, @@ -511,9 +514,9 @@ export class BuildService { name: fn.name || fn.universalIdentifier, universalIdentifier: fn.universalIdentifier, originalHandlerPath: fn.handlerPath, - builtHandlerPath: `${this.FUNCTIONS_DIR}/${relativePath}`, + builtHandlerPath: `${FUNCTIONS_DIR}/${relativePath}`, sourceMapPath: result.sourceMapPath - ? `${this.FUNCTIONS_DIR}/${relativePath}.map` + ? `${FUNCTIONS_DIR}/${relativePath}.map` : undefined, }); } else { @@ -576,7 +579,7 @@ export class BuildService { outputDir: string, ): Promise { const generatedIndexPath = path.join(appPath, 'generated', 'index.ts'); - const generatedOutputDir = path.join(outputDir, this.GENERATED_DIR); + const generatedOutputDir = path.join(outputDir, GENERATED_DIR); if (!(await fs.pathExists(generatedIndexPath))) { // No index.ts in generated folder, skip @@ -604,10 +607,10 @@ export class BuildService { /** * 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: - * - src/assets/ - * - assets/ + * - assets/ (at the root of the application) * * @returns The number of files copied */ @@ -615,9 +618,9 @@ export class BuildService { appPath: string, outputDir: string, ): Promise { - const assetsOutputDir = path.join(outputDir, this.ASSETS_DIR); + const assetsOutputDir = path.join(outputDir, ASSETS_DIR); - // Find all asset files + // Find all asset files in source const assetFiles = await glob(this.ASSET_PATTERNS, { cwd: appPath, ignore: this.ASSET_IGNORE, @@ -625,7 +628,43 @@ export class BuildService { 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; } @@ -637,12 +676,10 @@ export class BuildService { const sourcePath = path.join(appPath, assetFile); // Compute the relative path within assets/ - // Remove src/assets/ or assets/ prefix + // Remove assets dir prefix let relativePath = assetFile; - if (relativePath.startsWith('src/assets/')) { - relativePath = relativePath.slice('src/assets/'.length); - } else if (relativePath.startsWith('assets/')) { - relativePath = relativePath.slice('assets/'.length); + if (relativePath.startsWith(assetsDirPrefix)) { + relativePath = relativePath.slice(assetsDirPrefix.length); } const destPath = path.join(assetsOutputDir, relativePath); @@ -656,4 +693,28 @@ export class BuildService { 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/constants/assets-dir.ts b/packages/twenty-sdk/src/cli/constants/assets-dir.ts new file mode 100644 index 0000000000..1d7ac30a8f --- /dev/null +++ b/packages/twenty-sdk/src/cli/constants/assets-dir.ts @@ -0,0 +1,5 @@ +/** + * Directory name for static assets in Twenty applications. + * Assets are copied from this folder at the root of the app to the build output. + */ +export const ASSETS_DIR = 'assets'; diff --git a/packages/twenty-sdk/src/cli/constants/functions-dir.ts b/packages/twenty-sdk/src/cli/constants/functions-dir.ts new file mode 100644 index 0000000000..cb4878d437 --- /dev/null +++ b/packages/twenty-sdk/src/cli/constants/functions-dir.ts @@ -0,0 +1,4 @@ +/** + * 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 new file mode 100644 index 0000000000..e924dbc0f1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/constants/generated-dir.ts @@ -0,0 +1,4 @@ +/** + * 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 new file mode 100644 index 0000000000..b01620b87d --- /dev/null +++ b/packages/twenty-sdk/src/cli/constants/output-dir.ts @@ -0,0 +1,5 @@ +/** + * Output directory for built Twenty applications. + * Contains the compiled functions, generated client, and assets. + */ +export const OUTPUT_DIR = '.twenty/output';