From e5f19af2a5cfed9ec7f6ea1e1f770f8a8b342de3 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 17 May 2026 00:47:21 -0400 Subject: [PATCH] feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage ` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm /scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes) --- scripts/install.ps1 | 732 ++++++++++++++---- .../tests/test-install-ps1-stage-protocol.ps1 | 134 ++++ 2 files changed, 736 insertions(+), 130 deletions(-) create mode 100644 scripts/tests/test-install-ps1-stage-protocol.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 53a1ea96486..4d7545ca689 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -17,11 +17,49 @@ param( [switch]$SkipSetup, [string]$Branch = "main", [string]$HermesHome = "$env:LOCALAPPDATA\hermes", - [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent" + [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent", + + # --- Stage protocol (additive; default invocation behaves as before) ---- + # See the "Stage protocol" section near the bottom of the file for the + # full contract. Intended for programmatic drivers (the desktop GUI's + # onboarding wizard, CI, future install.sh parity, etc.). CLI users + # running the canonical `irm | iex` one-liner never touch these flags. + [switch]$Manifest, + [string]$Stage, + [switch]$ProtocolVersion, + [switch]$NonInteractive, + [switch]$Json ) $ErrorActionPreference = "Stop" +# Suppress Invoke-WebRequest's per-chunk progress bar. Windows PowerShell +# 5.1's progress UI repaints synchronously on every received byte, which +# pegs CPU on a single core and throttles downloads by 10-100x (a 57MB +# PortableGit grab can take 5 minutes with progress on vs 20 seconds +# with progress off, on the same network). Every IWR call in this +# script is fire-and-forget so we never need to see the bar. Restored +# automatically when the script exits. +$ProgressPreference = "SilentlyContinue" + +# Force the console to UTF-8 so non-ASCII output from native commands +# (e.g. playwright's box-drawing progress bars and download banners, +# git's bullet glyphs, npm's check marks) renders correctly instead of +# as IBM437/Windows-1252 mojibake (sequences like 0xE2 0x95 0x94 box- +# drawing chars decoded under the legacy DOS codepage). This is a +# DISPLAY-only fix; the underlying bytes are already correct. We do +# NOT change the file's own encoding (it remains pure ASCII for PS 5.1 +# parser compatibility; see comments at the top of the entry-point +# dispatch). This affects only what the user sees in their terminal +# during this install run, and reverts automatically when the script +# exits and the host's console encoding is restored. +try { + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() +} catch { + # Some constrained PowerShell hosts disallow encoding mutation. + # Mojibake on output is then cosmetic-only, install still works. +} + # ============================================================================ # Configuration # ============================================================================ @@ -31,38 +69,43 @@ $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git" $PythonVersion = "3.11" $NodeVersion = "22" +# Stage-protocol version. Bumped only for genuinely breaking changes to the +# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a +# new stage does NOT bump this -- drivers iterate the manifest dynamically. +$InstallStageProtocolVersion = 1 + # ============================================================================ # Helper functions # ============================================================================ function Write-Banner { Write-Host "" - Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Magenta - Write-Host "│ ⚕ Hermes Agent Installer │" -ForegroundColor Magenta - Write-Host "├─────────────────────────────────────────────────────────┤" -ForegroundColor Magenta - Write-Host "│ An open source AI agent by Nous Research. │" -ForegroundColor Magenta - Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Magenta + Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta + Write-Host "| * Hermes Agent Installer |" -ForegroundColor Magenta + Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta + Write-Host "| An open source AI agent by Nous Research. |" -ForegroundColor Magenta + Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta Write-Host "" } function Write-Info { param([string]$Message) - Write-Host "→ $Message" -ForegroundColor Cyan + Write-Host "-> $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) - Write-Host "✓ $Message" -ForegroundColor Green + Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Warn { param([string]$Message) - Write-Host "⚠ $Message" -ForegroundColor Yellow + Write-Host "[!] $Message" -ForegroundColor Yellow } function Write-Err { param([string]$Message) - Write-Host "✗ $Message" -ForegroundColor Red + Write-Host "[X] $Message" -ForegroundColor Red } # ============================================================================ @@ -97,8 +140,22 @@ function Install-Uv { # Install uv Write-Info "Installing uv (fast Python package manager)..." try { + # Relax ErrorActionPreference around the nested astral installer. + # The astral installer (a separate `powershell -c "irm ... | iex"`) + # writes download progress to stderr. With $ErrorActionPreference + # = "Stop" set at the top of this script, PowerShell wraps stderr + # lines from native commands (which `powershell -c` is, from our + # perspective) as ErrorRecord objects when captured via 2>&1, then + # throws a terminating exception on the first one -- even though + # uv installs successfully and the child exits 0. Same fix + # pattern Test-Python uses for `uv python install`; verify success + # via Test-Path on the expected binary afterwards, which is more + # reliable than exit-code/stderr signal anyway. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null - + $ErrorActionPreference = $prevEAP + # Find the installed binary $uvExe = "$env:USERPROFILE\.local\bin\uv.exe" if (-not (Test-Path $uvExe)) { @@ -123,12 +180,78 @@ function Install-Uv { Write-Info "Try restarting your terminal and re-running" return $false } catch { - Write-Err "Failed to install uv" + # Restore EAP in case the try block threw before the assignment + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } } +# Refresh $env:Path from the User + Machine registry hives. Stage drivers +# invoke each stage in a fresh powershell process, but those processes +# inherit env from the parent driver shell, NOT from the registry. When +# an earlier stage (Stage-Git, Stage-Node, ...) installs a binary and +# pushes its directory into User PATH, the next child process's $env:Path +# is stale and the binary appears missing. This helper re-reads PATH +# from the registry so every Invoke-Stage starts from a fresh, up-to-date +# PATH view. Cheap (registry reads, no I/O elsewhere) and idempotent. +function Sync-EnvPath { + $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") +} + +# Re-discover uv without re-installing it. Cross-process stage drivers +# (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage +# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a +# prior process is not visible here. Later stages (Test-Python, +# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this +# at the top to populate $script:UvCmd from PATH or known install paths. +# Throws if uv is not findable -- the caller's stage then surfaces a +# clean error via the stage-driver's try/catch. Fast path is a single +# Get-Command call when uv is on PATH (the common case after Stage-Uv +# ran path-modifying installs in a sibling process). +function Resolve-UvCmd { + # Already resolved (default invocation path: Install-Uv ran earlier + # in the same process and set $script:UvCmd). + if ($script:UvCmd) { + if ($script:UvCmd -eq "uv") { + # "uv" on PATH -- verify it's still resolvable (PATH could have + # changed mid-session; cheap to recheck). + if (Get-Command uv -ErrorAction SilentlyContinue) { return } + } elseif (Test-Path $script:UvCmd) { + return + } + # Stale; fall through to re-discover. + } + + # Try PATH first (covers `winget install astral.uv`, manual installs, + # and the post-Install-Uv state where uv.exe lives in + # %USERPROFILE%\.local\bin which the installer added to PATH). + if (Get-Command uv -ErrorAction SilentlyContinue) { + $script:UvCmd = "uv" + return + } + + # Refresh PATH from registry in case the current process started before + # Install-Uv updated User PATH. + $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + if (Get-Command uv -ErrorAction SilentlyContinue) { + $script:UvCmd = "uv" + return + } + + # Check the well-known install locations the astral.sh installer drops + # uv into. Mirrors the probe order Install-Uv uses. + foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) { + if (Test-Path $uvPath) { + $script:UvCmd = $uvPath + return + } + } + + throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first." +} + function Test-Python { Write-Info "Checking Python $PythonVersion..." @@ -142,7 +265,7 @@ function Test-Python { } } catch { } - # Python not found — use uv to install it (no admin needed!) + # Python not found -- use uv to install it (no admin needed!) Write-Info "Python $PythonVersion not found, installing via uv..." try { # Temporarily relax ErrorActionPreference: uv writes download progress @@ -150,7 +273,7 @@ function Test-Python { # stderr. With $ErrorActionPreference = "Stop" (set at the top of this # script) PowerShell wraps stderr lines from native commands as # ErrorRecord objects when captured via 2>&1, then throws a terminating - # exception on the first one — even though uv exits 0 and Python was + # exception on the first one -- even though uv exits 0 and Python was # installed successfully. Verify success via `uv python find` # afterwards, which is the reliable signal regardless of exit-code # semantics or stderr noise. This fix was previously landed as @@ -170,7 +293,7 @@ function Test-Python { return $true } - # uv ran but Python still not findable — show what happened + # uv ran but Python still not findable -- show what happened if ($uvExitCode -ne 0) { Write-Warn "uv python install output:" Write-Host $uvOutput -ForegroundColor DarkGray @@ -195,7 +318,7 @@ function Test-Python { } catch { } } - # Fallback: try system python — but skip the Microsoft Store stub. + # Fallback: try system python -- but skip the Microsoft Store stub. # On Windows, %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe is a 0-byte # reparse-point stub that prints "Python was not found; run without # arguments to install from the Microsoft Store..." to stdout and exits @@ -244,17 +367,17 @@ function Install-Git { Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe which Hermes uses to run shell commands. - Priority order (deliberately simple — no winget, no registry, no system + Priority order (deliberately simple -- no winget, no registry, no system package manager): - 1. Existing ``git`` on PATH — use it as-is (the common fast path). + 1. Existing ``git`` on PATH -- use it as-is (the common fast path). 2. Download **PortableGit** from the official git-for-windows GitHub release (self-extracting 7z.exe) and unpack it to - ``%LOCALAPPDATA%\hermes\git`` — never touches system Git, never + ``%LOCALAPPDATA%\hermes\git`` -- never touches system Git, never requires admin, works even on locked-down machines and machines with a broken system Git install. **Why PortableGit, not MinGit:** MinGit is the minimal-automation - distribution and ships ONLY ``git.exe`` — no bash, no POSIX utilities. + distribution and ships ONLY ``git.exe`` -- no bash, no POSIX utilities. Hermes needs ``bash.exe`` to run shell commands. PortableGit is the full Git for Windows distribution without the installer UI; it ships ``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``, @@ -280,9 +403,9 @@ function Install-Git { } # Download PortableGit into $HermesHome\git. Always works as long as - # we can reach github.com — no admin, no winget, no reliance on the + # we can reach github.com -- no admin, no winget, no reliance on the # user's possibly-broken system Git install. - Write-Info "Git not found — downloading PortableGit to $HermesHome\git\ ..." + Write-Info "Git not found -- downloading PortableGit to $HermesHome\git\ ..." Write-Info "(no admin rights required; isolated from any system Git install)" try { @@ -294,7 +417,7 @@ function Install-Git { "64-bit" } } else { - # PortableGit does not ship a 32-bit build — fall back to MinGit 32-bit + # PortableGit does not ship a 32-bit build -- fall back to MinGit 32-bit # with a warning that bash-based features will be unavailable. "32-bit-mingit" } @@ -303,7 +426,7 @@ function Install-Git { $release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" } if ($arch -eq "32-bit-mingit") { - Write-Warn "32-bit Windows detected — PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine." + Write-Warn "32-bit Windows detected -- PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine." $assetPattern = "MinGit-*-32-bit.zip" $downloadIsZip = $true } elseif ($arch -eq "arm64") { @@ -428,7 +551,7 @@ function Set-GitBashEnvVar { # Standard system install locations as a final fallback. Note: # ProgramFiles(x86) can't be referenced via ${env:...} string interpolation - # because of the parens — use [Environment]::GetEnvironmentVariable(). + # because of the parens -- use [Environment]::GetEnvironmentVariable(). $candidates += "${env:ProgramFiles}\Git\bin\bash.exe" $pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" } @@ -443,7 +566,7 @@ function Set-GitBashEnvVar { } } - Write-Warn "Could not locate bash.exe — Hermes may not find Git Bash." + Write-Warn "Could not locate bash.exe -- Hermes may not find Git Bash." Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } @@ -467,26 +590,18 @@ function Test-Node { return $true } - Write-Info "Node.js not found — installing Node.js $NodeVersion LTS..." + Write-Info "Node.js not found -- installing Node.js $NodeVersion LTS..." - # Try winget first (cleanest on modern Windows) - if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Info "Installing via winget..." - try { - winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null - # Refresh PATH - $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - if (Get-Command node -ErrorAction SilentlyContinue) { - $version = node --version - Write-Success "Node.js $version installed via winget" - $script:HasNode = $true - return $true - } - } catch { } - } - - # Fallback: download binary zip to ~/.hermes/node/ - Write-Info "Downloading Node.js $NodeVersion binary..." + # Try the portable-zip path FIRST -- no UAC, no admin, no winget MSI. + # winget install OpenJS.NodeJS.LTS triggers a system-wide MSI install + # which prompts UAC (the dialog often appears minimized in the taskbar + # and the install silently waits for consent, looking like a hang). + # The portable zip path drops node.exe + npm into $HermesHome\node\ + # which is user-scoped and identical to how Install-Git handles + # PortableGit. Same UX guarantee: works on locked-down enterprise + # machines with no admin rights. + Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..." + Write-Info "(no admin rights required; isolated from any system Node install)" try { $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" @@ -506,10 +621,23 @@ function Test-Node { if ($extractedDir) { if (Test-Path "$HermesHome\node") { Remove-Item -Recurse -Force "$HermesHome\node" } Move-Item $extractedDir.FullName "$HermesHome\node" + + # Session PATH so the rest of this run sees node/npm. $env:Path = "$HermesHome\node;$env:Path" + # Persist to User PATH so fresh shells (and future stages + # in cross-process driver mode) see it. Matches the + # pattern Install-Git uses for PortableGit. + $nodeDir = "$HermesHome\node" + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } + if ($userPathItems -notcontains $nodeDir) { + $userPathItems += $nodeDir + [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") + } + $version = & "$HermesHome\node\node.exe" --version - Write-Success "Node.js $version installed to ~/.hermes/node/" + Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)" $script:HasNode = $true Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue @@ -518,10 +646,39 @@ function Test-Node { } } } catch { - Write-Warn "Download failed: $_" + Write-Warn "Portable Node.js download failed: $_" } - Write-Warn "Could not auto-install Node.js" + # Fallback: try winget (used to be primary, demoted because the MSI + # install triggers a UAC prompt that frequently appears minimized in + # the taskbar -- looks like a hang to users on stock Windows). + # Kept for environments where the portable download fails (proxy, + # locked firewall, etc.) but the user is willing to consent to UAC. + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..." + try { + # Relax EAP=Stop so stderr lines from winget don't get wrapped + # as ErrorRecords and short-circuit the 2>&1 pipe before we can + # check the post-condition. See the long comment in Install-Uv + # for the same pattern. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null + $ErrorActionPreference = $prevEAP + # Refresh PATH + $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + if (Get-Command node -ErrorAction SilentlyContinue) { + $version = node --version + Write-Success "Node.js $version installed via winget" + $script:HasNode = $true + return $true + } + } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + } + } + + Write-Info "Install manually: https://nodejs.org/en/download/" $script:HasNode = $false return $true @@ -657,7 +814,7 @@ function Install-Repository { if (Test-Path $InstallDir) { # Test-Path "$InstallDir\.git" returns True when .git is a file OR a - # directory OR a symlink OR a submodule-style gitfile — and also when + # directory OR a symlink OR a submodule-style gitfile -- and also when # it's a broken stub left over from a failed previous install (e.g. # a partial Remove-Item that couldn't delete a locked index.lock). # Validate the repo properly by asking git itself. Two checks @@ -704,7 +861,7 @@ function Install-Repository { # a partial uninstall used to lock the installer into the # "update" branch forever, emitting three ``fatal: not a git # repository`` errors and failing with "not in a git directory". - Write-Warn "Existing directory at $InstallDir is not a valid git repo — replacing it." + Write-Warn "Existing directory at $InstallDir is not a valid git repo -- replacing it." try { Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop } catch { @@ -750,7 +907,7 @@ function Install-Repository { # Fallback: download ZIP archive (bypasses git file I/O issues entirely) if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } - Write-Warn "Git clone failed — downloading ZIP archive instead..." + Write-Warn "Git clone failed -- downloading ZIP archive instead..." try { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip" $zipPath = "$env:TEMP\hermes-agent-$Branch.zip" @@ -841,14 +998,14 @@ function Install-Dependencies { $env:VIRTUAL_ENV = "$InstallDir\venv" } - # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # Hash-verified install (Tier 0) -- when uv.lock is present, prefer # `uv sync --locked`. The lockfile records SHA256 hashes for every # transitive dependency, so a compromised transitive (different hash # than what we shipped) is REJECTED by the resolver. This is the # *only* path that protects against the "direct dep is fine, but the # dep's dep got worm-poisoned overnight" failure mode. The # `uv pip install` tiers below re-resolve transitives fresh from PyPI - # without any hash verification — they exist to keep installs working + # without any hash verification -- they exist to keep installs working # when the lockfile is stale, missing, or out-of-sync with the # current extras spec, NOT because they're equivalent in posture. if (Test-Path "uv.lock") { @@ -863,7 +1020,7 @@ function Install-Dependencies { # # UV_PROJECT_ENVIRONMENT pins the sync target to our venv\. # Without it, modern uv (>=0.5) ignores VIRTUAL_ENV for `sync` - # and creates a sibling .venv\ inside the repo — leaving venv\ + # and creates a sibling .venv\ inside the repo -- leaving venv\ # empty and producing the broken state where `hermes.exe` exists # in the wrong directory and imports fail with ModuleNotFoundError. # (Mirrors the same flag in scripts/install.sh::install_deps.) @@ -872,7 +1029,7 @@ function Install-Dependencies { if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed (hash-verified via uv.lock)" $script:InstalledTier = "hash-verified (uv.lock)" - # Skip the rest of the tiered cascade — we already have a + # Skip the rest of the tiered cascade -- we already have a # complete, hash-verified install. $skipPipFallback = $true } else { @@ -880,22 +1037,22 @@ function Install-Dependencies { $skipPipFallback = $false } } else { - Write-Info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + Write-Info "uv.lock not found -- falling back to PyPI resolve (no hash verification)" $skipPipFallback = $false } # Install main package. Tiered fallback so a single flaky transitive # doesn't silently drop everything. Each tier's stdout/stderr is - # preserved — no Out-Null swallowing — so the user can see what failed. + # preserved -- no Out-Null swallowing -- so the user can see what failed. # - # Tier 1: [all] — the curated extra in pyproject.toml. + # Tier 1: [all] -- the curated extra in pyproject.toml. # Tier 2: [all] minus the currently-broken extras list ($brokenExtras). # Edit $brokenExtras below when something on PyPI breaks; this # lets users keep the rest of [all] when one transitive is # unavailable. The list of [all]'s contents is parsed from - # pyproject.toml at runtime — there is NO hand-mirrored copy + # pyproject.toml at runtime -- there is NO hand-mirrored copy # to drift out of sync. - # Tier 3: bare `.` — last-resort so at least the core CLI launches. + # Tier 3: bare `.` -- last-resort so at least the core CLI launches. # Currently-broken extras. Edit this list when an upstream package # gets quarantined / yanked / breaks resolution. Empty means everything @@ -969,11 +1126,21 @@ except Exception: if (-not (Test-Path $venvPython)) { throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" } + # Relax EAP=Stop while running the import probe. Python writes + # deprecation warnings and import-system info to stderr; under + # EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and + # throws even when the imports succeed. $LASTEXITCODE is the + # reliable signal (it's 0 iff the python invocation exited 0, + # regardless of what was written to stderr). + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" & $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { + $importExitCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($importExitCode -ne 0) { $sibling = "$InstallDir\.venv" $hint = if (Test-Path $sibling) { - "Detected sibling .venv\ at $sibling — uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv" + "Detected sibling .venv\ at $sibling -- uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv" } else { "Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" } @@ -982,19 +1149,27 @@ except Exception: Write-Success "Baseline imports verified in venv" } - # Verify the dashboard deps specifically — they're the most common thing + # Verify the dashboard deps specifically -- they're the most common thing # users hit and lazy-import errors from `hermes dashboard` are confusing. # If tier 1 failed (the common case), [web] was still picked up by tiers # 2-3; only tier 4 leaves you without it. $pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } if (Test-Path $pythonExe) { $webOk = $false + # Relax EAP=Stop while running the import probe; see the matching + # comment on the baseline-imports check above. Python writes + # deprecation warnings to stderr and we don't want those wrapped + # as ErrorRecords that silently force the "not importable" path + # even when fastapi/uvicorn are actually installed. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" try { & $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { $webOk = $true } } catch { } + $ErrorActionPreference = $prevEAP if (-not $webOk) { - Write-Warn "fastapi/uvicorn not importable — `hermes dashboard` will not work." + Write-Warn "fastapi/uvicorn not importable -- `hermes dashboard` will not work." Write-Info "Attempting targeted install of [web] extra as last resort..." & $UvCmd pip install -e ".[web]" if ($LASTEXITCODE -eq 0) { @@ -1099,7 +1274,7 @@ function Copy-ConfigTemplates { # flags the BOM as an invisible unicode character and refuses to # load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we # don't control which PowerShell version the user has. Go direct - # to .NET with an explicit UTF8Encoding($false) — BOM-free on every + # to .NET with an explicit UTF8Encoding($false) -- BOM-free on every # PowerShell version. $soulPath = "$HermesHome\SOUL.md" if (-not (Test-Path $soulPath)) { @@ -1155,7 +1330,7 @@ function Install-NodeDeps { # Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows # ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim). # Get-Command's default ordering picks whichever comes first in PATHEXT, - # and on many systems that's .ps1 — but .ps1 requires scripts to be + # and on many systems that's .ps1 -- but .ps1 requires scripts to be # enabled in PowerShell's execution policy, which most Windows users # don't have (the Restricted / RemoteSigned default blocks unsigned # .ps1 files). .cmd has no such restriction and works on every box. @@ -1165,7 +1340,7 @@ function Install-NodeDeps { # returned if we can't find a .cmd sibling. $npmCmd = Get-Command npm -ErrorAction SilentlyContinue if (-not $npmCmd) { - Write-Warn "npm not found on PATH — skipping Node.js dependencies." + Write-Warn "npm not found on PATH -- skipping Node.js dependencies." Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later." return } @@ -1176,7 +1351,7 @@ function Install-NodeDeps { Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)" $npmExe = $npmCmdSibling } else { - Write-Warn "Only npm.ps1 available — install may fail if script execution is disabled." + Write-Warn "Only npm.ps1 available -- install may fail if script execution is disabled." Write-Info " If it fails, either enable PS script execution or install Node via winget." } } @@ -1193,17 +1368,40 @@ function Install-NodeDeps { function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) { Push-Location $installDir try { - # Redirect ALL output streams to the log file via 2>&1 and then - # ``Tee-Object`` / ``Out-File``. Simpler approach: call npm - # with output redirected and inspect $LASTEXITCODE afterwards. - & $npmPath install --silent *> $logPath + # Stream npm's output to BOTH the console and the log file via + # Tee-Object. Previously this called ``& npm install --silent + # *> $logPath`` which redirected every stream to disk and left + # the user staring at a frozen "Installing..." line for the + # duration of the install. On a fresh VM that's 1-3 minutes + # of total silence, indistinguishable from a hang. + # + # Tee writes the live output to stdout AND $logPath; we still + # capture the exit code afterwards and surface diagnostics + # on failure. Note: 2>&1 merges npm's stderr into the success + # stream first because Tee-Object only sees the success + # stream of the pipeline. ForEach-Object { "$_" } coerces + # each item to a string so PowerShell's NativeCommandError + # formatter doesn't wrap stderr lines as alarming red blocks + # (cosmetic polish; the underlying text is unchanged). + # + # Relax EAP around the npm invocation: with EAP=Stop (set at + # the top of this script), PowerShell wraps stderr lines from + # native commands captured via 2>&1 as ErrorRecord objects and + # throws on the first one -- even though npm exited 0. This + # is the same issue Test-Python and Install-Uv work around + # for uv's stderr-emitting installer. Check success via + # $LASTEXITCODE, which is reliable regardless of stderr noise. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npmPath install --silent 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $logPath $code = $LASTEXITCODE + $ErrorActionPreference = $prevEAP if ($code -eq 0) { Write-Success "$label dependencies installed" Remove-Item -Force $logPath -ErrorAction SilentlyContinue return $true } - Write-Warn "$label npm install failed — exit code $code" + Write-Warn "$label npm install failed -- exit code $code" if (Test-Path $logPath) { $errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue) if ($errText) { @@ -1218,6 +1416,7 @@ function Install-NodeDeps { Write-Info "Run manually later: cd `"$installDir`"; npm install" return $false } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "$label npm install could not be launched: $_" return $false } finally { @@ -1236,7 +1435,7 @@ function Install-NodeDeps { # returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the # browser_* tools are silently filtered out of the agent's tool schema. # System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by - # agent-browser — it expects a Playwright-managed Chromium. + # agent-browser -- it expects a Playwright-managed Chromium. if ($browserNpmOk) { Write-Info "Installing browser engine (Playwright Chromium)..." # npx lives next to npm in the same bin dir. Prefer .cmd to dodge @@ -1252,19 +1451,54 @@ function Install-NodeDeps { if ($npxCmd) { $npxExe = $npxCmd.Source } } if (-not $npxExe) { - Write-Warn "npx not found — cannot install Playwright Chromium." + Write-Warn "npx not found -- cannot install Playwright Chromium." Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } else { $pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log" Push-Location $InstallDir try { - & $npxExe playwright install chromium *> $pwLog + # Playwright Chromium is ~170MB compressed and the + # download regularly takes 3-10 minutes on a fresh + # VM. Tee the output to console + log so the user + # sees download progress in real time instead of + # staring at a silent prompt that looks hung. See + # _Run-NpmInstall above for the same pattern and + # the rationale behind 2>&1 before the pipe. + Write-Info "(this can take several minutes -- streaming progress below)" + # --yes auto-accepts npx's "Need to install playwright@X.Y.Z" + # confirmation prompt. Without it, npx 7+ blocks on stdin + # waiting for a y/N answer that never comes when this is + # invoked through a pipeline (Tee-Object disconnects stdin + # from the user's TTY), and the install hangs indefinitely + # after printing "Need to install the following packages: + # playwright@X.Y.Z". + # + # Relax EAP around the playwright invocation: playwright + # emits a "Chromium downloaded to ..." success banner to + # stderr after a successful install. Under EAP=Stop, the + # 2>&1 merge wraps those stderr lines as ErrorRecord + # objects and throws -- causing this catch block to fire + # with a mangled banner as the error message even though + # the install actually succeeded. Check $LASTEXITCODE + # instead, which is the reliable signal. + # + # The ForEach-Object { "$_" } coercion BEFORE Tee-Object + # is a cosmetic polish: with bare 2>&1, PowerShell still + # renders stderr lines through its NativeCommandError + # formatter (the red "npx.cmd : ..." block). Coercing + # each pipeline item to a string strips that wrapper so + # the user sees clean playwright output instead of the + # alarming-looking error formatting. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npxExe --yes playwright install chromium 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $pwLog $pwCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP if ($pwCode -eq 0) { Write-Success "Playwright Chromium installed (browser tools ready)" Remove-Item -Force $pwLog -ErrorAction SilentlyContinue } else { - Write-Warn "Playwright Chromium install failed — exit code $pwCode" + Write-Warn "Playwright Chromium install failed -- exit code $pwCode" Write-Warn "Browser tools will not work until Chromium is installed." if (Test-Path $pwLog) { $pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue @@ -1280,6 +1514,7 @@ function Install-NodeDeps { Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "Playwright Chromium install could not be launched: $_" Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } finally { @@ -1307,7 +1542,7 @@ function Install-PlatformSdks { # which silently skips some messaging SDKs from [messaging]. # 2. `uv` creates the venv without pip. If a messaging SDK ends up # missing, the user can't `pip install python-telegram-bot` to - # recover — pip simply isn't in their venv. + # recover -- pip simply isn't in their venv. # # Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then # for each token set in .env, verify the matching SDK imports. If not, @@ -1387,7 +1622,7 @@ function Install-PlatformSdks { Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..." & $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { - Write-Warn "ensurepip failed — can't auto-install missing SDKs." + Write-Warn "ensurepip failed -- can't auto-install missing SDKs." Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`"" return } @@ -1412,20 +1647,28 @@ function Invoke-SetupWizard { Write-Info "Skipping setup wizard (-SkipSetup)" return } - + + if ($NonInteractive) { + # The setup wizard prompts for API keys, model choice, persona, etc. + # Non-interactive callers (GUI installer) own that UX themselves; let + # them drive it after install.ps1 returns. + Write-Info "Skipping setup wizard (non-interactive). Configure via the GUI or 'hermes setup'." + return + } + Write-Host "" Write-Info "Starting setup wizard..." Write-Host "" - + Push-Location $InstallDir - + # Run hermes setup using the venv Python directly (no activation needed) if (-not $NoVenv) { & ".\venv\Scripts\python.exe" -m hermes_cli.main setup } else { python -m hermes_cli.main setup } - + Pop-Location } @@ -1455,13 +1698,20 @@ function Start-GatewayIfConfigured { Write-Info "WhatsApp is enabled but not yet paired." Write-Info "Running 'hermes whatsapp' to pair via QR code..." Write-Host "" - $response = Read-Host "Pair WhatsApp now? [Y/n]" - if ($response -eq "" -or $response -match "^[Yy]") { - try { - & $hermesCmd whatsapp - } catch { - # Expected after pairing completes + # Non-interactive callers (GUI installer, CI) skip the QR-pair prompt; + # WhatsApp pairing requires a human looking at a phone camera, so the + # downstream UI is responsible for surfacing this when it makes sense. + if (-not $NonInteractive) { + $response = Read-Host "Pair WhatsApp now? [Y/n]" + if ($response -eq "" -or $response -match "^[Yy]") { + try { + & $hermesCmd whatsapp + } catch { + # Expected after pairing completes + } } + } else { + Write-Info "Skipping WhatsApp pairing prompt (non-interactive)." } } @@ -1469,6 +1719,16 @@ function Start-GatewayIfConfigured { Write-Info "Messaging platform token detected!" Write-Info "The gateway handles messaging platforms and cron job execution." Write-Host "" + + # In non-interactive mode the gateway lifecycle is the caller's problem + # (the GUI manages its own gateway process, CI doesn't want background + # services on the build agent, etc.). Treat it like the user declined. + if ($NonInteractive) { + Write-Info "Skipping gateway autostart prompt (non-interactive)." + Write-Info "Start the gateway later with: hermes gateway" + return + } + $response = Read-Host "Would you like to start the gateway now? [Y/n]" if ($response -eq "" -or $response -match "^[Yy]") { @@ -1492,13 +1752,13 @@ function Start-GatewayIfConfigured { function Write-Completion { Write-Host "" - Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Green - Write-Host "│ ✓ Installation Complete! │" -ForegroundColor Green - Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Green + Write-Host "+---------------------------------------------------------+" -ForegroundColor Green + Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green + Write-Host "+---------------------------------------------------------+" -ForegroundColor Green Write-Host "" # Show file locations - Write-Host "📁 Your files:" -ForegroundColor Cyan + Write-Host "* Your files:" -ForegroundColor Cyan Write-Host "" Write-Host " Config: " -NoNewline -ForegroundColor Yellow Write-Host "$HermesHome\config.yaml" @@ -1510,9 +1770,9 @@ function Write-Completion { Write-Host "$HermesHome\hermes-agent\" Write-Host "" - Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host "---------------------------------------------------------" -ForegroundColor Cyan Write-Host "" - Write-Host "🚀 Commands:" -ForegroundColor Cyan + Write-Host "* Commands:" -ForegroundColor Cyan Write-Host "" Write-Host " hermes " -NoNewline -ForegroundColor Green Write-Host "Start chatting" @@ -1528,9 +1788,9 @@ function Write-Completion { Write-Host "Update to latest version" Write-Host "" - Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host "---------------------------------------------------------" -ForegroundColor Cyan Write-Host "" - Write-Host "⚡ Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow + Write-Host "[*] Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow Write-Host "" if (-not $HasNode) { @@ -1548,18 +1808,136 @@ function Write-Completion { } # ============================================================================ -# Main +# Stage protocol +# ============================================================================ +# +# install.ps1 supports a small, stable "stage protocol" that lets programmatic +# callers (the desktop GUI's onboarding wizard, CI, future install.sh, etc.) +# drive the install one step at a time and surface progress/errors with their +# own UI. CLI users running the canonical `irm | iex` one-liner never +# encounter this -- default invocation behaves exactly as before. +# +# Entry points: +# +# install.ps1 Interactive install (today's behavior). +# install.ps1 -ProtocolVersion Emit the protocol version integer. +# install.ps1 -Manifest Emit the stage manifest as JSON. +# install.ps1 -Stage Run one stage and emit its result. +# install.ps1 -NonInteractive Disable all Read-Host prompts (also +# skips the setup wizard and the gateway +# autostart prompt). Can be combined +# with default invocation to do a full +# non-interactive install. +# install.ps1 -Json Emit machine-readable JSON instead of +# the human-readable success banner at +# the end of a full install. +# +# Manifest schema (the JSON returned by -Manifest): +# +# { +# "protocol_version": 1, +# "stages": [ +# { +# "name": "uv", +# "title": "Installing uv package manager", +# "category": "prereqs", +# "needs_user_input": false +# }, +# ... +# ] +# } +# +# Stage result (the JSON written by -Stage ): +# +# { +# "stage": "uv", +# "ok": true, +# "skipped": false, +# "reason": null, +# "duration_ms": 1234 +# } +# +# Exit codes: +# +# 0 -- success (stage ran, or stage was deliberately skipped). +# 1 -- generic failure; the stage threw. +# 2 -- unknown stage name passed to -Stage. +# +# Adding a stage: +# +# 1. Append an entry to $InstallStages below. +# 2. Make sure the worker function it points at is idempotent and respects +# $NonInteractive when it has prompts. Add it before "configure" +# (the wizard) or "gateway" (autostart) if it should run unconditionally; +# after those if it's optional post-install glue. +# 3. Do NOT bump $InstallStageProtocolVersion -- adding stages is additive. +# Drivers iterate the manifest dynamically. +# # ============================================================================ -function Main { - Write-Banner +# Stage definitions -- the single source of truth. Each entry maps a stable +# stage name (the API contract drivers depend on) to the worker function that +# implements it. ``Title`` is what UIs show; ``Category`` lets UIs group +# stages; ``NeedsUserInput`` tells UIs "this stage prompts -- either skip it +# or arrange to provide answers another way." +$InstallStages = @( + @{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" } + @{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" } + @{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" } + @{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" } + @{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" } + @{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" } + @{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" } + @{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" } + @{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" } + @{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" } + @{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" } + @{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" } + # Interactive stages. In non-interactive mode these become no-ops; the + # caller (GUI / CI) handles the equivalent UX themselves. + @{ Name = "configure"; Title = "Configuring API keys and models"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Configure" } + @{ Name = "gateway"; Title = "Starting messaging gateway"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Gateway" } +) +# Stage workers -- thin wrappers that delegate to the existing Install-* / +# Test-* / Invoke-* functions while preserving their error semantics. Kept +# as a separate layer so the existing functions remain callable directly +# (helpful for one-off recovery: ``. install.ps1; Install-Venv``). +# +# Stages that depend on uv (anything after Stage-Uv) call Resolve-UvCmd +# first so they work in cross-process driver mode where $script:UvCmd +# set by Stage-Uv in a sibling powershell process is not visible here. +# Resolve-UvCmd is a fast no-op when $script:UvCmd is already populated +# (the default-invocation case where Main runs everything in one +# process), and throws cleanly if uv truly isn't installed yet. +function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } } +function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } } +function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } } +function Stage-Node { [void](Test-Node) } +function Stage-SystemPackages { Install-SystemPackages } +function Stage-Repository { Install-Repository } +function Stage-Venv { Resolve-UvCmd; Install-Venv } +function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } +function Stage-NodeDeps { Install-NodeDeps } +function Stage-Path { Set-PathVariable } +function Stage-ConfigTemplates { Copy-ConfigTemplates } +function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } +function Stage-Configure { Invoke-SetupWizard } +function Stage-Gateway { Start-GatewayIfConfigured } + +function Get-InstallStage { + param([string]$Name) + foreach ($s in $InstallStages) { + if ($s.Name -eq $Name) { return $s } + } + return $null +} + +function Step-OutOfInstallDir { # Windows refuses to delete a directory any shell is currently cd'd - # inside — and silently leaves orphan files behind, which then wedge - # "is this a valid git repo" probes on re-install. If the current - # working dir is under $InstallDir, step out to the user's home - # BEFORE doing anything else. Harmless when the user ran the - # installer from somewhere else. + # inside -- and silently leaves orphan files behind, which then wedge + # "is this a valid git repo" probes on re-install. Harmless when the + # caller ran the installer from somewhere else. try { $currentResolved = (Get-Location).ProviderPath $installResolved = $null @@ -1571,36 +1949,130 @@ function Main { Set-Location $env:USERPROFILE } } catch {} - - if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" } - if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" } - if (-not (Install-Git)) { throw "Git not available and auto-install failed — install from https://git-scm.com/download/win then re-run" } - # Test-Node always returns $true (sets $script:HasNode on success, emits a - # warning on failure and continues so non-browser installs still work). - # Cast to [void] so the bare return value doesn't print "True" to the - # console between the "Node found" line and the next installer step. - [void](Test-Node) - Install-SystemPackages # ripgrep + ffmpeg in one step - - Install-Repository - Install-Venv - Install-Dependencies - Install-NodeDeps - Set-PathVariable - Copy-ConfigTemplates - Invoke-SetupWizard - Install-PlatformSdks - Start-GatewayIfConfigured - - Write-Completion } -# Wrap in try/catch so errors don't kill the terminal when run via: -# irm https://...install.ps1 | iex -# (exit/throw inside iex kills the entire PowerShell session) +function Invoke-Stage { + param( + [Parameter(Mandatory=$true)] [hashtable]$StageDef + ) + + # Refresh PATH from registry so this stage sees binaries installed by + # prior stages, even when each stage runs in its own powershell process. + # No-op in cost-relevant cases (default invocation path syncs once per + # foreach pass; cross-process drivers get the necessary freshening). + Sync-EnvPath + + $start = [DateTime]::UtcNow + $result = @{ + stage = $StageDef.Name + ok = $false + skipped = $false + reason = $null + duration_ms = 0 + } + + try { + & $StageDef.Worker + $result.ok = $true + } catch { + $result.ok = $false + $result.reason = "$_" + throw + } finally { + $result.duration_ms = [int]([DateTime]::UtcNow - $start).TotalMilliseconds + if ($Json -or $Stage) { + # In stage-driver mode every stage emits a JSON line so the + # caller can stream progress. In default interactive mode we + # stay silent here (the worker already wrote human output). + $result | ConvertTo-Json -Compress | Write-Output + } + } +} + +# ============================================================================ +# Main +# ============================================================================ + +function Invoke-AllStages { + Step-OutOfInstallDir + foreach ($s in $InstallStages) { + Invoke-Stage -StageDef $s + } +} + +function Main { + Write-Banner + Invoke-AllStages + if (-not $Json) { + Write-Completion + } else { + @{ ok = $true; protocol_version = $InstallStageProtocolVersion } | ConvertTo-Json -Compress | Write-Output + } +} + +# ---------------------------------------------------------------------------- +# Entry-point dispatch +# ---------------------------------------------------------------------------- +# +# All branches funnel through one try/catch so errors don't kill an `irm | +# iex` PowerShell session, and so failures in stage-driver mode produce a +# structured JSON error frame instead of a bare exception. + try { + if ($ProtocolVersion) { + Write-Output $InstallStageProtocolVersion + exit 0 + } + + if ($Manifest) { + $payload = @{ + protocol_version = $InstallStageProtocolVersion + stages = @($InstallStages | ForEach-Object { + @{ + name = $_.Name + title = $_.Title + category = $_.Category + needs_user_input = $_.NeedsUserInput + } + }) + } + $payload | ConvertTo-Json -Depth 5 -Compress | Write-Output + exit 0 + } + + if ($Stage) { + $def = Get-InstallStage -Name $Stage + if (-not $def) { + $err = @{ + ok = $false + stage = $Stage + reason = "unknown stage: $Stage. Run install.ps1 -Manifest to list valid stages." + } + $err | ConvertTo-Json -Compress | Write-Output + exit 2 + } + Step-OutOfInstallDir + Invoke-Stage -StageDef $def + exit 0 + } + + # Default: full install (today's behavior, plus optional -NonInteractive + # and -Json layered on by the params above). Main } catch { + if ($Json -or $Stage) { + # Stage-driver mode: caller wants JSON they can parse. Emit a + # structured error frame and exit non-zero. + $err = @{ + ok = $false + stage = if ($Stage) { $Stage } else { $null } + reason = "$_" + } + $err | ConvertTo-Json -Compress | Write-Output + exit 1 + } + + # Interactive mode: keep today's friendly recovery hint. Write-Host "" Write-Err "Installation failed: $_" Write-Host "" diff --git a/scripts/tests/test-install-ps1-stage-protocol.ps1 b/scripts/tests/test-install-ps1-stage-protocol.ps1 new file mode 100644 index 00000000000..4e2e7ea256e --- /dev/null +++ b/scripts/tests/test-install-ps1-stage-protocol.ps1 @@ -0,0 +1,134 @@ +# Smoke tests for the install.ps1 stage protocol. +# +# Run from a PowerShell prompt: +# +# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1 +# +# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest, +# unknown -Stage handling). They DO NOT actually run any install stages — +# those have heavy side effects (winget, git clone, pip install, PATH writes) +# and are out of scope for a unit smoke test. All three metadata commands +# below return without invoking Main / Invoke-AllStages. +# +# To exercise real install stages, drive the script from a clean VM. + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) +$installScript = Join-Path $repoRoot "scripts\install.ps1" + +if (-not (Test-Path $installScript)) { + throw "Could not locate install.ps1 at $installScript" +} + +$failures = 0 +function Assert-Equal { + param([Parameter(Mandatory=$true)] $Expected, + [Parameter(Mandatory=$true)] $Actual, + [Parameter(Mandatory=$true)] [string]$Label) + if ($Expected -ne $Actual) { + Write-Host "FAIL: $Label" -ForegroundColor Red + Write-Host " expected: $Expected" + Write-Host " actual: $Actual" + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} +function Assert-True { + param([Parameter(Mandatory=$true)] $Condition, + [Parameter(Mandatory=$true)] [string]$Label) + if (-not $Condition) { + Write-Host "FAIL: $Label" -ForegroundColor Red + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} + +# ----------------------------------------------------------------------------- +# Test: -ProtocolVersion emits a single integer +# ----------------------------------------------------------------------------- +Write-Host "" +Write-Host "-- -ProtocolVersion --" +$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion +Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0" +Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)" + +# ----------------------------------------------------------------------------- +# Test: -Manifest emits valid JSON with expected shape +# ----------------------------------------------------------------------------- +Write-Host "" +Write-Host "-- -Manifest --" +$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest +Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0" + +$manifest = $null +try { + $manifest = $manifestJson | ConvertFrom-Json + Assert-True $true -Label "-Manifest output parses as JSON" +} catch { + Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)" +} + +if ($manifest) { + Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) ` + -Label "manifest.protocol_version is an integer" + Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty" + + # Every stage has the four required fields + $allValid = $true + foreach ($stage in $manifest.stages) { + foreach ($field in @("name", "title", "category", "needs_user_input")) { + if (-not ($stage.PSObject.Properties.Name -contains $field)) { + Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red + $allValid = $false + } + } + } + Assert-True $allValid -Label "every stage has name/title/category/needs_user_input" + + # Specific stage names that the GUI driver will rely on + $names = $manifest.stages | ForEach-Object { $_.name } + foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) { + Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'" + } + + # The two known-interactive stages must declare needs_user_input + $interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name } + Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input" + Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input" +} + +# ----------------------------------------------------------------------------- +# Test: unknown stage name -> exit 2, structured JSON error +# ----------------------------------------------------------------------------- +Write-Host "" +Write-Host "-- -Stage with unknown name --" +$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist" +Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2" + +$errFrame = $null +try { + $errFrame = $errOutput | ConvertFrom-Json + Assert-True $true -Label "unknown-stage output parses as JSON" +} catch { + Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)" +} + +if ($errFrame) { + Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false" + Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name" + Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why" +} + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +Write-Host "" +if ($failures -gt 0) { + Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red + exit 1 +} else { + Write-Host "All smoke tests passed." -ForegroundColor Green + exit 0 +}