Files
hermes-agent/scripts/build/frontend-common.mjs
ethernet 1bf588234c refactor(build): share product recipes across distributions
Build TUI, web, desktop UI and runnable agent products from explicit
prepared inputs. Keep dependency preparation separate from distribution
packaging, with PM and native builds sharing uv environment construction.

Docker copies compiled frontend products instead of build dependencies.
Nix retains uv2nix environments and consumes shared assembly through store
references. Native desktop and Termux use the same launcher and frontend
contracts. Preserve the independent PM runtime and source imports from
arbitrary working directories.

Keep failed frontend builds from replacing the previous product, reject
source/output overlap, and bound dependency-process output draining.
Include hermes_wisdom in the Nix wheel: real CLI smoke tests exposed its
missing package declaration on the base revision too.

Verified focused Python and JavaScript suites, Docker build/runtime checks,
Nix desktop and CLI/ACP checks, standalone TUI and packaged Electron PTY,
and real full-Chromium interaction. Native signed installers, Android device
installation and the full repository suite remain CI verification.
2026-09-11 13:16:55 -04:00

114 lines
5.0 KiB
JavaScript

import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
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}`)
}
}
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 }
}
// Never delete the last successful product before a compiler succeeds. The
// staging and backup directories are siblings so publication stays on one FS.
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)
const backup = `${staged}.previous`
const previous = existsSync(out)
if (previous) renameSync(out, backup)
try {
renameSync(staged, out)
} catch (error) {
if (previous) renameSync(backup, out)
throw error
}
if (previous) rmSync(backup, { 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 {
rmSync(scratch, { recursive: true, force: true })
}
}
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
}