diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 017d5ac8af..4049679220 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -304,6 +304,11 @@ function Stage-Gateway { & (Join-Path $InstallDir "venv\Scripts\python.exe") (Join-Path $InstallDir "hermes") gateway install } +function Stage-Desktop { + Install-DesktopVoiceDeps + Install-Desktop +} + function Stage-Complete { $commit = $Commit if (-not $commit) { $commit = git -C $InstallDir rev-parse HEAD 2>$null } @@ -983,242 +988,6 @@ function Write-Completion { } } -# ============================================================================ -# 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. -# -# ============================================================================ - -# 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 = "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" } - # Managed Python lives under $InstallDir\.hermes-runtime, so the checkout - # must exist before this stage creates that directory. Otherwise the later - # repository stage treats the runtime-only directory as a broken checkout, - # parks it, and leaves Stage-Venv with no managed interpreter. - @{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" } - @{ 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" } -) -if ($IncludeDesktop) { - # Insert AFTER node-deps so workspace npm is already installed when - # the desktop build runs. Inserted only when explicitly requested - # (Hermes-Setup.exe), never via the irm|iex CLI one-liner. - $InstallStages += @{ Name = "desktop"; Title = "Building desktop app"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Desktop" } -} -$InstallStages += @( - @{ 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" } - @{ Name = "bootstrap-marker"; Title = "Marking install complete"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-BootstrapMarker" } - # 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)) { - if ($script:GitInstallFailureReason) { throw $script:GitInstallFailureReason } - throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" - } -} -# Node is optional (browser tools degrade gracefully without it). Surface -# failure to the JSON contract as skipped=true / reason rather than ok=true, -# so a GUI driver consuming the manifest can distinguish "node ready" from -# "node missing". Install flow continues either way -- matches the -# existing Write-Completion behavior that prints a "Note: Node.js could -# not be installed" hint instead of aborting. -function Stage-Node { - if (-not (Test-Node)) { - $script:_StageSkippedReason = "Node.js not available; browser tools will be unavailable until node is installed manually from https://nodejs.org/en/download/" - } -} -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-Desktop { Install-DesktopVoiceDeps; Install-Desktop } -function Stage-Path { Set-PathVariable } -function Stage-ConfigTemplates { Copy-ConfigTemplates } -function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } -function Stage-BootstrapMarker { Write-BootstrapMarker } -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. Harmless when the - # caller ran the installer from somewhere else. - try { - $currentResolved = (Get-Location).ProviderPath - $installResolved = $null - if (Test-Path $InstallDir) { - $installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath - } - if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) { - Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..." - Set-Location $env:USERPROFILE - } - } catch {} -} - -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 - - # Per-stage soft-skip channel. A worker can populate - # $script:_StageSkippedReason to surface "ran, but the thing it was - # supposed to set up is not available" as skipped=true in the JSON - # frame, without throwing. Used by Stage-Node so the install flow - # doesn't abort when an optional capability is missing while still - # being honest in the protocol contract. Reset before each stage so - # a prior stage's reason can never leak into a later stage's frame. - $script:_StageSkippedReason = $null - - $start = [DateTime]::UtcNow - $result = @{ - stage = $StageDef.Name - ok = $false - skipped = $false - reason = $null - duration_ms = 0 - } - - try { - & $StageDef.Worker - $result.ok = $true - if ($script:_StageSkippedReason) { - $result.skipped = $true - $result.reason = $script:_StageSkippedReason - } - } 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 - # Tell the entry-point catch that we've already emitted a - # frame for this failure (when $result.ok = $false), so it - # doesn't double-emit a second JSON object and break the - # one-line-per-stage contract the driver protocol promises. - if (-not $result.ok) { - $script:_StageEmittedErrorFrame = $true - } - } - $markerPath = Join-Path $InstallDir ".hermes-bootstrap-complete" - $tmp = "$markerPath.tmp" - $marker | ConvertTo-Json | Set-Content -Path $tmp -Encoding utf8 - Move-Item -Force $tmp $markerPath - } - Log "install complete. Run: hermes" -} function Invoke-StageByName([string]$name) { switch ($name) {