feat(install.ps1): write .hermes-bootstrap-complete marker at end of install

The desktop app's main.cjs resolver ladder has a 'bootstrap-needed' rung
that fires when .hermes-bootstrap-complete is missing from
ACTIVE_HERMES_ROOT. Pre-Hermes-Setup, this marker was written by the
packaged-desktop's own bootstrap-runner.cjs at the end of its install
flow. Now that Hermes-Setup.exe runs install.ps1 directly, install.ps1
needs to own the marker — otherwise the desktop sees no marker on first
launch and triggers its legacy first-launch bootstrap (re-running
install.ps1 from inside Electron, the exact recursion Hermes-Setup.exe
was supposed to obviate).

Implementation:
  * New Stage-BootstrapMarker (worker) → Write-BootstrapMarker (helper)
  * Slotted in the manifest right after platform-sdks, before the
    interactive configure/gateway stages, so it runs unconditionally
    when the install reaches the finalize phase
  * Schema mirrors apps/desktop/electron/main.cjs writeBootstrapMarker /
    isBootstrapComplete EXACTLY: {schemaVersion: 1, pinnedCommit,
    pinnedBranch, completedAt}. Schema version stays at 1 so old
    desktops that read marker files written by future install.ps1s
    can still parse them.
  * pinnedCommit comes from -Commit flag (Hermes-Setup.exe passes it)
    or falls back to 'git rev-parse HEAD' in InstallDir
  * pinnedBranch from -Branch flag, defaults to 'main' matching
    install.ps1's own param default

Two PS-5.1 gotchas baked into comments:
  * The ?. null-conditional operator doesn't exist pre-PS7; use
    explicit if-checks on Get-Command results
  * Set-Content -Encoding UTF8 emits a BOM in 5.1 and Node's plain
    JSON.parse rejects BOM — write via .NET's UTF8Encoding(false)
    to produce BOM-less JSON the desktop's readJson() can parse
This commit is contained in:
emozilla 2026-05-28 13:31:44 -04:00
parent 060c4f64a8
commit a4cfc8b740

View file

@ -1524,6 +1524,83 @@ function Set-PathVariable {
Write-Success "hermes command ready"
}
function Write-BootstrapMarker {
# Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes
# desktop app (apps/desktop/electron/main.cjs) "install.ps1 ran
# successfully — DON'T trigger the legacy first-launch bootstrap
# runner."
#
# Schema mirrors what main.cjs's writeBootstrapMarker() / isBootstrap
# Complete() expect. Keep this in lockstep when either side changes:
# apps/desktop/electron/main.cjs lines 1199-1222
# BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 (line 187)
#
# Pinned commit/branch come from -Commit + -Branch flags (passed by
# Hermes-Setup.exe) or fall back to whatever git resolves in the
# checkout. The desktop validates schemaVersion + pinnedCommit
# length but doesn't enforce that HEAD matches the pin (users
# update via `hermes update` which moves HEAD legitimately).
if (-not (Test-Path $InstallDir)) {
Write-Warn "Skipping bootstrap marker: $InstallDir doesn't exist"
return
}
# Resolve the pinned commit: explicit -Commit wins, otherwise read
# the checkout's HEAD via git. If git can't run, leave commit empty
# and the marker will fail desktop validation (pinnedCommit.length
# >= 7) — better to be invalid than wrong.
$pinnedCommit = $Commit
if (-not $pinnedCommit) {
# PS 5.1 doesn't support the ?. null-conditional operator, so
# check Get-Command's result explicitly before reading .Source.
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
$gitExe = if ($gitCmd) { $gitCmd.Source } else { $null }
if ($gitExe) {
Push-Location $InstallDir
try {
$resolved = & $gitExe rev-parse HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $resolved) {
$pinnedCommit = $resolved.Trim()
}
} catch {
# Ignore — pinnedCommit stays empty, marker stays invalid,
# desktop falls through to its legacy bootstrap path.
} finally {
Pop-Location
}
}
}
$pinnedBranch = $Branch
if (-not $pinnedBranch) {
$pinnedBranch = "main" # install.ps1's own default for -Branch
}
$markerPath = Join-Path $InstallDir ".hermes-bootstrap-complete"
$marker = [ordered]@{
schemaVersion = 1
pinnedCommit = $pinnedCommit
pinnedBranch = $pinnedBranch
completedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
# desktopVersion field intentionally omitted — only the desktop
# app knows its own version, and the marker validator doesn't
# require it. The desktop fills it in if/when it writes its
# own marker (e.g. after a future in-app upgrade).
}
$json = $marker | ConvertTo-Json -Compress:$false
# Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8`
# always emits a BOM, and Node's plain JSON.parse rejects the BOM as an
# unexpected character — so a BOM'd marker would silently fail the
# desktop's readJson(), make isBootstrapComplete() return null, and the
# desktop would re-run the legacy bootstrap runner anyway. Defeats the
# whole point. Use the .NET API directly for BOM-less UTF-8.
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($markerPath, $json, $utf8NoBom)
Write-Success "Bootstrap marker written: $markerPath"
}
function Copy-ConfigTemplates {
Write-Info "Setting up configuration files..."
@ -2376,6 +2453,7 @@ $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" }
@ -2416,6 +2494,7 @@ function Stage-Desktop { 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 }