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.
114 lines
5.0 KiB
JavaScript
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
|
|
}
|