import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { setTimeout } from 'node:timers/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') // Look only in the prepared workspace, not a parent checkout's dependency tree. // Package symlinks themselves are valid inputs (including Nix store references). export function workspaceTool(source, workspace, name) { for (const root of [path.join(source, workspace), source]) { const manifest = path.join(root, 'node_modules', name, 'package.json') if (existsSync(manifest)) { return createRequire(path.join(root, 'package.json')).resolve(name) } } throw new Error(`Missing prepared ${name} dependency in ${workspace} or ${source}/node_modules`) } function canonical(file) { if (existsSync(file)) return realpathSync(file) return path.join(canonical(path.dirname(file)), path.basename(file)) } function contains(parent, child) { const relative = path.relative(parent, child) return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) } const productMarker = '.hermes-product' const productOwner = 'hermes-frontend-product-v1\n' const developerOutputs = ['ui-tui/dist', 'hermes_cli/web_dist', 'apps/desktop/dist', 'apps/desktop/build/native-deps'] function developerOutput(source, out) { return source && developerOutputs.some(name => path.resolve(out) === path.join(path.resolve(source), name)) } // A destination name alone does not confer ownership of its existing contents. function requireOwnedOutput(out, source) { const stat = lstatSync(out, { throwIfNoEntry: false }) if (!stat) return if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Output must be a directory, not a file or symlink: ${out}`) // These exact npm destinations belong to their compiler. Other paths require // explicit ownership before an existing directory can be replaced. if (developerOutput(source, out)) return const marker = path.join(out, productMarker) if (!lstatSync(marker, { throwIfNoEntry: false })?.isFile() || readFileSync(marker, 'utf8') !== productOwner) { throw new Error(`Output already exists and is not builder-owned; choose a fresh output directory: ${out}`) } } // 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)) const dest = canonical(path.resolve(out)) if (contains(dest, src) || inputs.some(input => { const protectedPath = canonical(path.join(src, input)) return contains(dest, protectedPath) || contains(protectedPath, dest) })) throw new Error(`Output must not overlap build inputs: ${out}`) // In-tree products have explicit homes; everything else under source is an // input, even when this particular compiler does not read it. const generated = developerOutput(src, dest) || ['.build', 'apps/desktop/build/products'].some(name => { const root = path.join(src, name) return dest !== root && contains(root, dest) }) if (contains(src, dest) && !generated) throw new Error(`Output must be outside source or in a supported generated destination: ${out}`) requireOwnedOutput(out, src) return { source: src, out: dest } } // Windows refuses to move a tree while anything holds a handle inside it, and // an antivirus scanner opens every freshly written exe. Node reports that as // EPERM/EACCES/EBUSY; it clears in moments, so ride it out (~3s) rather than // throw away a build that took minutes. Other codes never clear by waiting. const heldCodes = new Set(['EPERM', 'EACCES', 'EBUSY']) const heldRetryDelays = [100, 200, 400, 800, 1600] export function retryHeld(operation) { for (const delay of [...heldRetryDelays, undefined]) { try { return operation() } catch (error) { if (delay === undefined || !heldCodes.has(error?.code)) throw error Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay) } } } // Never delete the last successful product before a compiler succeeds. The // backup lives beside out, outside withProduct's scratch tree, so a failed // rollback leaves the previous product somewhere a person can recover it. export function publishDirectory(staged, out, { source } = {}) { // The destination may have been occupied while the compiler was running. requireOwnedOutput(out, source) writeFileSync(path.join(staged, productMarker), productOwner) if (!existsSync(out)) return retryHeld(() => renameSync(staged, out)) const backupRoot = mkdtempSync(path.join(path.dirname(out), `.${path.basename(out)}-previous-`)) const backup = path.join(backupRoot, 'product') try { retryHeld(() => renameSync(out, backup)) } catch (error) { rmSync(backupRoot, { recursive: true, force: true }) throw error } try { retryHeld(() => renameSync(staged, out)) } catch (error) { try { retryHeld(() => renameSync(backup, out)) } catch (rollbackError) { error.message += `; rollback failed (${rollbackError.message}); previous product kept at ${backup}` throw error } rmSync(backupRoot, { recursive: true, force: true }) throw error } rmSync(backupRoot, { recursive: true, force: true }) } export async function withProduct(out, compile, { source } = {}) { requireOwnedOutput(out, source) mkdirSync(path.dirname(out), { recursive: true }) const scratch = mkdtempSync(path.join(path.dirname(out), `.${path.basename(out)}-build-`)) const product = path.join(scratch, 'product') mkdirSync(product) try { await compile(product, scratch) publishDirectory(product, out, { source }) } finally { await rmTree(scratch) } } // macOS's Finder/Spotlight can drop a fresh .DS_Store (or xattr) into a // directory while we're removing it, making rmSync throw ENOTEMPTY on the // first pass. That file is cosmetic garbage — retry the removal so the race // can't abort an otherwise-clean product build. export async function rmTree(dir) { const maxAttempts = 5 for (let attempt = 1; ; attempt++) { try { rmSync(dir, { recursive: true, force: true }) return } catch (error) { if (error?.code !== 'ENOTEMPTY' || attempt >= maxAttempts) throw error // Give Finder a beat to finish writing/clear the directory. await setTimeout(50) } } } export function isMain(url) { return process.argv[1] && url === pathToFileURL(path.resolve(process.argv[1])).href } export function frontendArgs(args, extra = {}) { const { values } = parseArgs({ args, options: { source: { type: 'string' }, out: { type: 'string' }, ...extra } }) if (!values.source || !values.out) throw new Error('--source and --out are required for product builds') return values }