refactor(install): one completion tail shared by install and update

The installer ladder stopped at node-deps/path/desktop with its own
semantics while an update ran launchers, product builds and post-build
maintenance, so a fresh install and a finished update ended in different
states: after re-running the installer at HEAD the products had no receipts
and the read-only source acceptance failed.

hermes_cli/source_completion.py now owns that tail -- publish launchers,
build the products, run the maintenance -- and update_completion's
_complete_selected calls it, so there is one implementation. install.sh and
install.ps1 keep the bootstrap stages (prerequisites, repository, venv,
python-deps, config) and hand off to it in a single `products` stage;
--include-desktop selects the desktop product inside that stage instead of
adding a second build stage, and `desktop` stays dispatchable via --stage for
external callers.

Windows keeps its installer-owned PATH publication (expose_cli answers
"windows-installer-owned" on Windows) plus the packaged-artifact probe, ACL
grant and shortcuts. The desktop stage no longer pre-syncs wake/voice: pm
lazy-installs them at first use, as the update path does.
This commit is contained in:
ethernet
2026-09-17 13:47:50 -04:00
parent 5239fa45eb
commit dbeebaadfc
9 changed files with 221 additions and 89 deletions

View File

@@ -76,7 +76,7 @@ def installation_command(repo_root: Path, args=(), *, module: str = "hermes_cli.
return [str(root / ".hermes" / "bin" / "hermes"), *prefix, *args]
#: Launcher command names — keep in lockstep with scripts/install.ps1
#: Stage-Path and hermes_cli/_install_repair.py.
#: Publish-UserCommand and hermes_cli/_install_repair.py.
WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp")
#: command name -> (entry module, callable) — mirrors pyproject.toml

View File

@@ -0,0 +1,107 @@
"""The tail every source install and update shares.
A fresh install (``scripts/install.sh`` / ``scripts/install.ps1``) and a finished
update must land in the same state: launchers published, product builds current,
and the post-build maintenance (skills sync, config migration) applied. One
implementation, two callers -- an install and an update cannot drift apart.
``main()`` is the installer entry. It is handed the bootstrap interpreter the
installer happens to have (uv, PM and no application dependencies), so it
re-executes itself in PM's selected interpreter before touching any product.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
# The installers run this file directly under an isolated interpreter, which
# leaves the checkout off sys.path. Same idiom as hermes_cli/_launchers.py.
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
#: Second-phase marker: the re-executed interpreter, not the bootstrap one.
_PREPARED = "--prepared"
def complete_source_checkout(
root: Path,
*,
desktop: bool,
assume_yes: bool,
gateway_mode: bool = False,
pre_update_snapshot_id: str | None = None,
pre_update_version: str | None = None,
completion_message: str | None = None,
announce: str | None = None,
) -> bool:
"""Publish commands, build the products, then run post-build maintenance.
Returns the SQLite runtime verdict: a positive unsafe-runtime probe withholds
success here exactly as it does at the end of an update.
"""
from hermes_cli.source_build import build_update_products
from hermes_cli.update_cmd_maint import _run_post_update_maintenance
from hermes_cli.venv_sync import publish_launchers
root = Path(root)
publish_launchers(root)
build_update_products(root, desktop=desktop)
if announce:
print(announce)
return _run_post_update_maintenance(
assume_yes=assume_yes,
gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
had_desktop_app_before_update=desktop,
pre_update_version=pre_update_version,
completion_message=completion_message,
)
def _bootstrap_command(root: Path, argv: list[str]) -> list[str]:
"""Re-enter the checkout on PM's selected interpreter with its environment."""
from hermes_cli.runtime_paths import selected_venv
from hermes_constants import venv_python_path
return [str(venv_python_path(selected_venv(root))), "-I", "-B", "-u",
str(Path(__file__).resolve()), "--source", str(root), *argv, _PREPARED]
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
prepared = _PREPARED in argv
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--desktop", action="store_true",
help="Also build the packaged desktop app.")
parser.add_argument("--interactive", action="store_true",
help="Allow prompts; installers pass no flag and stay unattended.")
args = parser.parse_args([argument for argument in argv if argument != _PREPARED])
root = args.source.resolve()
if not (root / "hermes_cli/source_completion.py").is_file():
print(f"✗ {root} is not a Hermes source checkout", file=sys.stderr)
return 1
if prepared:
# Dependencies are selected before any application import: this is the
# same ordering the update completion guarantees.
from hermes_cli.runtime_paths import activate_dependencies
activate_dependencies(root)
ok = complete_source_checkout(
root, desktop=args.desktop, assume_yes=not args.interactive,
completion_message="✓ Install complete!",
)
return 0 if ok else 1
from hermes_cli.runtime_paths import activation_environment
command = _bootstrap_command(root, ["--desktop"] if args.desktop else [])
return subprocess.call(command, cwd=root, env=activation_environment(root))
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -160,10 +160,8 @@ def _prepare(request: dict, request_path: Path, result_path: Path) -> int:
def _complete_selected(request: dict) -> None:
from hermes_cli import main, update_cmd, update_cmd_config
from hermes_cli.source_completion import complete_source_checkout
from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan
from hermes_cli.update_cmd_maint import _run_post_update_maintenance
from hermes_cli.source_build import build_update_products
from hermes_cli.venv_sync import publish_launchers
root = Path(request["source"])
main.PROJECT_ROOT = root
@@ -172,15 +170,14 @@ def _complete_selected(request: dict) -> None:
plan = None if plan_data is None else UpdatePlan(**{
**plan_data, "runtimes": [RuntimeRecord(**row) for row in plan_data.get("runtimes", [])]})
update_cmd._sweep_bytecode_after_update(request["branch"])
publish_launchers(root)
build_update_products(root, desktop=request["desktop"])
if not request.get("completion_message"):
print("\n✓ Code updated!")
complete = _run_post_update_maintenance(
assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"],
pre_update_snapshot_id=request["snapshot_id"],
had_desktop_app_before_update=request["desktop"], pre_update_version=request["pre_update_version"],
completion_message=request.get("completion_message"))
# Launchers, products and post-build maintenance live in one place so an
# install and an update cannot end in different states.
complete = complete_source_checkout(
root, desktop=request["desktop"], assume_yes=request["assume_yes"],
gateway_mode=request["gateway_mode"], pre_update_snapshot_id=request["snapshot_id"],
pre_update_version=request["pre_update_version"],
completion_message=request.get("completion_message"),
announce=None if request.get("completion_message") else "\n✓ Code updated!")
# systemctl's KillMode=mixed fallback can kill this whole cgroup. Publish the
# gateway watcher's status BEFORE that operation, and demote on later failure.
if request["gateway_mode"]:

