Files
hermes-agent/apps/desktop/scripts/write-build-stamp.mjs
alt-glitch 3383a1d8b3 fix(bundle): the agent's terminal runs a working hermes inside the desktop payload
Inside a packaged desktop build, bare `hermes` in the terminal tool resolved
to <payload>/venv/Scripts/hermes.exe. That venv was built on CI, and its
Windows redirector reads pyvenv.cfg `home`, which still names the CI path,
so every call failed with "did not find executable at C:\a\hermes-agent\...".
The working launcher in <payload>/bin carries the channel name and was never
on the agent's PATH.

- activate_dependencies puts the payload's own launcher dir (from the
  manifest's runtime.commands) first on PATH, and on Windows no longer adds
  the shipped venv's Scripts dir: every redirector in it names the CI
  interpreter.
- _resolve_hermes_bin_dir answers with that launcher dir in a sealed payload,
  so terminal and cron children do not depend on PATH order.
- stageDesktopLaunchers copies the canonical launchers to their qualified
  names instead of renaming them. Only the qualified names are published.
- The "plugin already exists" hint names the real CLI command.
- Every manifest-relative path (venv, store, hermes launcher) goes through one
  resolve-and-contain check, and a manifest.json that is unreadable or not a
  payload manifest reads as "not a payload" instead of raising, since every
  install now asks.

Source and dev installs are unchanged: they have no payload manifest.
2026-10-07 01:29:02 +05:30

280 lines
12 KiB
JavaScript

