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 <name>    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 <name>` 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 <branch>/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)
This commit is contained in:
emozilla 2026-05-17 00:47:21 -04:00 committed by Teknium
parent ea2ee51f0b
commit e5f19af2a5
2 changed files with 736 additions and 130 deletions

File diff suppressed because it is too large Load diff

View file

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