View File

@@ -476,20 +476,20 @@ function Emit-Frame([bool]$ok, [string]$name, [bool]$skipped, [string]$reason =
$frame | ConvertTo-Json -Compress | Write-Output
}
$ProductTitle = if ($IncludeDesktop) { "Install command and app + desktop" } else { "Install command and app" }
$Stages = @(
@{ name = "prerequisites"; title = "System prerequisites"; category = "runtime"; needs_user_input = $false },
@{ name = "repository"; title = "Download Hermes Agent"; category = "runtime"; needs_user_input = $false },
@{ name = "venv"; title = "Create Python environment"; category = "runtime"; needs_user_input = $false },
@{ name = "python-deps"; title = "Install Python dependencies"; category = "runtime"; needs_user_input = $false },
@{ name = "node-deps"; title = "Install tool dependencies"; category = "runtime"; needs_user_input = $false },
@{ name = "path"; title = "Install hermes command"; category = "runtime"; needs_user_input = $false },
@{ name = "config"; title = "Prepare config and skills"; category = "configuration"; needs_user_input = $false },
# The shared completion tail -- the same call `hermes update` makes -- so
# the manifest and the run cannot disagree. -IncludeDesktop selects the
# desktop product inside this stage instead of adding a second build stage.
@{ name = "products"; title = $ProductTitle; category = "runtime"; needs_user_input = $false },
@{ name = "setup"; title = "Configure API keys and settings"; category = "configuration"; needs_user_input = $true },
@{ name = "gateway"; title = "Configure gateway service"; category = "configuration"; needs_user_input = $true }
)
if ($IncludeDesktop) {
$Stages += @{ name = "desktop"; title = "Build desktop app"; category = "runtime"; needs_user_input = $false }
}
$Stages += @{ name = "complete"; title = "Finish install"; category = "runtime"; needs_user_input = $false }
function Stage-Prerequisites {
if (-not (Ensure-Git)) {
@@ -558,11 +558,32 @@ function Stage-PythonDeps {
Invoke-BootstrapPm
}
function Stage-NodeDeps {
Log "tool dependencies are managed by pm (hermes pm install)"
function Invoke-SourceCompletion([bool]$Desktop) {
# The whole tail in one place, by calling the completion an update calls:
# publish the commands, build the products (tui/web, plus the desktop app
# when asked), then run the post-build maintenance that syncs bundled
# skills and migrates config. Node, browsers and the frontend build tools
# arrive through pm as the build asks for them; the bootstrap interpreter
# itself only re-enters the tree on PM's selected Python.
$bootPy = Get-BootstrapPython
$completionArgs = @('-I', '-B', '-X', 'utf8', 'hermes_cli/source_completion.py', '--source', $InstallDir)
if ($Desktop) { $completionArgs += '--desktop' }
Push-Location $InstallDir
try {
& $bootPy @completionArgs
$code = $LASTEXITCODE
} finally {
Pop-Location
}
if ($code) { Fail "app products or command publication failed (exit $code)" }
Log "app products and hermes command ready"
}
function Stage-Path {
function Publish-UserCommand {
# PATH exposure stays installer-owned on Windows: expose_cli() answers
# "windows-installer-owned" rather than creating the user-facing command,
# so the install-scoped launchers the completion publishes are not the ones
# the user's PATH points at.
$binDir = Join-Path $HermesHome "bin"
$bootPy = Get-BootstrapPython
Push-Location $InstallDir
@@ -577,6 +598,12 @@ function Stage-Path {
Log "hermes command installed at $binDir"
}
function Stage-Products {
Invoke-SourceCompletion ([bool]$IncludeDesktop)
Publish-UserCommand
if ($IncludeDesktop) { Confirm-DesktopArtifact }
}
function Set-LauncherUserPath([string]$binDir) {
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$binDir*") {
@@ -619,29 +646,20 @@ function Stage-Gateway {
}
function Stage-Desktop {
# Desktop support via the CURRENT runtime paths only. wake/voice extras
# ride pm's venv sync ([all] does not include them; lazy install at
# first use remains the fallback -- policy: Teknium, July 2026, #70509).
# The build is `hermes desktop --build-only`, the same authority as
# `hermes gui` and the update flow; the deleted installer-local
# npm/Electron helpers must not reappear here.
$bootPy = Get-BootstrapPython
# External-caller contract: -Stage desktop stays dispatchable on its own
# (see Invoke-StageByName). The work is the same completion call with the
# desktop product selected. Voice and wake extras are not synced here: pm
# lazy-installs them at first use (policy: Teknium, July 2026, #70509).
Invoke-SourceCompletion $true
Publish-UserCommand
Confirm-DesktopArtifact
}
function Confirm-DesktopArtifact {
# Probe the packaged artifact the completion just built -- the same
# candidates hermes_cli/main_desktop._desktop_packaged_executable resolves.
Push-Location $InstallDir
try {
Log "ensuring desktop voice/wake dependencies via pm venv sync"
& $bootPy -I -c "import sys; sys.path.insert(0, sys.argv[1]); from pm import sync_venv; sync_venv(['wake', 'voice'], explicit=True)" $InstallDir
if ($LASTEXITCODE) {
Write-Host "[hermes] voice/wake dependency sync failed (exit $LASTEXITCODE) -- they will lazy-install at first use" -ForegroundColor Yellow
}
Log "building desktop app (hermes desktop --build-only)"
Invoke-InstalledHermes @('desktop', '--build-only')
$code = $LASTEXITCODE
if ($code) { Fail "desktop build failed (hermes desktop --build-only exited $code)" }
# Probe the produced artifact -- the same candidates
# hermes_cli/main_desktop._desktop_packaged_executable resolves
# (verified: --build-only returns the packaged app under
# apps/desktop/release/, not the --source dist/).
$desktopDir = Join-Path $InstallDir "apps\desktop"
$candidates = @(
(Join-Path $desktopDir "release\win-unpacked\Hermes.exe"),
@@ -762,8 +780,7 @@ function Invoke-StageByName([string]$name) {
"repository" { Stage-Repository }
"venv" { Stage-Venv }
"python-deps" { Stage-PythonDeps }
"node-deps" { Stage-NodeDeps }
"path" { Stage-Path }
"products" { Stage-Products }
"config" { Stage-Config }
"setup" { Stage-Setup }
"gateway" { Stage-Gateway }
@@ -805,9 +822,8 @@ if ($Stage) {
# The $Stages table is the single authoritative list: it drives the
# -Manifest output AND the no-flag ladder, so -IncludeDesktop affects
# the real run exactly as the manifest advertises. "desktop" stays
# directly dispatchable via -Stage even without the flag (long-standing
# external-caller contract; the bootstrap frontend always pairs it
# with -IncludeDesktop when it lists the stage).
# directly dispatchable via -Stage even though it is never listed
# (long-standing external-caller contract).
$known = @($Stages | ForEach-Object { $_.name })
if ($known -notcontains $Stage -and $Stage -ne "desktop") {
if ($Json) { Emit-Frame $false $Stage $false "unknown stage: $Stage" }

View File

@@ -1,11 +1,15 @@
#!/usr/bin/env bash
# Hermes Agent bootstrap: git checkout + venv + hermes command on PATH.
# Heavy dependencies (tool binaries, browsers, node) are pm's job after
# this: `hermes pm install`. Stage protocol kept for Hermes-Setup:
# Hermes Agent bootstrap: clone, acquire uv/Python, then hand the checkout to
# the same completion an update runs -- command publication, product builds and
# post-build maintenance -- so a fresh install and a finished update land in one
# state. Heavy dependencies (tool binaries, browsers, node) are pm's job:
# `hermes pm install`.
#
# Stage protocol kept for Hermes-Setup:
# --manifest print the stage list as JSON
# --stage NAME [--json] run one stage
# --non-interactive skip stages that need input
# --include-desktop add the desktop build stage
# --include-desktop build the desktop app too (products stage)
set -u
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
@@ -227,12 +231,22 @@ stage_result() {
}
# The single authoritative stage list: emit_manifest prints it AND the
# no-flag ladder runs it, so --include-desktop affects the real run
# exactly as the manifest advertises.
# no-flag ladder runs it. `products` is the shared completion tail -- the same
# call `hermes update` makes -- so the manifest and the run cannot disagree.
# `desktop` stays directly dispatchable via --stage for external callers, but
# is never listed: --include-desktop selects the desktop product inside
# `products` instead of adding a second build stage.
stage_names() {
printf '%s\n' prerequisites repository venv python-deps node-deps path config setup gateway
[ "$INCLUDE_DESKTOP" = true ] && printf '%s\n' desktop
printf '%s\n' complete
printf '%s\n' prerequisites repository venv python-deps config products setup gateway complete
}
# "title|category|needs_user_input".
products_record() {
if [ "$INCLUDE_DESKTOP" = true ]; then
echo "Install command and app + desktop|runtime|false"
else
echo "Install command and app|runtime|false"
fi
}
# "$1" stage name -> its manifest record fields (title|category|needs_user_input).
@@ -242,9 +256,8 @@ stage_record() {
repository) echo "Download Hermes Agent|runtime|false" ;;
venv) echo "Create Python environment|runtime|false" ;;
python-deps) echo "Install Python dependencies|runtime|false" ;;
node-deps) echo "Install tool dependencies|runtime|false" ;;
path) echo "Install hermes command|runtime|false" ;;
config) echo "Prepare config and skills|configuration|false" ;;
products) products_record ;;
setup) echo "Configure API keys and settings|configuration|true" ;;
gateway) echo "Configure gateway service|configuration|true" ;;
desktop) echo "Build desktop app|runtime|false" ;;
@@ -363,22 +376,28 @@ stage_python_deps() {
bootstrap_pm
}
stage_node_deps() {
# Tool binaries, node, browsers: pm packages, installed on demand or
# via `hermes pm install`. Nothing to do at bootstrap time.
log "tool dependencies are managed by pm (hermes pm install)"
stage_products() {
# The whole tail in one place, by calling the completion an update calls:
# publish the commands, build the products (tui/web, plus the desktop app
# under --include-desktop), then run the post-build maintenance that syncs
# bundled skills and migrates config. Node, browsers and the frontend build
# tools arrive through pm as the build asks for them; the bootstrap
# interpreter itself only re-enters the tree on PM's selected Python.
local boot_py
local args=(--source "$INSTALL_DIR")
bootstrap_python
[ "$INCLUDE_DESKTOP" = true ] && args+=(--desktop)
(cd "$INSTALL_DIR" && "$boot_py" -I -B -X utf8 hermes_cli/source_completion.py "${args[@]}") \
|| fail "app products or command publication failed"
log "app products and hermes command ready"
}
stage_path() {
local link_dir="$HOME/.local/bin"
local boot_py
bootstrap_python
(cd "$INSTALL_DIR" && "$boot_py" -I -X utf8 hermes_cli/_launchers.py "$link_dir") || fail "launcher publication failed"
case ":$PATH:" in
*":$link_dir:"*) : ;;
*) log "add $link_dir to your PATH to use the hermes command" ;;
esac
log "hermes command installed at $link_dir/hermes"
stage_desktop() {
# External-caller contract: `--stage desktop` stays dispatchable on its own
# (the manifest never lists it now -- --include-desktop selects the desktop
# product inside `products`). Same completion call, desktop selected.
INCLUDE_DESKTOP=true
stage_products
}
stage_config() {
@@ -405,12 +424,6 @@ stage_gateway() {
"$INSTALL_DIR/.hermes/bin/hermes" gateway install || fail "gateway installation failed"
}
stage_desktop() {
# `hermes desktop --build-only` is the current authority (same path as
# `hermes gui` / the update flow); no installer-local node/electron code.
"$INSTALL_DIR/.hermes/bin/hermes" desktop --build-only || fail "desktop build failed"
}
stage_complete() {
local commit
commit="$INSTALL_COMMIT"
@@ -440,9 +453,8 @@ run_stage() (
repository) stage_repository ;;
venv) stage_venv ;;
python-deps) stage_python_deps ;;
node-deps) stage_node_deps ;;
path) stage_path ;;
config) stage_config ;;
products) stage_products ;;
setup) stage_setup ;;
gateway) stage_gateway ;;
desktop) stage_desktop ;;

