diff --git a/scripts/build/README.md b/scripts/build/README.md index 074e83d754..7f5c2fde17 100644 --- a/scripts/build/README.md +++ b/scripts/build/README.md @@ -120,6 +120,10 @@ marker. Files, symlinks, and source directories are rejected. The exact npm destinations (`ui-tui/dist`, `hermes_cli/web_dist`, `apps/desktop/dist`, and `apps/desktop/build/native-deps`) remain rebuildable without a prior marker. Other in-tree products live beneath `.build/` or `apps/desktop/build/products/`. +`frontend-common.mjs` classifies these destinations independently of which source +children already exist, so a warm desktop rebuild uses the same rule as a fresh +build. Explicit stamp, icon, native-tree and dependency inputs remain protected, +even when they live beneath a generated destination. ### Icons and native inputs diff --git a/scripts/build/desktop.mjs b/scripts/build/desktop.mjs index e140bf5158..ddd650589e 100644 --- a/scripts/build/desktop.mjs +++ b/scripts/build/desktop.mjs @@ -29,9 +29,10 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type if (!icons || !stamp || !nativeDeps) throw new Error('icons, stamp and nativeDeps are required prepared inputs') const app = 'apps/desktop' ;({ source, out } = productOutput(source, out, [ - ...readdirSync(join(resolve(source), app)).filter(name => name !== 'dist').map(name => `${app}/${name}`), - `${app}/scripts`, 'scripts/build', 'package.json', 'package-lock.json', - 'apps/shared', 'node_modules', ...[join(resolve(icons), app, 'public'), stamp, nativeDeps].map(input => relative(resolve(source), resolve(input))), + // productOutput owns source/generated classification. Protect prepared + // inputs explicitly, including dependency symlinks outside the checkout. + `${app}/node_modules`, 'node_modules', + ...[join(resolve(icons), app, 'public'), stamp, nativeDeps].map(input => relative(resolve(source), resolve(input))), ])) const publicIcons = join(resolve(icons), app, 'public') if (!existsSync(join(publicIcons, 'apple-touch-icon.png'))) throw new Error(`Missing desktop icon: ${join(publicIcons, 'apple-touch-icon.png')}`) diff --git a/scripts/build/frontend-common.mjs b/scripts/build/frontend-common.mjs index 2b2088f9ce..7a2284ef87 100644 --- a/scripts/build/frontend-common.mjs +++ b/scripts/build/frontend-common.mjs @@ -50,6 +50,9 @@ function requireOwnedOutput(out, source) { } } +// Classify in-tree destinations here, not by enumerating existing workspace +// children: generated parents also exist after the first build. inputs names +// explicit prepared trees/files; their protection wins even in generated homes. export function productOutput(source, out, inputs) { if (!source || !out) throw new Error('source and output paths are required') const src = realpathSync(path.resolve(source)) diff --git a/tests-js/desktop-builder.test.mjs b/tests-js/desktop-builder.test.mjs index ba93f804be..6eac116656 100644 --- a/tests-js/desktop-builder.test.mjs +++ b/tests-js/desktop-builder.test.mjs @@ -97,6 +97,31 @@ test('desktop compiler consumes explicit immutable inputs, replaces variants, an expect(files(input.source).some(([name]) => name.includes('.vite') || name.endsWith('tsbuildinfo'))).toBe(false) }, 60000) +test('in-tree desktop products rebuild after build exists without replacing prepared inputs', async () => { + const { buildDesktop } = await import('../scripts/build/desktop.mjs') + const { productCurrent } = await import('../scripts/build/freshness.mjs') + const input = fixture() + input.out = join(input.source, 'apps/desktop/build/products/desktop') + await buildDesktop(input) + put(join(input.source, 'apps/desktop/src/index.js'), 'document.getElementById("app").textContent = "warm rebuild"') + await buildDesktop(input) + expect(productCurrent({ ...input, product: 'desktop' })).toBe(true) + const assets = files(join(input.out, 'assets')).map(([, bytes]) => Buffer.from(bytes, 'base64').toString()).join('') + expect(assets).toContain('warm rebuild') + + // The generated-path allowance never overrides an explicitly prepared input, + // even when that input itself is an earlier builder-owned product. + const built = files(input.out) + for (const prepared of [ + { stamp: join(input.out, 'hermes-build.json') }, + { nativeDeps: join(input.out, 'node_modules') }, + { icons: input.out }, + ]) { + await expect(buildDesktop({ ...input, ...prepared })).rejects.toThrow(/overlap/) + expect(files(input.out)).toEqual(built) + } +}, 30000) + test('a prepared input changing during desktop compilation cannot publish a current receipt', async () => { const { buildDesktop } = await import('../scripts/build/desktop.mjs') const input = fixture() @@ -137,7 +162,7 @@ test('typecheck uses scratch state and incomplete prepared inputs fail before pu rmSync(join(input.icons, 'apps/desktop/public/apple-touch-icon.png')) await expect(buildDesktop(input)).rejects.toThrow(/icon/i) expect(files(input.out)).toEqual(built) - await expect(buildDesktop({ ...input, out: join(input.source, 'apps/desktop/scripts') })).rejects.toThrow(/overlap/) + await expect(buildDesktop({ ...input, out: join(input.source, 'apps/desktop/scripts') })).rejects.toThrow(/Output/) put(join(input.icons, 'apps/desktop/public/apple-touch-icon.png'), 'fresh icon') rmSync(join(input.nativeDeps, 'node-pty/build'), { recursive: true, force: true }) rmSync(join(input.nativeDeps, 'node-pty/prebuilds'), { recursive: true, force: true })