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:
@@ -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" }
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user