View File

@@ -54,9 +54,8 @@ exit /b 0
Assert-True ($recorded[1] -eq 'python find --managed-python --no-project 3.13') 'lookup ignores ambient project discovery'
Assert-True ((Get-Content -LiteralPath $pythonArgsFile -Raw).Trim() -eq '-m pm.cli install') 'Python launches PM without a uv parent'
function Get-Uv { throw 'node stage attempted provisioning' }
Stage-NodeDeps
Write-Host 'PASS: node stage performs no separate install'
# The installer owns no node stage: tool and frontend provisioning belongs
# to pm, driven by the shared completion tail (install.ps1 "products").
} finally {
if (Test-Path $testRoot) { Remove-Item -LiteralPath $testRoot -Recurse -Force }
}

View File

@@ -1,7 +1,8 @@
"""The default `pm install` closure must stage the interpreter boot requires.
A fresh source install emits boot launchers (scripts/install.sh stage_path,
hermes_cli/_launchers.py) that exec the pm STORE interpreter. The `python`
A fresh source install emits boot launchers (the source completion's
publish_launchers, hermes_cli/_launchers.py) that exec the pm STORE
interpreter. The `python`
package is marked optional (dev installs use their own venv; sealed bundles
adopt a shipped one), so the old default closure — every non-optional
lockfile package — skipped it and left `hermes` unbootable (audit C05).

View File

@@ -27,7 +27,7 @@ def _frame(result):
def test_stage_result_matches_the_actual_exit(tmp_path, case):
env = _env(tmp_path)
stage = {"explicit-failure": "repository", "write-failure": "complete",
"unknown": 'unknown"\\\n\x1fstage', "skipped": "setup", "success": "node-deps"}[case]
"unknown": 'unknown"\\\n\x1fstage', "skipped": "setup", "success": "config"}[case]
env["PROBE_STAGE"] = stage
if case == "explicit-failure":
repo = tmp_path / "install"
@@ -81,7 +81,7 @@ def test_manifest_accepts_the_desktop_home_argument(tmp_path, flag):
text=True, encoding="utf-8", timeout=30)
assert result.returncode == 0, result.stderr
manifest = json.loads(result.stdout)
assert any(row["name"] == "path" for row in manifest["stages"])
assert any(row["name"] == "products" for row in manifest["stages"])
assert not home.exists()
env = dict(_env(tmp_path), PROBE_FLAG=flag, PROBE_HOME=home.as_posix())
env.pop("HERMES_INSTALL_DIR")

View File

@@ -36,7 +36,7 @@ def test_powershell_stage_publishes_without_a_checkout_venv(tmp_path, monkeypatc
wrapper.write_text('''$ErrorActionPreference = 'Stop'
. $env:PROBE_INSTALLER -InstallDir $env:PROBE_REPO -HermesHome $env:PROBE_HOME
Initialize-ResolvedPaths
# Replace acquisition only; Get-BootstrapPython and Stage-Path stay real.
# Replace acquisition only; Get-BootstrapPython and Publish-UserCommand stay real.
function Get-Uv { return 'Invoke-FixtureUv' }
function Invoke-FixtureUv {
$call = $args -join ' '
@@ -59,7 +59,7 @@ function Set-LauncherUserPath([string]$binDir) {
$script:publishedPath = $binDir
Write-Output 'REACHED_PATH_PUBLICATION'
}
Stage-Path
Publish-UserCommand
if (-not $script:publishedPath) { throw 'registry-publication seam was bypassed' }
exit 0
''', encoding='utf-8-sig')