/** * cli-entrypoints.mjs — the bundled payload's CLI entrypoint generators. * * The bundled payload ships three CLI entrypoints (hermes / hermes-agent / * hermes-acp — mirrors [project.scripts] in pyproject.toml) staged into * agent-payload/bin/. They are fully self-relative, so a bundled artifact * works wherever it lands (and read-only, MSIX included): * * win32 — distlib-MINTED launchers (scripts/desktop-cli/mint-launchers.py * runs on the payload's own store python, whose architecture is by * construction the target's). The minted exe is a plain PE + shebang + * zip overlay of scripts/desktop-cli/launcher-wrapper.py. The shebang is * `#!\..\tools\\python.exe` — the * literal is resolved by the launcher at run time relative to its own * directory, which is the relocatability mechanism (live-proved). * * POSIX — $0-relative bash trampolines generated below. No PE is needed: * a plain script exec'ing the store python is the entrypoint. They follow * the $0 symlink chain so a link out on PATH (e.g. ~/.local/bin) resolves * back into the install. * * Pure module: no side effects, importable from tests. */ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const HERE = path.dirname(fileURLToPath(import.meta.url)) /** The three CLI entrypoints, mirroring [project.scripts] in pyproject.toml. */ export const CLI_LAUNCHER_SPECS = [ { name: 'hermes', module: 'hermes_cli.main', func: 'main' }, { name: 'hermes-agent', module: 'run_agent', func: 'main' }, { name: 'hermes-acp', module: 'acp_adapter.entry', func: 'main' } ] /** * Render scripts/desktop-cli/launcher-wrapper.py for one entry. The * relative paths are the payload's bin-relative layout facts, forward * slashes (same convention as the payload manifest — the wrapper splits * them itself, so one text works cross-platform). * * @param {{ module: string, func: string }} spec * @param {string} relRepo bin-relative repo dir, e.g. ../hermes-agent * @param {string} relSite bin-relative venv site-packages * @returns {string} the rendered wrapper source */ export function renderWinWrapper(spec, relRepo, relSite) { const template = fs.readFileSync(path.join(HERE, 'launcher-wrapper.py'), 'utf8') const substitutions = { __HERMES_ENTRY_MODULE__: spec.module, __HERMES_ENTRY_FUNC__: spec.func, __HERMES_REPO_REL__: relRepo, __HERMES_SITE_REL__: relSite } let text = template for (const [placeholder, value] of Object.entries(substitutions)) { if (value.includes('__')) { throw new Error(`bad substitution for ${placeholder}: ${JSON.stringify(value)}`) } text = text.replaceAll(placeholder, value) } for (const placeholder of Object.keys(substitutions)) { if (text.includes(placeholder)) { throw new Error(`launcher-wrapper.py has an unsubstituted ${placeholder}`) } } return text } /** * One POSIX trampoline. Rel paths are bin-relative with FORWARD slashes * (the same strings the win32 side bakes); the bash resolves them against * the trampoline's own directory. * * @param {{ name: string, module: string }} spec * @param {{ relPython: string, relSite: string, relRepo: string }} rels * @returns {string} executable bash text */ export function posixTrampolineScript(spec, rels) { const join = (rel) => ['"$BIN_DIR"', ...rel.split('/')].join('/') return `#!/usr/bin/env bash # Generated by scripts/build-bundled-desktop.mjs — the bundled payload's # POSIX CLI entrypoint (${spec.name}). Fully $0-relative: follows the # symlink chain so a link out on PATH (~/.local/bin) resolves back into # the install, then execs the store python with the payload's own import # roots. Do not edit the generated copy — change the generator. set -euo pipefail self="$0" while [ -L "$self" ]; do target="$(readlink "$self")" case "$target" in /*) self="$target" ;; *) self="$(dirname "$self")/$target" ;; esac done BIN_DIR="$(cd -- "$(dirname -- "$self")" && pwd)" PYTHON=${join(rels.relPython)} REPO=${join(rels.relRepo)} SITE=${join(rels.relSite)} [ -x "$PYTHON" ] || { echo "${spec.name}: bundled interpreter missing at $PYTHON — the Hermes install is damaged; reinstall from the website" >&2 exit 2 } # A sealed payload has no working editable install (its pointer names the # BUILD machine), so its own import roots REPLACE any inherited PYTHONPATH. # Repo first — its hermes_cli wins over anything stale in site-packages. unset PYTHONPATH PYTHONHOME export PYTHONPATH="$REPO:$SITE" # Keep __pycache__ writes out of the (possibly read-only) payload. if [ -z "\${PYTHONPYCACHEPREFIX:-}" ] && [ -n "\${HOME:-}" ]; then export PYTHONPYCACHEPREFIX="$HOME/.cache/hermes-pycache" fi exec "$PYTHON" -m ${spec.module} "$@" ` } /** * All three POSIX trampolines. * @param {{ relPython: string, relSite: string, relRepo: string }} rels * @returns {{ name: string, text: string }[]} */ export function posixTrampolineScripts(rels) { return CLI_LAUNCHER_SPECS.map((spec) => ({ name: spec.name, text: posixTrampolineScript(spec, rels) })) }