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

@@ -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 }
}