diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 98ccadab34..dc9637a54a 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -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 diff --git a/hermes_cli/source_completion.py b/hermes_cli/source_completion.py new file mode 100644 index 0000000000..9847c1cae7 --- /dev/null +++ b/hermes_cli/source_completion.py @@ -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()) \ No newline at end of file diff --git a/hermes_cli/update_completion.py b/hermes_cli/update_completion.py index da64519de2..dcd4a5768a 100644 --- a/hermes_cli/update_completion.py +++ b/hermes_cli/update_completion.py @@ -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"]: diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 13fa8a3d9c..ed9456aa91 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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" } diff --git a/scripts/install.sh b/scripts/install.sh index c8f733ed11..20cfbca40c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 ;; diff --git a/scripts/tests/test-install-ps1-node-compatibility.ps1 b/scripts/tests/test-install-ps1-node-compatibility.ps1 index af074b2d6e..19cacd57d5 100644 --- a/scripts/tests/test-install-ps1-node-compatibility.ps1 +++ b/scripts/tests/test-install-ps1-node-compatibility.ps1 @@ -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 } } diff --git a/tests/pm/test_install_default_closure.py b/tests/pm/test_install_default_closure.py index cbd643feeb..2ad82b0bc9 100644 --- a/tests/pm/test_install_default_closure.py +++ b/tests/pm/test_install_default_closure.py @@ -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). diff --git a/tests/test_install_stage_frames.py b/tests/test_install_stage_frames.py index ebce4f2695..7c03e838e5 100644 --- a/tests/test_install_stage_frames.py +++ b/tests/test_install_stage_frames.py @@ -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") diff --git a/tests/test_source_launcher_stages.py b/tests/test_source_launcher_stages.py index 36e79e548c..32460d9a42 100644 --- a/tests/test_source_launcher_stages.py +++ b/tests/test_source_launcher_stages.py @@ -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')