/**
* Write the desktop artifact stamp for source, bundled and Light builds.
* bundle-electron-main.mjs bakes it into the running code. The packaged
* sidecar exists only to detect replacement of that artifact by an update.
* PM's bundle builder supplies relative launch paths for bundled artifacts.
* Provenance comes from CI, local git, or an explicit unknown-source stamp.
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
import { resolve, join, relative, posix } from "path"
import productIdentity from "../product-identity.cjs"
import { channelBuildRequest } from "../../../scripts/msix-shared.mjs"
import { validateBundleEnvironment } from "./bundle-env.mjs"
import { execFileSync } from "child_process"
import { isMain } from "./utils.mjs"
const STAMP_SCHEMA_VERSION = 1
/** All-zero placeholder used when no real commit can be resolved. */
export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000"
export const FALLBACK_BRANCH = "main"
const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = join(DESKTOP_ROOT, "build")
const OUT_FILE = join(OUT_DIR, "install-stamp.json")
function tryExec(argv, opts) {
try {
return execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, ...opts }).trim()
} catch {
return null
}
}
export function fromCI(env = process.env) {
const sha = env.GITHUB_SHA
if (!sha) return null
const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null
return {
commit: sha,
branch: branch,
dirty: false, // CI builds from a checkout-of-ref by definition
source: "ci"
}
}
export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) {
const sha = execFn(["git", "rev-parse", "HEAD"], { cwd: repoRoot })
if (!sha) return null
const branch = execFn(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot })
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
// We exclude untracked files (-uno) intentionally: a developer who's
// checked out an installer scratch dir alongside the repo shouldn't
// poison every local build with a [DIRTY] stamp. We DO care about
// tracked-but-modified files because those mean the .exe content
// differs from the commit being pinned.
const status = execFn(["git", "status", "--porcelain", "-uno"], { cwd: repoRoot })
const dirty = status !== null && status.length > 0
return {
commit: sha,
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
dirty: dirty,
source: "local"
}
}
export function fromFallback(branch = FALLBACK_BRANCH) {
// Non-git builds (ZIP download, bootstrap installer without a resolvable
// HEAD) cannot determine a real commit. Use a placeholder so local /
// personal builds can still complete. The desktop bootstrap treats the
// all-zero commit as "unknown" and falls back to an unpinned branch
// bootstrap instead of trying to fetch a non-existent GitHub commit.
return {
commit: FALLBACK_COMMIT,
branch: branch || FALLBACK_BRANCH,
dirty: false,
source: "fallback"
}
}
/**
* Resolve the install stamp without writing it. Pure enough for unit tests:
* inject env / execFn / repoRoot to simulate CI, local git, or no-git trees.
*/
export function resolveStamp({
env = process.env,
repoRoot = REPO_ROOT,
execFn = tryExec,
fallbackBranch = FALLBACK_BRANCH
} = {}) {
const channelBuild = channelBuildRequest(env)
if (channelBuild) {
const local = fromLocalGit(repoRoot, execFn)
if (!local || local.commit !== channelBuild.commit || local.dirty) throw new Error('Channel build identity does not match clean checkout HEAD')
return { ...local, branch: null, source: 'channel-build', baseVersion: channelBuild.sourceVersion, channelBuild }
}
if (env.HERMES_BUILD_COMMIT) {
if (!/^[a-f0-9]{40}$/.test(env.HERMES_BUILD_COMMIT) || env.HERMES_PAYLOAD_TAG) {
throw new Error('Commit builds require an exact full SHA without a release tag')
}
const local = fromLocalGit(repoRoot, execFn)
if (!local || local.commit !== env.HERMES_BUILD_COMMIT) {
throw new Error('Commit build identity does not match the checkout HEAD')
}
return { ...local, branch: null, source: 'commit-build' }
}
return fromCI(env) || fromLocalGit(repoRoot, execFn) || fromFallback(fallbackBranch)
}
export function isFallbackCommit(commit) {
return typeof commit === "string" && /^0{7,40}$/.test(commit)
}
/** Qualify desktop CLI files before the immutable runtime paths are baked.
* Canonical command keys remain stable for backend consumers; public aliases
* come from the declared filenames, never those internal keys. The canonical
* files stay beside them: the agent's own terminal puts <payload>/bin first on
* PATH (pm.environments.activate_dependencies) and runs bare `hermes`, while
* only the qualified names are published (MSIX aliases, ~/.local/bin links).
*/
export function stageDesktopLaunchers(root, identity = productIdentity) {
const file = join(root, 'manifest.json')
const manifest = JSON.parse(readFileSync(file, 'utf8'))
const windows = manifest.target.startsWith('win32')
const commands = {}
const launchers = []
for (const [name, source] of Object.entries(manifest.runtime.commands)) {
const alias = name.replace(/^hermes(?=-|$)/, identity.cliName)
const destination = posix.join(posix.dirname(source), `${alias}${windows ? '.exe' : ''}`)
if (source !== destination && existsSync(join(root, source))) {
copyFileSync(join(root, source), join(root, destination))
}
if (!existsSync(join(root, destination))) throw new Error(`Missing desktop launcher: ${destination}`)
commands[name] = destination
launchers.push(alias)
}
manifest.runtime.commands = commands
manifest.launchers = launchers
writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n')
return manifest
}
/** Electron and the embedded CLI must see the same immutable provenance. */
export function writeDesktopStamp(outDir, built) {
const json = JSON.stringify(built, null, 2) + "\n"
mkdirSync(outDir, { recursive: true })
if (built.payload === 'bundled') {
writeFileSync(join(outDir, 'agent-payload', built.runtime.repoDir, 'install-stamp.json'), json, 'utf8')
}
writeFileSync(join(outDir, 'install-stamp.json'), json, 'utf8')
}
function main() {
const stamp = resolveStamp()
if (!stamp || !stamp.commit) {
// Should not happen — fromFallback() always provides a commit.
console.error(
"[write-build-stamp] ERROR: could not determine git commit.\n" +
" - $GITHUB_SHA not set\n" +
" - `git rev-parse HEAD` failed at " +
REPO_ROOT +
"\n" +
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
)
process.exit(1)
}
if (isFallbackCommit(stamp.commit)) {
console.warn(
"[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" +
" Using placeholder commit — the packaged app will fall back to the\n" +
" default branch for first-launch bootstrap. For production builds,\n" +
" run from a git checkout or set $GITHUB_SHA."
)
}
if (stamp.dirty) {
console.warn(
"[write-build-stamp] WARNING: working tree is dirty.\n" +
" Pinning to " +
stamp.commit.slice(0, 12) +
" but the packaged code may differ from that commit.\n" +
" Commit your changes before publishing this build."
)
}
const bundled = ['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT)
const payload = bundled
? stageDesktopLaunchers(join(OUT_DIR, 'agent-payload'))
: null
const built = buildStampPayload(stamp, process.env, process.platform, payload)
writeDesktopStamp(OUT_DIR, built)
console.log(
"[write-build-stamp] wrote " +
relative(REPO_ROOT, OUT_FILE) +
" -> " +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +
(stamp.dirty ? " [DIRTY]" : "") +
(stamp.source === "fallback" ? " [FALLBACK]" : "")
)
}
/** One artifact schema for source, bundled and Light builds.
* The PM bundle builder supplies launch paths only for bundled artifacts.
*/
export function buildStampPayload(stamp, env = process.env, platform = process.platform, payload = null) {
const variant = (env.HERMES_DESKTOP_VARIANT || "").trim()
const channelBuild = channelBuildRequest(env)
if (channelBuild && (stamp.commit !== channelBuild.commit || stamp.dirty)) throw new Error('Channel build identity does not match stamp')
const commitBuild = env.HERMES_BUILD_COMMIT || null
if (commitBuild && (!/^[a-f0-9]{40}$/.test(commitBuild) || commitBuild !== stamp.commit)) {
throw new Error('Commit build identity does not match the stamp commit')
}
if (commitBuild && env.HERMES_PAYLOAD_TAG) {
throw new Error('Commit builds cannot also set a release tag')
}
const version = env.HERMES_PAYLOAD_VERSION || (env.HERMES_PAYLOAD_TAG || '').replace(/^v/, '') || null
// The bundle's baked runtime defaults/clears, recorded as data so the smoke
// driver can predict the app's resolved Hermes home without reimplementing
// the banner. Only commit bundles carry one, but the field is harmless when
// absent elsewhere.
const bundleEnv = env.HERMES_BUNDLE_ENV_JSON ? validateBundleEnvironment(JSON.parse(env.HERMES_BUNDLE_ENV_JSON)) : undefined
const base = {
schemaVersion: STAMP_SCHEMA_VERSION,
commit: stamp.commit,
branch: commitBuild || channelBuild ? null : stamp.branch,
builtAt: new Date().toISOString(),
dirty: stamp.dirty,
source: channelBuild ? 'channel-build' : commitBuild ? 'commit-build' : stamp.source,
commitDate: stamp.commitDate ?? null,
baseVersion: channelBuild?.sourceVersion ?? stamp.baseVersion ?? version?.split('-')[0] ?? null,
displayVersion: channelBuild
? `${channelBuild.sourceVersion} (${channelBuild.channel} #${channelBuild.sequence}, ${channelBuild.commit.slice(0, 7)})`
: stamp.displayVersion ?? version,
distance: stamp.distance ?? null
}
if (channelBuild) base.channelBuild = channelBuild
// Rehearsal receivers use the real stable update path, never preview resolution.
if (channelBuild?.receiverCandidate) {
delete base.channelBuild
base.source = 'build'
base.displayVersion = channelBuild.version
}
if (variant === 'bundled') base.receiverProtocol = 1
const updateMechanism = {
'': 'self',
bootstrap: 'self',
store: 'microsoft-store',
bundled: { win32: 'app-installer', darwin: 'electron-updater' }[platform] || 'external',
light: platform === 'darwin' ? 'electron-updater' : 'external'
}[variant]
if (!updateMechanism) throw new Error(`Unknown desktop variant: ${variant}`)
if (channelBuild && updateMechanism === 'external') throw new Error('Channel builds require a supported native update owner')
const bundled = variant === 'bundled' || variant === 'store'
if (bundled && !payload?.runtime?.commands?.hermes) {
throw new Error('PM payload has no completed launch contract; stage the bundle before packaging')
}
return {
...base,
payload: variant === "store" ? "bundled" : variant || "bootstrap",
distribution: "desktop-app",
updateMechanism: commitBuild ? 'external' : updateMechanism,
tag: channelBuild?.receiverCandidate ? channelBuild.releaseTag : env.HERMES_PAYLOAD_TAG || null,
...(bundleEnv ? { bundleEnv } : {}),
...(bundled ? { runtime: payload.runtime } : {})
}
}
if (isMain(import.meta.url)) {
main()
}