hermes-agent/scripts/build-bundled-desktop.mjs

278 lines
11 KiB
JavaScript

#!/usr/bin/env node
// build-bundled-desktop.mjs — build the fully bundled desktop installer
// locally, on any of the three platforms.
//
// 1. preflight: uv, git, npm exist; a release tag is resolvable
// 2. npm ci at the repo root
// 3. build ui-tui (with hermes-ink) and the dashboard SPA
// 4. download the payload node dist (the exact host node version)
// 5. npm run build in apps/desktop with HERMES_DESKTOP_BUNDLED=1
// 6. npm run builder -- <platform targets>
//
// Every step always runs. There is no opt-out: a skipped step is a
// different artifact, and a different artifact is not a reproduction.
//
// Usage:
// node scripts/build-bundled-desktop.mjs --tag=v0.20.0
//
// Signing is CI's job (Azure/Apple secrets). Local builds are unsigned.
import { execSync, spawnSync } from "node:child_process"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { hostTarBin } from "../apps/desktop/scripts/stage-agent-payloads.mjs"
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const args = process.argv.slice(2)
const tagArg = args.find((a) => a.startsWith("--tag="))?.slice("--tag=".length)
// Everything after `--` goes to electron-builder verbatim (CI appends its
// signing configuration this way).
const dashDash = process.argv.indexOf("--")
const extraBuilderArgs = dashDash === -1 ? [] : process.argv.slice(dashDash + 1)
for (const retired of ["--no-install", "--no-package"]) {
if (args.includes(retired)) {
fail(`${retired} is retired: the build always runs every step`)
}
}
function fail(message) {
console.error(`[build-bundled] ${message}`)
process.exit(1)
}
function run(cmd, argv, opts = {}) {
console.log(`[build-bundled] $ ${cmd} ${argv.join(" ")}`)
// shell mode is for npm.cmd on Windows. It forbids arguments with
// spaces: cmd.exe re-splits them and no quoting survives npm's own
// re-spawn. Anything space-valued must travel as an environment
// variable instead (see run-electron-builder.mjs for signing).
const shell = process.platform === "win32"
if (shell) {
const bad = argv.find((a) => /\s/.test(a))
if (bad) {
fail(`argument with whitespace cannot cross the Windows shell: ${JSON.stringify(bad)} — pass it via environment instead`)
}
}
const result = spawnSync(cmd, argv, { stdio: "inherit", cwd: REPO_ROOT, shell, ...opts })
if (result.status !== 0) {
fail(`${cmd} exited ${result.status}`)
}
}
function capture(cmd) {
return execSync(cmd, { cwd: REPO_ROOT, encoding: "utf8" }).trim()
}
// ── 1. preflight ────────────────────────────────────────────────────────────
for (const tool of ["uv", "git", "npm", "tar"]) {
const probe = spawnSync(tool, ["--version"], { stdio: "ignore", shell: process.platform === "win32" })
if (probe.status !== 0) {
fail(`required tool missing: ${tool}`)
}
}
// Toolchain gates. The build's output depends on these tools, so a wrong
// version makes a silently different artifact (the first Windows build
// shipped a wrong-arch uv exactly this way). The rules come from ONE
// source — package.json "engines" — and the embedded runtimes are pinned
// to the EXACT host versions the gates approved:
// node — the payload node dist is downloaded at the host node version.
// uv — the staged uv IS the host binary, copied (stageUvAndPython).
// npm — ships inside the node dist; it cannot be chosen separately,
// so the host npm is gated by engines and the payload npm is
// whatever the pinned node dist bundles.
export function parseVersion(text) {
const match = String(text).match(/(\d+)\.(\d+)\.(\d+)/)
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
}
export function compareVersions(a, b) {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) return a[i] - b[i]
}
return 0
}
// The subset of semver ranges that package.json engines actually uses:
// space-separated comparators AND together, `||` separates alternatives.
// An unparseable comparator fails closed.
export function satisfiesRange(version, range) {
return String(range).split("||").some((alternative) => {
const comparators = alternative.trim().split(/\s+/).filter(Boolean)
if (comparators.length === 0) return false
return comparators.every((comparator) => {
const m = comparator.match(/^(>=|<=|>|<|=)?v?(\d+)\.(\d+)\.(\d+)$/)
if (!m) return false
const cmp = compareVersions(version, [Number(m[2]), Number(m[3]), Number(m[4])])
switch (m[1]) {
case ">=": return cmp >= 0
case "<=": return cmp <= 0
case ">": return cmp > 0
case "<": return cmp < 0
default: return cmp === 0
}
})
})
}
export function uvBannerProblem(banner) {
// A build triple is three dash-joined words that end in letters
// (aarch64-pc-windows-msvc). Its position varies: nix builds print it
// first in the parens, official builds put a commit hash and a date
// before it. Match it anywhere — the date (2026-07-31) cannot match
// because its last segment is digits.
return /[a-z0-9_]+-[a-z0-9]+-[a-z][a-z0-9-]*/.test(String(banner))
? null
: "its --version prints no build triple; the payload arch guard needs one (official uv 0.12+, or any nix/source build)"
}
const engines = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).engines || {}
// The approved host toolchain. Filled by the gates below; the payload
// stages embed THESE versions, so gate == embed by construction.
const HOST_TOOLCHAIN = { node: null, npm: null, uvBanner: null }
for (const tool of ["node", "npm"]) {
const text = tool === "node" ? process.version : capture("npm --version")
const version = parseVersion(text)
if (!version) {
fail(`${tool}: cannot parse a version from ${JSON.stringify(text)}`)
}
const range = engines[tool]
if (range && !satisfiesRange(version, range)) {
fail(`${tool} ${version.join(".")} does not satisfy package.json engines ${JSON.stringify(range)} — the build would make a different artifact`)
}
HOST_TOOLCHAIN[tool] = version
console.log(`[build-bundled] ${tool} ${version.join(".")} (engines: ${range || "unconstrained"})`)
}
{
const uvBanner = capture("uv --version")
const problem = uvBannerProblem(uvBanner)
if (problem) {
fail(`uv (${uvBanner}) would make a broken artifact: ${problem}`)
}
HOST_TOOLCHAIN.uvBanner = uvBanner
console.log(`[build-bundled] ${uvBanner} (staged into the payload as-is)`)
}
let tag = tagArg
if (!tag) {
try {
tag = capture("git describe --tags --exact-match")
} catch {
fail("no --tag=vX.Y.Z given and HEAD is not at an exact release tag")
}
}
if (!/^v(?:0|[1-9]\d{0,2})\.\d+\.\d+$/.test(tag)) {
fail(`'${tag}' is not a final release tag (vX.Y.Z)`)
}
// The canonical Hermes version is owned by pyproject.toml (the same rule
// the Nix derivation applies). electron-builder gets it via extraMetadata,
// so app.getVersion(), the artifact names, and the latest*.yml feed all
// carry the real release version instead of the UI manifest's stale one.
// The tag must agree with it: a v0.21.0 payload inside an app that
// announces 0.20.0 would make electron-updater blind to the mismatch.
const pyprojectVersion = fs
.readFileSync(path.join(REPO_ROOT, "pyproject.toml"), "utf8")
.match(/^version\s*=\s*"([^"]+)"/m)?.[1]
if (!pyprojectVersion) {
fail("could not read version from pyproject.toml")
}
if (tag !== `v${pyprojectVersion}`) {
fail(`tag ${tag} does not match pyproject.toml version ${pyprojectVersion}`)
}
const targets = { linux: "--linux AppImage", darwin: "--mac dmg zip", win32: "--win nsis" }[process.platform]
if (!targets) {
fail(`unsupported platform: ${process.platform}`)
}
console.log(`[build-bundled] tag=${tag} platform=${process.platform}-${process.arch}`)
// ── 2-3. deps + JS surfaces ─────────────────────────────────────────────────
// ui-tui, ui-tui/packages/*, and web are npm workspaces of the repo root:
// ONE root `npm ci` installs all of them, hoisted into the root
// node_modules. Never run npm ci inside a workspace directory — that
// builds a partial shadow tree beside the hoisted one and breaks module
// resolution for the workspace builds below.
run("npm", ["ci", "--no-audit", "--no-fund"], {
env: {
...process.env, // spawnSync env REPLACES the child environment; keep PATH etc.
"CI": "true" // skip annoying unicode install banner
}
})
run("npm", ["run", "build", "--workspace", "ui-tui"])
run("npm", ["run", "build", "--workspace", "web"])
// ── 4. payload node dist ────────────────────────────────────────────────────
// Pinned to the EXACT host node version: the JS surfaces were built and
// npm-installed by the host node, and the payload node runs them at
// runtime. A different version is a different artifact. This also means
// the host node must be an official nodejs.org release — a patched build
// whose version does not exist upstream fails here, loudly.
const distName = { linux: "linux", darwin: "darwin", win32: "win" }[process.platform]
const distArch = { x64: "x64", arm64: "arm64" }[process.arch]
const distExt = process.platform === "win32" ? "zip" : process.platform === "darwin" ? "tar.gz" : "tar.xz"
const version = `v${HOST_TOOLCHAIN.node.join(".")}`
const index = JSON.parse(
execSync(`curl -fsSL https://nodejs.org/dist/index.json`, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 })
)
if (!index.some((e) => e.version === version)) {
fail(`host node ${version} is not an official nodejs.org release — cannot embed the exact build toolchain`)
}
const work = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-node-payload-"))
const archive = `node-${version}-${distName}-${distArch}.${distExt}`
const extractDir = path.join(work, "extract")
const nodeDir = path.join(work, "node-payload")
fs.mkdirSync(extractDir, { recursive: true })
console.log(`[build-bundled] payload node: ${version}`)
run("curl", ["-fsSL", "-o", path.join(work, archive), `https://nodejs.org/dist/${version}/${archive}`])
run(hostTarBin(), ["-xf", path.join(work, archive), "-C", extractDir])
const [topDir] = fs.readdirSync(extractDir)
fs.renameSync(path.join(extractDir, topDir), nodeDir)
const nodeBinary = process.platform === "win32" ? path.join(nodeDir, "node.exe") : path.join(nodeDir, "bin", "node")
if (!fs.existsSync(nodeBinary)) {
fail(`extracted node dist has no runnable node at ${nodeBinary}`)
}
// ── 5-6. bundled desktop build + package ────────────────────────────────────
const env = {
...process.env,
HERMES_DESKTOP_BUNDLED: "1",
HERMES_PAYLOAD_TAG: tag,
HERMES_PAYLOAD_PYTHON: process.env.HERMES_PAYLOAD_PYTHON || "3.11",
HERMES_PAYLOAD_NODE_DIST: nodeDir,
}
const desktop = path.join(REPO_ROOT, "apps", "desktop")
run("npm", ["run", "build"], { cwd: desktop, env })
run(
"npm",
[
"run", "builder", "--",
...targets.split(" "),
`-c.extraMetadata.version=${pyprojectVersion}`,
...extraBuilderArgs,
],
{ cwd: desktop, env }
)
console.log(`[build-bundled] artifacts: ${path.join(desktop, "release")}`)
fs.rmSync(work, { recursive: true, force: true })