5071 lines
240 KiB
PowerShell
5071 lines
240 KiB
PowerShell
# ============================================================================
|
||
# Hermes Agent Installer for Windows
|
||
# ============================================================================
|
||
# Installation script for Windows (PowerShell).
|
||
# Uses uv for fast Python provisioning and package management.
|
||
#
|
||
# Usage:
|
||
# iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||
#
|
||
# Or download and run with options:
|
||
# .\install.ps1 -NoVenv -SkipSetup
|
||
#
|
||
# ============================================================================
|
||
|
||
param(
|
||
[switch]$NoVenv,
|
||
[switch]$SkipSetup,
|
||
[switch]$SkipComputerUse,
|
||
[string]$Branch = "aiturk/main",
|
||
# -Commit and -Tag are higher-precedence variants of -Branch for users
|
||
# who need reproducible installs (desktop installer pinning, CI, release
|
||
# bundles). When set, the repository stage clones $Branch (faster than
|
||
# cloning the full default-branch history) and then `git checkout`s the
|
||
# exact ref. Precedence: Commit > Tag > Branch.
|
||
[string]$Commit = "",
|
||
# Apply -Commit even when it would roll an existing install BACKWARDS.
|
||
# Without this the repository stage skips a pin that is already an ancestor
|
||
# of HEAD, so a stale baked-in BUILD_PIN_COMMIT can't downgrade a current
|
||
# checkout. Reproducible/CI installs that genuinely want an older SHA on an
|
||
# existing tree pass -ForceCommit.
|
||
[switch]$ForceCommit,
|
||
[string]$Tag = "",
|
||
[string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }),
|
||
[string]$InstallDir = $(if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$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,
|
||
|
||
# Print the paths this install would use, as JSON, and exit without
|
||
# touching anything. The first question on any "installer says a path
|
||
# doesn't exist" report is which paths it actually resolved -- especially
|
||
# on profiles Windows exposes through an 8.3 alias, where what the user
|
||
# sees in Explorer and what the installer receives differ.
|
||
#
|
||
# powershell -File install.ps1 -ShowResolvedPaths
|
||
[switch]$ShowResolvedPaths,
|
||
|
||
# --- Ensure mode (dep_ensure.py entry point) ---
|
||
[string]$Ensure = "",
|
||
[switch]$PostInstall,
|
||
|
||
# --- Desktop GUI build (opt-in) ---
|
||
# When set, install.ps1 includes Stage-Desktop in the manifest and
|
||
# builds apps/desktop into a launchable Hermes.exe.
|
||
#
|
||
# Why opt-in:
|
||
# * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes
|
||
# -IncludeDesktop so a user who installed via the GUI ends up
|
||
# with a launchable desktop binary.
|
||
# * The Electron desktop's own bootstrap-runner.ts runs install.ps1
|
||
# from inside an already-launched Hermes.exe; if THAT recursively
|
||
# built apps/desktop it would try to overwrite the live Hermes.exe
|
||
# on disk and fail. The recursive path omits the flag.
|
||
# * The canonical CLI one-liner (irm | iex) omits the flag too;
|
||
# terminal users don't need a desktop binary built for them, and
|
||
# `hermes desktop` already builds on demand.
|
||
[switch]$IncludeDesktop
|
||
)
|
||
|
||
$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.
|
||
}
|
||
|
||
# ============================================================================
|
||
# 8.3 short-path normalization
|
||
# ============================================================================
|
||
# Windows generates an 8.3 short alias for a user-profile folder whose name
|
||
# contains a space ("First Last" -> FIRST~1.LAS), a dot ("Stone.ZEN8" ->
|
||
# STONE~1.ZEN), or an accented character ("Ruben" spelled with an acute e ->
|
||
# RUBN~1). It can then expose %TEMP%, %TMP%, %LOCALAPPDATA%, %APPDATA% and
|
||
# %USERPROFILE% -- plus everything derived from them, including the default
|
||
# HERMES_HOME and InstallDir -- in that short form:
|
||
# C:\Users\FIRST~1.LAS\AppData\Local\Temp
|
||
#
|
||
# PowerShell's FileSystem provider mishandles the aliased component when such a
|
||
# path reaches a provider cmdlet (`Tee-Object -FilePath`, `Out-File`,
|
||
# `New-Item`, `Test-Path`), throwing "An object at the specified path
|
||
# C:\Users\FIRST~1.LAS does not exist" -- localized on non-English hosts.
|
||
# Every Node/Electron stage streams its build log to %TEMP% via Tee-Object and
|
||
# the desktop stage probes the binary it produced under the profile-derived
|
||
# InstallDir, so the bootstrap aborts even though the artifact built fine.
|
||
# The Python/uv stages, which never hand a %TEMP% path to a provider cmdlet,
|
||
# sail through -- which is why the failure looks Node-specific.
|
||
#
|
||
# Expanding every profile-rooted path back to long form once, up front, lets
|
||
# every downstream cmdlet and child process see something the provider can
|
||
# resolve. Three resolvers, tried in order, because no single one covers every
|
||
# host:
|
||
#
|
||
# 1. kernel32!GetLongPathNameW -- expands any 8.3 component regardless of
|
||
# locale, including the accented-username aliases the COM resolver misses.
|
||
# 2. Scripting.FileSystemObject -- fallback for hosts where P/Invoke is
|
||
# blocked.
|
||
# 3. Profile-root substitution -- when the volume has 8.3 generation disabled
|
||
# or the alias is stale, neither resolver can expand the name because it
|
||
# no longer maps to anything on disk. The aliased component is always the
|
||
# profile folder itself (everything below it was created long), so swap in
|
||
# a profile root we can prove is long and reattach the tail.
|
||
#
|
||
# All three degrade to returning the input untouched, so a host where none of
|
||
# them apply -- including non-Windows -- behaves exactly as it did before.
|
||
|
||
$script:LongProfileRoot = $null
|
||
|
||
function Write-PathDiag {
|
||
# Diagnostics for this block go to stderr, never stdout: the stage protocol
|
||
# hands drivers a single line of JSON on stdout and a stray note would break
|
||
# anything parsing it.
|
||
#
|
||
# Suppressed entirely under -ShowResolvedPaths, which is a machine-readable
|
||
# query: Windows PowerShell 5.1 wraps any native-command stderr in a
|
||
# NativeCommandError and folds it back into the caller's own stream, so a
|
||
# child writing here at all is enough to corrupt a 5.1 caller's capture.
|
||
# The JSON already carries everything these lines say.
|
||
#
|
||
# [Console]::Error.WriteLine specifically -- verified reaching a caller on a
|
||
# windows-latest runner. $host.UI.WriteErrorLine was tried and silently
|
||
# produced nothing there under a non-interactive host.
|
||
param([string]$Message)
|
||
if ($ShowResolvedPaths) { return }
|
||
[Console]::Error.WriteLine("[hermes] $Message")
|
||
}
|
||
|
||
function Get-LongProfileRoot {
|
||
# The user's profile directory in long form, or '' when every source we
|
||
# can reach is itself aliased. Cached: this runs per env var.
|
||
if ($null -ne $script:LongProfileRoot) { return $script:LongProfileRoot }
|
||
$script:LongProfileRoot = ''
|
||
|
||
# %USERPROFILE% first: it is what the rest of the install derives from, and
|
||
# on a host handing us aliased paths the .NET known-folder lookup tends to
|
||
# be aliased in exactly the same way. Then the HOMEDRIVE/HOMEPATH pair, then
|
||
# the profile's parent (C:\Users never carries an alias) plus %USERNAME%,
|
||
# which stays the long account name even when every path is short.
|
||
$envProfile = [Environment]::GetEnvironmentVariable('USERPROFILE')
|
||
$shellProfile = [Environment]::GetFolderPath('UserProfile')
|
||
$candidates = @($envProfile, $shellProfile, "$env:HOMEDRIVE$env:HOMEPATH")
|
||
foreach ($anchor in @($envProfile, $shellProfile)) {
|
||
if ($anchor -and $env:USERNAME) {
|
||
$parent = Split-Path -Parent $anchor.TrimEnd('\', '/')
|
||
if ($parent) { $candidates += (Join-Path $parent $env:USERNAME) }
|
||
}
|
||
}
|
||
|
||
foreach ($candidate in $candidates) {
|
||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||
# Trailing separators make Split-Path -Parent return the directory
|
||
# itself, which would silently break the ancestry check downstream.
|
||
$candidate = $candidate.TrimEnd('\', '/')
|
||
if (-not $candidate) { continue }
|
||
if ($candidate -match '~\d') { continue }
|
||
try {
|
||
if (Test-Path -LiteralPath $candidate -PathType Container) {
|
||
$script:LongProfileRoot = $candidate
|
||
break
|
||
}
|
||
} catch {
|
||
# Unreadable candidate (denied, malformed): try the next one.
|
||
}
|
||
}
|
||
|
||
# Say which root we landed on. When someone reports "still broken" this is
|
||
# the first thing worth knowing, and it costs one line on the rare path
|
||
# where an alias actually showed up.
|
||
if ($script:LongProfileRoot) {
|
||
Write-PathDiag "long profile root: $script:LongProfileRoot"
|
||
} else {
|
||
Write-PathDiag "no long profile root found; 8.3 paths left as-is (tried: $($candidates -join ', '))"
|
||
}
|
||
return $script:LongProfileRoot
|
||
}
|
||
|
||
function Expand-ShortProfileRoot {
|
||
# Rebuild $Path onto a known-long profile root when its aliased component
|
||
# is the profile folder. Returns $Path unchanged when it isn't, so a custom
|
||
# TEMP on another volume (D:\SHORT~1\Temp) is never rewritten.
|
||
param([string]$Path)
|
||
|
||
$longRoot = Get-LongProfileRoot
|
||
if (-not $longRoot) { return $Path }
|
||
$longRootParent = Split-Path -Parent $longRoot
|
||
if (-not $longRootParent) { return $Path }
|
||
|
||
$node = $Path
|
||
$tail = ''
|
||
while ($node -and ($node -match '~\d')) {
|
||
$leaf = Split-Path -Leaf $node
|
||
$parent = Split-Path -Parent $node
|
||
if (-not $parent) { return $Path }
|
||
if ($leaf -match '~\d') {
|
||
# Candidate profile folder. Only substitute when it sits in the
|
||
# same directory as the real profile (both C:\Users).
|
||
if ($parent -ne $longRootParent) { return $Path }
|
||
if ($tail) { return (Join-Path $longRoot $tail) }
|
||
return $longRoot
|
||
}
|
||
$tail = if ($tail) { Join-Path $leaf $tail } else { $leaf }
|
||
$node = $parent
|
||
}
|
||
return $Path
|
||
}
|
||
|
||
function ConvertTo-LongPath {
|
||
param([string]$Path)
|
||
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
|
||
# Only 8.3 short names carry a tilde+digit ("~1"); skip every resolver for
|
||
# ordinary long paths, which is the overwhelmingly common case.
|
||
if ($Path -notmatch '~\d') {
|
||
$script:LastResolver = 'skipped-long-path'
|
||
return $Path
|
||
}
|
||
|
||
# 1. kernel32. Compiled on first use only, so a normal profile never pays
|
||
# the Add-Type cost (this file is re-entered once per install stage).
|
||
try {
|
||
if (-not ([System.Management.Automation.PSTypeName]'HermesInstall.LongPath').Type) {
|
||
Add-Type -Namespace 'HermesInstall' -Name 'LongPath' -MemberDefinition @'
|
||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||
public static extern int GetLongPathNameW(string lpszShortPath, System.Text.StringBuilder lpszLongPath, int cchBuffer);
|
||
'@
|
||
}
|
||
$buffer = New-Object System.Text.StringBuilder 4096
|
||
$length = [HermesInstall.LongPath]::GetLongPathNameW($Path, $buffer, $buffer.Capacity)
|
||
if ($length -gt $buffer.Capacity) {
|
||
$buffer = New-Object System.Text.StringBuilder $length
|
||
$length = [HermesInstall.LongPath]::GetLongPathNameW($Path, $buffer, $buffer.Capacity)
|
||
}
|
||
if ($length -gt 0) {
|
||
$expanded = $buffer.ToString()
|
||
if ($expanded -and $expanded -notmatch '~\d') {
|
||
$script:LastResolver = 'kernel32'
|
||
return $expanded
|
||
}
|
||
}
|
||
} catch {
|
||
# Not Windows, or P/Invoke denied by policy: try the next resolver.
|
||
}
|
||
|
||
# 2. COM. Validate the result the same way the kernel32 branch does: this
|
||
# resolver can report success and still hand back a path that carries the
|
||
# alias (observed on a windows-latest runner, where it "resolved"
|
||
# C:\Users\FIRST~1.LAS\... to itself). Accepting that silently is what let a
|
||
# short path reach the provider cmdlets in the first place, so an
|
||
# unexpanded result counts as failure and falls through.
|
||
try {
|
||
$fso = New-Object -ComObject Scripting.FileSystemObject
|
||
$resolved = $null
|
||
if ($fso.FolderExists($Path)) { $resolved = $fso.GetFolder($Path).Path }
|
||
elseif ($fso.FileExists($Path)) { $resolved = $fso.GetFile($Path).Path }
|
||
if ($resolved -and $resolved -notmatch '~\d') {
|
||
$script:LastResolver = 'com'
|
||
return $resolved
|
||
}
|
||
} catch {
|
||
# COM unavailable / locked-down host: try the next resolver.
|
||
}
|
||
|
||
# 3. The alias resolves to nothing. Rebuild from a long profile root.
|
||
$rebuilt = Expand-ShortProfileRoot $Path
|
||
$script:LastResolver = if ($rebuilt -ne $Path) { 'profile-root' } else { 'none' }
|
||
return $rebuilt
|
||
}
|
||
|
||
function Set-LongProfileEnvVars {
|
||
# Normalize every profile-rooted variable the install reads, not just
|
||
# %TEMP%: the desktop stage derives InstallDir from %LOCALAPPDATA%, and a
|
||
# short root there fails the post-build probe after a successful build.
|
||
# Returns $true when anything was rewritten.
|
||
$rewrote = $false
|
||
$script:NormalizedPathRewrites = @{}
|
||
foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) {
|
||
$current = [Environment]::GetEnvironmentVariable($name)
|
||
if (-not $current) { continue }
|
||
$expanded = ConvertTo-LongPath $current
|
||
if ($expanded -and $expanded -ne $current) {
|
||
Set-Item -Path "Env:$name" -Value $expanded
|
||
$rewrote = $true
|
||
$script:NormalizedPathRewrites[$name] = $expanded
|
||
# Rewriting a profile path is rare and corrective; say so. Every
|
||
# report of this bug class arrived as a bare "does not exist" with
|
||
# no hint that a short alias was involved. stderr, so the stage
|
||
# protocol's stdout JSON stays parseable.
|
||
Write-PathDiag "expanded 8.3 short path in %$name%: $current -> $expanded"
|
||
}
|
||
}
|
||
return $rewrote
|
||
}
|
||
|
||
# ConvertTo-LongPath only assigns $script:LastResolver when a ~\d short path
|
||
# actually needs expansion, so an ordinary long profile leaves it unset -- and
|
||
# the ResolvedPathReport below reads it unconditionally, which is fatal under
|
||
# Set-StrictMode before any stage starts. 'none' is the resolver's own value
|
||
# for "nothing ran".
|
||
$script:LastResolver = 'none'
|
||
$script:NormalizedProfilePaths = Set-LongProfileEnvVars
|
||
|
||
# Re-derive the install paths now that the env vars behind their defaults are
|
||
# long. An explicitly passed -HermesHome / -InstallDir is normalized in place
|
||
# rather than replaced, so a caller's choice is never overwritten by a default.
|
||
# $PSBoundParameters is only meaningful at script scope, so this stays inline.
|
||
if ($PSBoundParameters.ContainsKey('HermesHome')) {
|
||
$HermesHome = ConvertTo-LongPath $HermesHome
|
||
} else {
|
||
$HermesHome = ConvertTo-LongPath $(
|
||
if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }
|
||
)
|
||
}
|
||
if ($PSBoundParameters.ContainsKey('InstallDir')) {
|
||
$InstallDir = ConvertTo-LongPath $InstallDir
|
||
} else {
|
||
$InstallDir = ConvertTo-LongPath $(
|
||
if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }
|
||
)
|
||
}
|
||
if ($script:NormalizedProfilePaths) {
|
||
# Which paths the install actually settled on. Absent from every report of
|
||
# this bug class, and the whole question once a short alias is in play.
|
||
Write-PathDiag "resolved install paths: HermesHome=$HermesHome InstallDir=$InstallDir"
|
||
}
|
||
|
||
# Captured here, where the values are final, and emitted from the entry-point
|
||
# dispatch at the bottom (alongside -ProtocolVersion / -Manifest) so
|
||
# -ShowResolvedPaths exits before any stage runs.
|
||
#
|
||
# The report goes to STDOUT as JSON: on Windows a child's stderr does not
|
||
# reliably reach a parent process -- three separate capture mechanisms each came
|
||
# back empty on a windows-latest runner while stdout arrived intact -- and the
|
||
# first question on any "installer says a path doesn't exist" report is which
|
||
# paths it actually resolved.
|
||
$script:ResolvedPathReport = @{
|
||
long_profile_root = (Get-LongProfileRoot)
|
||
normalized = $script:NormalizedPathRewrites
|
||
resolver = $script:LastResolver
|
||
temp = $env:TEMP
|
||
hermes_home = $HermesHome
|
||
install_dir = $InstallDir
|
||
}
|
||
|
||
# ============================================================================
|
||
# Configuration
|
||
# ============================================================================
|
||
|
||
$RepoUrlSsh = "https://gitea.twinpay.one/yilsem/aiturk-hermes-ide.git"
|
||
$RepoUrlHttps = "https://gitea.twinpay.one/yilsem/aiturk-hermes-ide.git"
|
||
$PythonVersion = "3.11"
|
||
# Minor versions the installer accepts when the requested $PythonVersion isn't
|
||
# available, in preference order. Only checkout-private uv-managed interpreters
|
||
# are eligible. Single source of truth shared by Test-Python's fallback and
|
||
# Resolve-AvailablePythonVersion.
|
||
$PythonFallbackVersions = @("3.12", "3.13", "3.10")
|
||
$PythonFindTimeoutMs = 30000
|
||
$NodeVersion = "22"
|
||
# The npm range the root package.json pins in `engines.npm`. A constant rather
|
||
# than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo
|
||
# is cloned, so there is usually no package.json on disk yet (and none at all
|
||
# when install.ps1 is piped straight from the web). Keep this fallback in sync
|
||
# with package.json; Get-NpmRange prefers the manifest once a checkout exists.
|
||
$NpmRange = "<11.10.0 || >=11.17.0"
|
||
|
||
# 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
|
||
|
||
# Return the real OS processor architecture as a lowercase string suitable for
|
||
# Node.js / electron download URL slugs: "arm64", "x64", or "x86".
|
||
#
|
||
# Why not just trust [Environment]::Is64BitOperatingSystem or
|
||
# [RuntimeInformation]::OSArchitecture? On Windows on ARM, when this script
|
||
# is invoked from Windows PowerShell 5.1 (the default `powershell.exe`) or
|
||
# any x64 PowerShell host, the process runs under Prism x64 emulation and
|
||
# BOTH of those APIs report `X64` -- they describe the emulated view, not
|
||
# the real OS. We've seen this concretely on Snapdragon X1 hardware: an
|
||
# ARM64-based Surface Laptop returns OSArchitecture=X64 from an emulated
|
||
# PowerShell session.
|
||
#
|
||
# Win32_Processor.Architecture is invariant to emulation. Values:
|
||
# 0=x86, 5=ARM, 9=AMD64/x64, 12=ARM64. We fall back to
|
||
# PROCESSOR_ARCHITEW6432 (set on WoW64 with the real OS arch) and then
|
||
# PROCESSOR_ARCHITECTURE so we still produce a sensible answer if CIM
|
||
# isn't available (locked-down WMI, container, etc.).
|
||
function Get-WindowsArch {
|
||
try {
|
||
$proc = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop |
|
||
Select-Object -First 1
|
||
switch ([int]$proc.Architecture) {
|
||
12 { return "arm64" }
|
||
9 { return "x64" }
|
||
0 { return "x86" }
|
||
5 { return "arm" }
|
||
}
|
||
} catch {
|
||
# CIM unavailable -- fall through to env-var path
|
||
}
|
||
|
||
$envArch = if ($env:PROCESSOR_ARCHITEW6432) {
|
||
$env:PROCESSOR_ARCHITEW6432
|
||
} else {
|
||
$env:PROCESSOR_ARCHITECTURE
|
||
}
|
||
switch ($envArch) {
|
||
"ARM64" { return "arm64" }
|
||
"AMD64" { return "x64" }
|
||
"x86" { return "x86" }
|
||
default {
|
||
# Last-resort: respect 64-bitness so we don't ship a 32-bit
|
||
# toolchain to anyone.
|
||
if ([Environment]::Is64BitOperatingSystem) { return "x64" } else { return "x86" }
|
||
}
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
|
||
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 ""
|
||
}
|
||
|
||
function Write-Info {
|
||
param([string]$Message)
|
||
Write-Host "-> $Message" -ForegroundColor Cyan
|
||
}
|
||
|
||
function Write-Success {
|
||
param([string]$Message)
|
||
Write-Host "[OK] $Message" -ForegroundColor Green
|
||
}
|
||
|
||
function Write-Warn {
|
||
param([string]$Message)
|
||
Write-Host "[!] $Message" -ForegroundColor Yellow
|
||
}
|
||
|
||
function Write-Err {
|
||
param([string]$Message)
|
||
Write-Host "[X] $Message" -ForegroundColor Red
|
||
}
|
||
|
||
function Invoke-NativeWithRelaxedErrorAction {
|
||
param([scriptblock]$Script)
|
||
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
try {
|
||
& $Script
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
}
|
||
}
|
||
function Discard-LockfileChurn {
|
||
param([string]$Repo = $InstallDir)
|
||
|
||
if (-not $Repo -or -not (Test-Path (Join-Path $Repo ".git"))) { return }
|
||
|
||
try {
|
||
$diff = & git -c windows.appendAtomically=false -C $Repo diff --name-only 2>$null
|
||
if ($LASTEXITCODE -ne 0 -or -not $diff) { return }
|
||
|
||
$dirtyPackageDirs = [System.Collections.Generic.HashSet[string]]::new(
|
||
[System.StringComparer]::OrdinalIgnoreCase
|
||
)
|
||
foreach ($path in $diff) {
|
||
if ($path -like "*package.json") {
|
||
$null = $dirtyPackageDirs.Add((Split-Path $path -Parent))
|
||
}
|
||
}
|
||
|
||
$dirtyLocks = [System.Collections.Generic.List[string]]::new()
|
||
foreach ($path in $diff) {
|
||
if ($path -notlike "*package-lock.json") { continue }
|
||
$lockDir = Split-Path $path -Parent
|
||
if ($dirtyPackageDirs.Contains($lockDir)) { continue }
|
||
$dirtyLocks.Add($path)
|
||
}
|
||
|
||
if ($dirtyLocks.Count -eq 0) { return }
|
||
& git -c windows.appendAtomically=false -C $Repo checkout -- @($dirtyLocks) 2>$null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Info "Discarded npm lockfile churn ($($dirtyLocks.Count) file(s))"
|
||
}
|
||
} catch {
|
||
# Best-effort only; never let cleanup block the installer update path.
|
||
}
|
||
}
|
||
# Inspect npm output for a TLS-trust failure and, if found, print actionable
|
||
# remediation. npm/Node surface corporate MITM proxies and missing root CAs as
|
||
# "unable to get local issuer certificate" / "self-signed certificate in
|
||
# certificate chain" / UNABLE_TO_GET_ISSUER_CERT_LOCALLY -- most commonly while
|
||
# Electron's install.js postinstall downloads the Electron binary. The reporter
|
||
# usually misreads this as an admin-rights or generic install failure (see
|
||
# issue #38016), so detect it once here and route every npm stage through this
|
||
# hint. Returns $true when a cert error was detected (caller may adjust its own
|
||
# messaging), $false otherwise.
|
||
function Show-NpmCertHint {
|
||
param([string]$NpmOutput)
|
||
if (-not $NpmOutput) { return $false }
|
||
$isCertError = $NpmOutput -match "unable to get local issuer certificate" `
|
||
-or $NpmOutput -match "self.signed certificate" `
|
||
-or $NpmOutput -match "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" `
|
||
-or $NpmOutput -match "SELF_SIGNED_CERT_IN_CHAIN" `
|
||
-or $NpmOutput -match "CERT_HAS_EXPIRED"
|
||
if (-not $isCertError) { return $false }
|
||
Write-Warn "This looks like a TLS certificate-trust failure, not a permissions problem."
|
||
Write-Info " A corporate proxy or antivirus is likely intercepting HTTPS and presenting a"
|
||
Write-Info " certificate Node.js doesn't trust. To fix, point Node at your org's root CA:"
|
||
Write-Info " 1. Get the corporate root CA as a .pem/.crt from your IT team."
|
||
Write-Info " 2. setx NODE_EXTRA_CA_CERTS `"C:\path\to\corp-ca.pem`""
|
||
Write-Info " 3. Open a NEW terminal (so the env var takes effect) and re-run the installer."
|
||
Write-Info " Quick (less secure) alternative -- disable TLS verification just for the install:"
|
||
Write-Info " npm config set strict-ssl false (re-enable afterwards: npm config set strict-ssl true)"
|
||
return $true
|
||
}
|
||
|
||
function Write-NpmDebugLogTail {
|
||
# On failure npm prints only a terse summary to stdout/stderr; the real
|
||
# evidence (postinstall script stderr like Electron's install.js, network
|
||
# traces, EBUSY retries) lives in npm's own debug log under
|
||
# <npm-cache>\_logs\<timestamp>-debug-0.log. The bootstrap installer's
|
||
# streaming sink only captures what WE emit, so on any npm failure this
|
||
# helper locates that debug log and replays its tail into our output
|
||
# stream -- making the bootstrap log a self-contained diagnosis instead
|
||
# of "exit 1, details in a file on a VM nobody can reach".
|
||
param(
|
||
[string]$NpmOutput,
|
||
[int]$TailLines = 200
|
||
)
|
||
$logPath = $null
|
||
# Preferred: npm names the exact file in its failure summary.
|
||
if ($NpmOutput -and $NpmOutput -match "A complete log of this run can be found in:\s*(?<path>[^\r\n]+)") {
|
||
$candidate = $Matches['path'].Trim()
|
||
if (Test-Path -LiteralPath $candidate) { $logPath = $candidate }
|
||
}
|
||
# Fallback (covers --silent runs, truncated output): newest debug log in
|
||
# npm's cache _logs directory.
|
||
if (-not $logPath) {
|
||
try {
|
||
$npm = Resolve-NpmCmd
|
||
if ($npm) {
|
||
$prevEAPLocal = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
$cacheDir = (& $npm config get cache 2>$null | Select-Object -Last 1)
|
||
$ErrorActionPreference = $prevEAPLocal
|
||
if ($cacheDir) {
|
||
$logsDir = Join-Path ("$cacheDir").Trim() "_logs"
|
||
if (Test-Path -LiteralPath $logsDir) {
|
||
$newest = Get-ChildItem -LiteralPath $logsDir -Filter "*-debug-*.log" -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||
if ($newest) { $logPath = $newest.FullName }
|
||
}
|
||
}
|
||
}
|
||
} catch { }
|
||
}
|
||
if (-not $logPath) {
|
||
Write-Warn "npm debug log could not be located -- no further npm detail available"
|
||
return
|
||
}
|
||
$tail = $null
|
||
try {
|
||
$tail = Get-Content -LiteralPath $logPath -Tail $TailLines -ErrorAction Stop
|
||
} catch {
|
||
Write-Warn "Could not read npm debug log ${logPath}: $($_.Exception.Message)"
|
||
return
|
||
}
|
||
Write-Warn "---- npm debug log: last $TailLines lines of $logPath ----"
|
||
foreach ($line in $tail) { Write-Host " $line" -ForegroundColor DarkGray }
|
||
Write-Warn "---- end npm debug log ----"
|
||
}
|
||
|
||
# --- Ensure-mode helpers ---
|
||
|
||
function Resolve-NpmCmd {
|
||
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
|
||
if (-not $npmCmd) { return $null }
|
||
$npmExe = $npmCmd.Source
|
||
if ($npmExe -like "*.ps1") {
|
||
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
|
||
if (Test-Path $npmCmdSibling) { return $npmCmdSibling }
|
||
}
|
||
return $npmExe
|
||
}
|
||
|
||
function Find-SystemBrowser {
|
||
# Honor ONLY an explicit, user-set AGENT_BROWSER_EXECUTABLE_PATH override.
|
||
#
|
||
# We no longer scan well-known install locations for a system browser.
|
||
# Auto-detection silently bound the install to an arbitrary binary instead
|
||
# of the bundled Playwright Chromium, which made the browser tool behave
|
||
# differently across hosts (and, on Linux, picked up a sandboxed Snap
|
||
# Chromium that hangs every browser_navigate). Every install now uses the
|
||
# bundled Chromium unless the user explicitly points elsewhere.
|
||
$override = $env:AGENT_BROWSER_EXECUTABLE_PATH
|
||
if ([string]::IsNullOrWhiteSpace($override)) { return $null }
|
||
if (Test-Path $override) { return $override }
|
||
return $null
|
||
}
|
||
|
||
function Write-BrowserEnv {
|
||
param([string]$BrowserPath)
|
||
if (-not (Test-Path $HermesHome)) {
|
||
New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null
|
||
}
|
||
$envFile = Join-Path $HermesHome ".env"
|
||
if (-not (Test-Path $envFile)) {
|
||
Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
|
||
return
|
||
}
|
||
$content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue
|
||
if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return }
|
||
Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
|
||
}
|
||
|
||
function Install-AgentBrowser {
|
||
$npm = Resolve-NpmCmd
|
||
if (-not $npm) {
|
||
Write-Err "npm not found -- install Node.js first"
|
||
throw "npm not found"
|
||
}
|
||
|
||
# agent-browser itself is intentionally NOT installed here (#43564 /
|
||
# PR #44772 review): it resolves lazily via `npx agent-browser` instead,
|
||
# which every consumer (tools/browser_tool.py, `hermes update`'s npx
|
||
# cache warm) already goes through. Eagerly npm-installing a second,
|
||
# separately version-pinned copy here -- only reachable via this
|
||
# explicit -Ensure browser fallback in the first place -- was redundant
|
||
# complexity and an extra credential/supply-chain surface for a path
|
||
# npx already covers.
|
||
Write-Info "Installing camofox browser server..."
|
||
$prefixDir = Join-Path $HermesHome "node"
|
||
if (-not (Test-Path $prefixDir)) {
|
||
New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null
|
||
}
|
||
$npmLog = [System.IO.Path]::GetTempFileName()
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
& $npm install -g --prefix $prefixDir --silent --ignore-scripts "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null
|
||
$npmExit = $LASTEXITCODE
|
||
$ErrorActionPreference = $prevEAP
|
||
if ($npmExit -ne 0) {
|
||
$npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue
|
||
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
|
||
Write-Err "npm install -g failed (exit $npmExit): $npmDetail"
|
||
Show-NpmCertHint $npmDetail | Out-Null
|
||
# This install runs with --silent, so $npmDetail is often near-empty;
|
||
# npm's debug log is the only place the real error survives.
|
||
Write-NpmDebugLogTail -NpmOutput $npmDetail
|
||
throw "npm install failed"
|
||
}
|
||
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
|
||
|
||
$sysBrowser = Find-SystemBrowser
|
||
if ($sysBrowser) {
|
||
Write-BrowserEnv -BrowserPath $sysBrowser
|
||
Write-Info "Explicit browser override set -- Chromium download will be skipped when agent-browser installs on demand"
|
||
}
|
||
Write-Success "Agent-browser ready"
|
||
}
|
||
|
||
# ============================================================================
|
||
# Dependency checks
|
||
# ============================================================================
|
||
|
||
# Resolve the PowerShell host executable used to spawn child PowerShell
|
||
# processes (the astral uv installer below). We must NOT hardcode the bare
|
||
# name `powershell`: it names *Windows PowerShell* and only resolves when its
|
||
# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+
|
||
# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare
|
||
# `powershell` spawn dies with "The term 'powershell' is not recognized",
|
||
# aborting uv installation (field report: Windows install stuck, uv install
|
||
# failed with exactly that message). Prefer the absolute path of the host we
|
||
# are already running in (PATH-independent), then fall back to whichever of
|
||
# powershell/pwsh is resolvable, and only then to the bare name.
|
||
function Get-PowerShellHostExe {
|
||
try {
|
||
$hostExe = (Get-Process -Id $PID).Path
|
||
if ($hostExe -and (Test-Path $hostExe)) {
|
||
$leaf = Split-Path $hostExe -Leaf
|
||
# Only trust the current host when it is a real PowerShell CLI
|
||
# (not e.g. powershell_ise.exe or an embedded host that can't take
|
||
# `-ExecutionPolicy`/`-Command`).
|
||
if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe }
|
||
}
|
||
} catch { }
|
||
foreach ($candidate in @("powershell", "pwsh")) {
|
||
$cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue |
|
||
Select-Object -First 1
|
||
if ($cmd -and $cmd.Source) { return $cmd.Source }
|
||
}
|
||
# Last-ditch: hand back the bare name so the spawn surfaces its own error.
|
||
return "powershell"
|
||
}
|
||
|
||
function Install-Uv {
|
||
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there --
|
||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||
# place, so install.ps1 and `hermes update` stay in sync.
|
||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||
|
||
if (Test-Path $managedUv) {
|
||
$script:UvCmd = $managedUv
|
||
$version = & $managedUv --version
|
||
Write-Success "Managed uv found ($version)"
|
||
return $true
|
||
}
|
||
|
||
Write-Info "Installing managed uv into $HermesHome\bin ..."
|
||
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
|
||
|
||
# UV_INSTALL_DIR tells the astral installer to place the binary
|
||
# directly into $HermesHome\bin instead of ~/.local/bin.
|
||
$prevEAP = $ErrorActionPreference
|
||
try {
|
||
$ErrorActionPreference = "Continue"
|
||
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
||
# Spawn via the resolved host exe (see Get-PowerShellHostExe) rather
|
||
# than a bare `powershell`, which isn't guaranteed to be on PATH under
|
||
# PowerShell 7 / pwsh-only setups.
|
||
$psHostExe = Get-PowerShellHostExe
|
||
|
||
# Rungs 1 + 2: run the uv installer -- astral.sh first, then the
|
||
# byte-identical copy published on GitHub releases. Corporate
|
||
# proxies and AV products frequently block astral.sh while
|
||
# github.com is reachable (issue #69216), so a second source turns
|
||
# a hard failure into a working install. Capture the installer
|
||
# output (Tee-Object) instead of discarding it: when every source
|
||
# fails, the real error (download blocked, AV quarantine,
|
||
# permissions) must reach the user instead of only the generic
|
||
# "installed but not found" message.
|
||
$installerOutput = @()
|
||
$astralOut = @()
|
||
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Tee-Object -Variable astralOut | Out-Null
|
||
$installerOutput += "--- uv installer source: astral.sh ---"
|
||
$installerOutput += @($astralOut | ForEach-Object { "$_" })
|
||
if (Test-Path $managedUv) {
|
||
Write-Info "uv installer succeeded via astral.sh"
|
||
} else {
|
||
Write-Info "astral.sh uv installer did not produce $managedUv; trying GitHub releases mirror ..."
|
||
$ghOut = @()
|
||
& $psHostExe -ExecutionPolicy ByPass -c "irm https://github.com/astral-sh/uv/releases/latest/download/uv-installer.ps1 | iex" 2>&1 | Tee-Object -Variable ghOut | Out-Null
|
||
$installerOutput += "--- uv installer source: GitHub releases ---"
|
||
$installerOutput += @($ghOut | ForEach-Object { "$_" })
|
||
if (Test-Path $managedUv) {
|
||
Write-Info "uv installer succeeded via GitHub releases"
|
||
}
|
||
}
|
||
|
||
# Rung 3: salvage an existing uv.exe. When the installer cannot run
|
||
# at all (network fully blocked) but a working uv already exists --
|
||
# on PATH, or at ~/.local/bin (the astral default location when
|
||
# UV_INSTALL_DIR was ignored by an older installer) -- copy it into
|
||
# the managed location so the managed-first invariant holds
|
||
# (hermes_cli/managed_uv.py looks only at $HermesHome\bin\uv.exe).
|
||
if (-not (Test-Path $managedUv)) {
|
||
$existingUv = $null
|
||
$uvOnPath = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue |
|
||
Select-Object -First 1
|
||
if ($uvOnPath -and $uvOnPath.Source -and (Test-Path $uvOnPath.Source)) {
|
||
$existingUv = $uvOnPath.Source
|
||
}
|
||
if (-not $existingUv) {
|
||
$defaultUv = Join-Path $env:USERPROFILE ".local\bin\uv.exe"
|
||
if (Test-Path $defaultUv) { $existingUv = $defaultUv }
|
||
}
|
||
if ($existingUv) {
|
||
Write-Info "Salvaging existing uv from $existingUv"
|
||
try {
|
||
Copy-Item $existingUv $managedUv -Force
|
||
# Verify the salvaged binary actually runs before
|
||
# trusting it as the managed uv.
|
||
$null = & $managedUv --version
|
||
} catch {
|
||
Write-Info "Existing uv at $existingUv could not be salvaged: $_"
|
||
Remove-Item $managedUv -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
}
|
||
|
||
$ErrorActionPreference = $prevEAP
|
||
|
||
if (Test-Path $managedUv) {
|
||
$script:UvCmd = $managedUv
|
||
$version = & $managedUv --version
|
||
Write-Success "Managed uv installed ($version)"
|
||
return $true
|
||
}
|
||
|
||
Write-Err "uv installed but not found at $managedUv"
|
||
if ($installerOutput.Count -gt 0) {
|
||
Write-Info "uv installer output (last 15 lines):"
|
||
$installerOutput | Select-Object -Last 15 | ForEach-Object { Write-Info " $_" }
|
||
}
|
||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||
return $false
|
||
} catch {
|
||
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")
|
||
}
|
||
|
||
# npm lifecycle scripts on Windows spawn ``cmd.exe /d /s /c node <script>``.
|
||
# PowerShell can resolve ``node`` via Get-Command while the child cmd process
|
||
# still sees a PATH without node.exe's directory (nvm4w shims, App Paths
|
||
# aliases, stale cross-process PATH). Prepend the resolved node.exe parent
|
||
# directory so postinstall hooks (electron-winstaller, native modules, etc.)
|
||
# can find ``node``. Regression for #48130.
|
||
function Ensure-NodeExeOnPath {
|
||
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
|
||
if (-not $nodeCmd) { return $false }
|
||
|
||
$nodeExeDir = Split-Path $nodeCmd.Source -Parent
|
||
if (-not $nodeExeDir) { return $false }
|
||
|
||
$pathParts = $env:Path -split ";"
|
||
if ($pathParts -notcontains $nodeExeDir) {
|
||
$env:Path = "$nodeExeDir;$env:Path"
|
||
}
|
||
return $true
|
||
}
|
||
|
||
# Put the Hermes-managed Node dir at the FRONT of the persisted User PATH.
|
||
#
|
||
# Appending is not enough: it leaves a pre-existing system Node ahead of the
|
||
# bundled one in every new shell, so anything launched without a curated
|
||
# environment (a standalone hermes-setup.exe run, a user typing `npm`) silently
|
||
# resolves the wrong Node. Bundled must win.
|
||
#
|
||
# Move-to-front rather than add-if-missing, because installs made by an older
|
||
# install.ps1 already have this dir in User PATH -- at the tail. An
|
||
# add-if-missing check sees it present and leaves the broken ordering in place
|
||
# forever, so the very users the ordering bug hurt would never be repaired.
|
||
#
|
||
# Unrelated entries keep their relative order, including empty segments (a
|
||
# trailing ';' is legal and common in a real User PATH; Install-Git's splitting
|
||
# preserves them too, so this must not quietly rewrite them). Duplicate
|
||
# occurrences of the managed dir collapse into the single leading entry.
|
||
# PowerShell's -ne is case-insensitive for strings, which is the right
|
||
# comparison on Windows. Persists only when the resulting string differs, so
|
||
# an already-correct PATH costs one registry read and no write.
|
||
function Set-ManagedNodeFirstOnUserPath {
|
||
param([string]$NodeDir)
|
||
|
||
if (-not $NodeDir) { return }
|
||
|
||
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||
$items = if ($userPath) { @($userPath -split ";") } else { @() }
|
||
|
||
$rest = @($items | Where-Object { $_ -ne $NodeDir })
|
||
$updated = (@($NodeDir) + $rest) -join ";"
|
||
|
||
if ($updated -ne $userPath) {
|
||
[Environment]::SetEnvironmentVariable("Path", $updated, "User")
|
||
}
|
||
}
|
||
|
||
# The npm range to install into the managed Node tree. Prefers the checkout's
|
||
# root package.json so the installer and the manifest cannot drift; falls back
|
||
# to the $NpmRange constant, which is the common case here because Test-Node
|
||
# runs before the repo is cloned.
|
||
function Get-NpmRange {
|
||
$manifest = Join-Path $InstallDir "package.json"
|
||
if (Test-Path $manifest) {
|
||
try {
|
||
$engines = (Get-Content $manifest -Raw | ConvertFrom-Json).engines
|
||
if ($engines -and $engines.npm) { return [string]$engines.npm }
|
||
} catch { }
|
||
}
|
||
return $NpmRange
|
||
}
|
||
|
||
# Convert the numeric core of an npm version or range operand into a stable
|
||
# three-component System.Version. npm reports semantic versions, but the
|
||
# installer only needs the numeric core for the comparator ranges authored in
|
||
# package.json (for example, <11.10.0 || >=11.17.0).
|
||
function ConvertTo-NpmVersion {
|
||
param([string]$Version)
|
||
|
||
if (-not $Version) { return $null }
|
||
|
||
$core = ($Version.Trim() -replace '^v', '' -replace '-.*$', '')
|
||
$parts = @($core -split '\.')
|
||
if ($parts.Count -lt 1 -or $parts.Count -gt 3) { return $null }
|
||
foreach ($part in $parts) {
|
||
if ($part -notmatch '^\d+$') { return $null }
|
||
}
|
||
while ($parts.Count -lt 3) { $parts += '0' }
|
||
|
||
try {
|
||
return [version]($parts -join '.')
|
||
} catch {
|
||
return $null
|
||
}
|
||
}
|
||
|
||
# Evaluate the comparator-only npm ranges used by the root manifest and the
|
||
# pre-clone fallback. Alternatives are separated with || and each alternative
|
||
# may contain one or more whitespace-separated <, <=, >, or >= comparators.
|
||
# Unknown range syntax fails closed so an incompatible system npm cannot reach
|
||
# npm ci and fail later with EBADENGINE.
|
||
function Test-NpmVersionOk {
|
||
param(
|
||
[string]$Version,
|
||
[string]$Range = (Get-NpmRange)
|
||
)
|
||
|
||
$actual = ConvertTo-NpmVersion $Version
|
||
if (-not $actual -or -not $Range) { return $false }
|
||
|
||
foreach ($alternative in @($Range -split '\s*\|\|\s*')) {
|
||
$clause = $alternative.Trim()
|
||
if (-not $clause) { continue }
|
||
|
||
$comparators = [regex]::Matches(
|
||
$clause,
|
||
'(?:^|\s)(<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(?=\s|$)'
|
||
)
|
||
if ($comparators.Count -eq 0) { continue }
|
||
|
||
$remainder = [regex]::Replace(
|
||
$clause,
|
||
'(?:^|\s)(?:<=|>=|<|>)\s*\d+(?:\.\d+){0,2}(?=\s|$)',
|
||
''
|
||
).Trim()
|
||
if ($remainder) { continue }
|
||
|
||
$matchesClause = $true
|
||
foreach ($comparator in $comparators) {
|
||
$target = ConvertTo-NpmVersion $comparator.Groups[2].Value
|
||
if (-not $target) {
|
||
$matchesClause = $false
|
||
break
|
||
}
|
||
|
||
$matchesComparator = switch ($comparator.Groups[1].Value) {
|
||
'<' { $actual -lt $target }
|
||
'<=' { $actual -le $target }
|
||
'>' { $actual -gt $target }
|
||
'>=' { $actual -ge $target }
|
||
default { $false }
|
||
}
|
||
if (-not $matchesComparator) {
|
||
$matchesClause = $false
|
||
break
|
||
}
|
||
}
|
||
|
||
if ($matchesClause) { return $true }
|
||
}
|
||
|
||
return $false
|
||
}
|
||
|
||
# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange when
|
||
# needed. Managed Node trees survive updates, so their bundled npm can drift
|
||
# outside a newer root package.json engine range. The repo .npmrc sets
|
||
# `engine-strict=true`, making that mismatch fatal at the first `npm ci`.
|
||
# Provision the right npm here instead of reacting to EBADENGINE later.
|
||
#
|
||
# Three details are load-bearing, mirroring _nb_ensure_bundled_npm_range in
|
||
# scripts/lib/node-bootstrap.sh and upgrade_managed_npm in
|
||
# hermes_cli/npm_engine.py:
|
||
# - a temp cwd, so the checkout's own .npmrc (engine-strict,
|
||
# min-release-age) does not gate the very upgrade meant to satisfy it;
|
||
# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
|
||
# - an explicit --prefix at the managed tree, so the upgrade rewrites the
|
||
# tree's own npm rather than installing a second copy elsewhere.
|
||
#
|
||
# Best-effort: a failure leaves a working Node with an old npm, which beats no
|
||
# Node at all, and npm_engine.py still covers the EBADENGINE that follows.
|
||
function Update-ManagedNpm {
|
||
param([string]$NodeDir)
|
||
|
||
$npmCmd = Join-Path $NodeDir "npm.cmd"
|
||
if (-not (Test-Path $npmCmd)) { return $false }
|
||
|
||
$range = Get-NpmRange
|
||
|
||
# Skip the network round-trip when the bundled npm already satisfies the
|
||
# same range used by the system-Node acceptance gate.
|
||
try {
|
||
$have = (& $npmCmd --version 2>$null | Select-Object -First 1)
|
||
if ($have -and (Test-NpmVersionOk $have $range)) { return $true }
|
||
} catch { }
|
||
|
||
# In-app updates run while the desktop app's Node processes are alive.
|
||
# The managed npm lives inside the very tree they execute from, so an
|
||
# in-place upgrade would hit WinError 5 (Access denied) on npm.cmd
|
||
# (#80926). Defer; the next update with the app closed retries.
|
||
if (Test-ManagedNodeInUse $NodeDir) {
|
||
Write-Warn "Hermes-managed Node.js is in use by a running app; skipping the bundled npm upgrade (applies on a later update with the app closed)."
|
||
return $false
|
||
}
|
||
|
||
Write-Info "Upgrading bundled npm to satisfy $range ..."
|
||
|
||
$tmpCwd = Join-Path $env:TEMP ("hermes-npm-upgrade-" + [Guid]::NewGuid().ToString("N"))
|
||
New-Item -ItemType Directory -Force -Path $tmpCwd | Out-Null
|
||
$prevAge = $env:npm_config_min_release_age
|
||
$prevCI = $env:CI
|
||
$prevEAP = $ErrorActionPreference
|
||
Push-Location $tmpCwd
|
||
try {
|
||
$env:npm_config_min_release_age = "0"
|
||
$env:CI = "1"
|
||
# Relax EAP=Stop so npm's stderr lines don't get wrapped as
|
||
# ErrorRecords and short-circuit before $LASTEXITCODE is checked.
|
||
# Same pattern as Install-Uv.
|
||
$ErrorActionPreference = "Continue"
|
||
& $npmCmd install --global --prefix $NodeDir "npm@$range" `
|
||
--no-fund --no-audit --progress=false 2>&1 | Out-Null
|
||
$exit = $LASTEXITCODE
|
||
} catch {
|
||
$exit = 1
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
Pop-Location
|
||
$env:npm_config_min_release_age = $prevAge
|
||
$env:CI = $prevCI
|
||
Remove-Item -Recurse -Force $tmpCwd -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
if ($exit -ne 0) {
|
||
Write-Warn "Could not upgrade bundled npm to $range -- ``npm ci`` may fail with EBADENGINE."
|
||
Write-Info "Fix manually: npm install -g --prefix `"$NodeDir`" npm@`"$range`""
|
||
return $false
|
||
}
|
||
|
||
Write-Success "npm $(& $npmCmd --version 2>$null) installed"
|
||
return $true
|
||
}
|
||
|
||
function Test-ManagedNodeInUse {
|
||
param([string]$NodeDir)
|
||
# Windows locks files that running processes execute from. During an
|
||
# in-app update the desktop app's Node processes may hold the managed
|
||
# tree open, and rewriting it then fails with WinError 5 (Access denied)
|
||
# on npm.cmd (#80926). Cheap pre-check used to skip destructive steps;
|
||
# the rename/move itself remains the authoritative guard.
|
||
#
|
||
# Check the executable path AND the command line: a cmd.exe wrapper
|
||
# running npm.cmd from the tree reports its own exe (cmd.exe lives in
|
||
# System32) while the tree path appears only in the command line.
|
||
# Win32_Process.CommandLine is available on Windows PowerShell 5.1 and
|
||
# 7+ (the Get-Process .CommandLine ETS property is 7.4+ only), and a
|
||
# single CIM query beats a per-process property access loop.
|
||
return @(
|
||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||
Where-Object {
|
||
($_.ExecutablePath -like "$NodeDir\*") -or
|
||
($_.CommandLine -like "*$NodeDir*")
|
||
}
|
||
).Count -gt 0
|
||
}
|
||
|
||
# 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 the managed location.
|
||
# Throws if uv is not findable -- the caller's stage then surfaces a
|
||
# clean error via the stage-driver's try/catch.
|
||
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.
|
||
}
|
||
|
||
# Check the managed location first -- this is where Install-Uv puts it.
|
||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||
if (Test-Path $managedUv) {
|
||
$script:UvCmd = $managedUv
|
||
return
|
||
}
|
||
|
||
# Fall back to PATH (covers edge cases where the installer ran in a
|
||
# sibling process and HERMES_HOME wasn't propagated).
|
||
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
|
||
}
|
||
|
||
throw "uv is not installed. Run install.ps1 -Stage uv first."
|
||
}
|
||
|
||
function Initialize-ManagedPythonEnvironment {
|
||
# Python used by Hermes belongs to the checkout, never to another
|
||
# application or a user-level uv configuration. Keep this aligned with
|
||
# hermes_cli.managed_uv.managed_python_env(), which owns the update path.
|
||
foreach ($name in @(
|
||
"CONDA_DEFAULT_ENV", "CONDA_PREFIX", "UV_PROJECT_ENVIRONMENT",
|
||
"UV_NO_MANAGED_PYTHON", "UV_PYTHON", "UV_PYTHON_DOWNLOADS",
|
||
"UV_SYSTEM_PYTHON", "VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH"
|
||
)) {
|
||
Remove-Item -Path "Env:$name" -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
$managedRoot = Join-Path $InstallDir ".hermes-runtime\python"
|
||
New-Item -ItemType Directory -Force -Path $managedRoot | Out-Null
|
||
$env:UV_MANAGED_PYTHON = "1"
|
||
$env:UV_NO_CONFIG = "1"
|
||
$env:UV_PYTHON_INSTALL_BIN = "0"
|
||
$env:UV_PYTHON_INSTALL_DIR = $managedRoot
|
||
$env:UV_PYTHON_INSTALL_REGISTRY = "0"
|
||
return [System.IO.Path]::GetFullPath($managedRoot)
|
||
}
|
||
|
||
function Resolve-AvailablePythonVersion {
|
||
# Return the path and minor version of the first Hermes-managed interpreter
|
||
# uv can find, preferring the requested version and then fallback minors.
|
||
# System and application-owned interpreters are deliberately ineligible.
|
||
#
|
||
# Under Hermes-Setup.exe each stage runs in a fresh powershell.exe. The
|
||
# venv stage therefore re-resolves both version and provenance rather than
|
||
# relying on state selected by the earlier Python stage (#50769).
|
||
[string]$managedRoot = Initialize-ManagedPythonEnvironment
|
||
$managedPrefix = $managedRoot.TrimEnd('\') + '\'
|
||
$candidates = @($PythonVersion) + $PythonFallbackVersions
|
||
$seen = @{}
|
||
foreach ($ver in $candidates) {
|
||
if (-not $ver -or $seen.ContainsKey($ver)) { continue }
|
||
$seen[$ver] = $true
|
||
$process = $null
|
||
try {
|
||
# PowerShell 5.1 can lose a nested native command's stdout when
|
||
# this installer itself is redirected by the desktop bootstrapper.
|
||
# Capture uv directly through ProcessStartInfo instead of relying
|
||
# on the native-command pipeline for the interpreter path.
|
||
$process = New-Object System.Diagnostics.Process
|
||
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||
$startInfo.FileName = $UvCmd
|
||
$startInfo.Arguments = "python find $ver --managed-python --no-config"
|
||
$startInfo.UseShellExecute = $false
|
||
$startInfo.CreateNoWindow = $true
|
||
$startInfo.RedirectStandardOutput = $true
|
||
$startInfo.RedirectStandardError = $true
|
||
$process.StartInfo = $startInfo
|
||
if (-not $process.Start()) { continue }
|
||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||
if (-not $process.WaitForExit($PythonFindTimeoutMs)) {
|
||
try { $process.Kill() } catch { }
|
||
$process.WaitForExit()
|
||
throw "uv python find $ver timed out after $PythonFindTimeoutMs ms"
|
||
}
|
||
$stdout = $stdoutTask.Result
|
||
$stderrTask.Result | Out-Null
|
||
if ($process.ExitCode -ne 0) { continue }
|
||
[string]$foundPath = ($stdout.Trim() -split "`r?`n") | Select-Object -Last 1
|
||
if ($foundPath) {
|
||
$absolute = [System.IO.Path]::GetFullPath($foundPath)
|
||
if ($absolute.StartsWith($managedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||
return [PSCustomObject]@{
|
||
Path = $absolute
|
||
Version = $ver
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
throw "Failed to resolve Hermes-managed Python $ver`: $_"
|
||
} finally {
|
||
if ($process) { $process.Dispose() }
|
||
}
|
||
}
|
||
return $null
|
||
}
|
||
|
||
function Test-Python {
|
||
Initialize-ManagedPythonEnvironment | Out-Null
|
||
Write-Info "Checking Python $PythonVersion..."
|
||
|
||
# Only a checkout-private uv-managed interpreter satisfies this stage.
|
||
try {
|
||
$resolvedPython = Resolve-AvailablePythonVersion
|
||
if ($resolvedPython) {
|
||
$ver = & $resolvedPython.Path --version 2>$null
|
||
Write-Success "Python found: $ver"
|
||
return $true
|
||
}
|
||
} catch { }
|
||
|
||
# Python not found -- use uv to install it (no admin needed!)
|
||
Write-Info "Python $PythonVersion not found, installing via uv..."
|
||
# Capture EAP outside the try block so the catch's restore call always
|
||
# has a meaningful value (see Install-Uv for the full rationale).
|
||
$prevEAP = $ErrorActionPreference
|
||
try {
|
||
# Temporarily relax ErrorActionPreference: uv writes download progress
|
||
# ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to
|
||
# 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
|
||
# 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
|
||
# commit ec1714e71 and then lost in a release squash; reapplied here.
|
||
$ErrorActionPreference = "Continue"
|
||
$uvOutput = & $UvCmd python install $PythonVersion --no-bin --no-registry --no-config 2>&1
|
||
$uvExitCode = $LASTEXITCODE
|
||
$ErrorActionPreference = $prevEAP
|
||
|
||
# Check if Python is now available (more reliable than exit code
|
||
# since uv may return non-zero due to "already installed" etc.)
|
||
$resolvedPython = Resolve-AvailablePythonVersion
|
||
if ($resolvedPython) {
|
||
$ver = & $resolvedPython.Path --version 2>$null
|
||
Write-Success "Python installed: $ver"
|
||
return $true
|
||
}
|
||
|
||
# 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
|
||
}
|
||
} catch {
|
||
# Restore EAP in case the try block threw before the assignment
|
||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||
Write-Warn "uv python install error: $_"
|
||
}
|
||
|
||
# Preserve the established minor-version fallback contract, but provision
|
||
# every fallback into the same private store instead of borrowing a system
|
||
# interpreter. This path is reached only when the preferred install failed.
|
||
foreach ($fallbackVer in $PythonFallbackVersions) {
|
||
try {
|
||
Write-Info "Trying managed Python fallback $fallbackVer..."
|
||
$previousFallbackEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
& $UvCmd python install $fallbackVer --no-bin --no-registry --no-config 2>&1 | Out-Null
|
||
$ErrorActionPreference = $previousFallbackEAP
|
||
$resolvedPython = Resolve-AvailablePythonVersion
|
||
if ($resolvedPython) {
|
||
$ver = & $resolvedPython.Path --version 2>$null
|
||
Write-Success "Python fallback installed: $ver"
|
||
return $true
|
||
}
|
||
} catch {
|
||
if ($previousFallbackEAP) { $ErrorActionPreference = $previousFallbackEAP }
|
||
}
|
||
}
|
||
|
||
Write-Err "Failed to install Python $PythonVersion"
|
||
Write-Info "Check network access to uv's managed Python downloads, then retry."
|
||
return $false
|
||
}
|
||
|
||
$script:GitInstallFailureReason = $null
|
||
$script:GitBashPath = $null
|
||
$script:GitBashProbeOutput = $null
|
||
|
||
function Test-GitBashCompatibility {
|
||
<#
|
||
.SYNOPSIS
|
||
Verify that Git Bash can launch external MSYS programs, not just evaluate
|
||
shell builtins. Mandatory ASLR can allow bash.exe itself to start while
|
||
every child linked to msys-2.0.dll fails during fork/spawn.
|
||
#>
|
||
param([Parameter(Mandatory = $true)][string]$BashPath)
|
||
|
||
$script:GitBashProbeOutput = $null
|
||
if (-not (Test-Path -LiteralPath $BashPath)) {
|
||
$script:GitBashProbeOutput = "bash.exe was not found at $BashPath"
|
||
return $false
|
||
}
|
||
|
||
$process = New-Object System.Diagnostics.Process
|
||
try {
|
||
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||
$startInfo.FileName = $BashPath
|
||
$startInfo.Arguments = '--noprofile --norc -c "/usr/bin/true; /usr/bin/cat --version >/dev/null"'
|
||
$startInfo.UseShellExecute = $false
|
||
$startInfo.CreateNoWindow = $true
|
||
$startInfo.RedirectStandardOutput = $true
|
||
$startInfo.RedirectStandardError = $true
|
||
$process.StartInfo = $startInfo
|
||
|
||
if (-not $process.Start()) {
|
||
$script:GitBashProbeOutput = "bash.exe did not start"
|
||
return $false
|
||
}
|
||
if (-not $process.WaitForExit(15000)) {
|
||
try { $process.Kill() } catch { }
|
||
$script:GitBashProbeOutput = "Git Bash compatibility probe timed out"
|
||
return $false
|
||
}
|
||
|
||
$stdout = $process.StandardOutput.ReadToEnd()
|
||
$stderr = $process.StandardError.ReadToEnd()
|
||
$script:GitBashProbeOutput = ("$stdout`n$stderr").Trim()
|
||
return ($process.ExitCode -eq 0)
|
||
} catch {
|
||
$script:GitBashProbeOutput = $_.Exception.Message
|
||
return $false
|
||
} finally {
|
||
$process.Dispose()
|
||
}
|
||
}
|
||
|
||
function Test-MandatoryAslrEnabled {
|
||
<# Return true only when Windows reports system-wide ForceRelocateImages=ON. #>
|
||
try {
|
||
$cmd = Get-Command Get-ProcessMitigation -ErrorAction SilentlyContinue
|
||
if (-not $cmd) { return $false }
|
||
$mitigations = & $cmd -System
|
||
$value = $mitigations.Aslr.ForceRelocateImages
|
||
return ($null -ne $value -and $value.ToString().ToUpperInvariant() -eq "ON")
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
function Get-GitRootFromBashPath {
|
||
param([Parameter(Mandatory = $true)][string]$BashPath)
|
||
|
||
$binDir = Split-Path -Path $BashPath -Parent
|
||
if ((Split-Path -Path $binDir -Leaf) -ine "bin") {
|
||
return (Split-Path -Path $binDir -Parent)
|
||
}
|
||
|
||
$parent = Split-Path -Path $binDir -Parent
|
||
if ((Split-Path -Path $parent -Leaf) -ieq "usr") {
|
||
return (Split-Path -Path $parent -Parent)
|
||
}
|
||
return $parent
|
||
}
|
||
|
||
function New-GitBashAslrFailureReason {
|
||
param([Parameter(Mandatory = $true)][string]$BashPath)
|
||
|
||
$gitRoot = Get-GitRootFromBashPath -BashPath $BashPath
|
||
$escapedRoot = $gitRoot -replace "'", "''"
|
||
return @(
|
||
"Git Bash at $BashPath cannot launch required MSYS child processes because Windows Mandatory ASLR (ForceRelocateImages) is enabled system-wide. Reinstalling Git will not change this policy."
|
||
"Open PowerShell as Administrator and run:"
|
||
"`$gitRoot = '$escapedRoot'"
|
||
'Get-Item "$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\*.exe" -ErrorAction SilentlyContinue | ForEach-Object { Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }'
|
||
"Then rerun Hermes setup. If the override is blocked or later re-applied, ask your Windows administrator to allow this per-program exception."
|
||
) -join [Environment]::NewLine
|
||
}
|
||
|
||
function Install-Git {
|
||
<#
|
||
.SYNOPSIS
|
||
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
|
||
package manager):
|
||
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
|
||
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.
|
||
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``,
|
||
``ssh``, etc. in ``usr\bin\``.
|
||
|
||
We deliberately skip winget because it fails badly when the system Git
|
||
install is in a half-installed state (partially registered, or uninstall-
|
||
blocked). Owning the Hermes copy of Git ourselves is predictable and
|
||
recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git``
|
||
and re-running this installer fully recovers.
|
||
|
||
After install we locate ``bash.exe`` and persist the path in
|
||
``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh
|
||
shell without a second PATH refresh.
|
||
#>
|
||
$script:GitInstallFailureReason = $null
|
||
Write-Info "Checking Git..."
|
||
|
||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||
$version = git --version
|
||
Write-Success "Git found ($version)"
|
||
Set-GitBashEnvVar
|
||
if ($script:GitBashPath -and (Test-GitBashCompatibility -BashPath $script:GitBashPath)) {
|
||
Write-Success "Git Bash can launch MSYS programs"
|
||
return $true
|
||
}
|
||
|
||
if ($script:GitBashPath -and (Test-MandatoryAslrEnabled)) {
|
||
$script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath
|
||
Write-Err $script:GitInstallFailureReason
|
||
return $false
|
||
}
|
||
|
||
if ($script:GitBashPath) {
|
||
$probeDetail = if ($script:GitBashProbeOutput) { ": $script:GitBashProbeOutput" } else { "" }
|
||
Write-Warn "System Git Bash could not launch required MSYS programs$probeDetail"
|
||
} else {
|
||
Write-Warn "Git is on PATH, but its Git Bash installation could not be located."
|
||
}
|
||
Write-Info "Trying a Hermes-managed PortableGit install instead..."
|
||
}
|
||
|
||
# Download PortableGit into $HermesHome\git. Always works as long as
|
||
# 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 "(no admin rights required; isolated from any system Git install)"
|
||
|
||
try {
|
||
$arch = Get-WindowsArch
|
||
if ($arch -eq 'arm64') {
|
||
$assetTag = 'arm64'
|
||
$downloadIsZip = $false
|
||
} elseif ($arch -eq 'x64') {
|
||
$assetTag = '64-bit'
|
||
$downloadIsZip = $false
|
||
} else {
|
||
# PortableGit does not ship 32-bit / arm builds -- fall back to MinGit
|
||
# 32-bit with a warning that bash-based features will be unavailable.
|
||
$assetTag = '32-bit-mingit'
|
||
$downloadIsZip = $true
|
||
}
|
||
|
||
# Pinned git-for-windows release. We deliberately do NOT hit
|
||
# api.github.com/repos/.../releases/latest here: that endpoint
|
||
# is rate-limited to 60 requests/hour/IP for unauthenticated
|
||
# callers, and users behind CGNAT / corporate NAT / dorm WiFi
|
||
# routinely hit the limit, breaking the installer.
|
||
# Static github.com/.../releases/download/<tag>/<asset> URLs
|
||
# are not subject to the API rate limit.
|
||
$gitTag = "v2.54.0.windows.1"
|
||
$gitVer = "2.54.0"
|
||
$gitVerTag = "$gitVer.windows.1"
|
||
|
||
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."
|
||
$assetName = "MinGit-$gitVer-32-bit.zip"
|
||
$downloadIsZip = $true
|
||
} elseif ($arch -eq "arm64") {
|
||
$assetName = "PortableGit-$gitVer-arm64.7z.exe"
|
||
$downloadIsZip = $false
|
||
} else {
|
||
$assetName = "PortableGit-$gitVer-64-bit.7z.exe"
|
||
$downloadIsZip = $false
|
||
}
|
||
|
||
$downloadUrl = "https://github.com/git-for-windows/git/releases/download/$gitTag/$assetName"
|
||
$downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" }
|
||
$tmpFile = "$env:TEMP\$assetName"
|
||
$gitDir = "$HermesHome\git"
|
||
|
||
Write-Info "Downloading $assetName (Git for Windows $gitVerTag)..."
|
||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing
|
||
|
||
if (Test-Path $gitDir) {
|
||
Write-Info "Removing previous Git install at $gitDir ..."
|
||
Remove-Item -Recurse -Force $gitDir
|
||
}
|
||
New-Item -ItemType Directory -Path $gitDir -Force | Out-Null
|
||
|
||
if ($downloadIsZip) {
|
||
Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force
|
||
} else {
|
||
# PortableGit is a self-extracting 7z archive. Invoke it with
|
||
# `-o<target> -y` (silent) to extract to $gitDir. No 7z install
|
||
# required; it's fully self-contained.
|
||
Write-Info "Extracting PortableGit to $gitDir ..."
|
||
$extractProc = Start-Process -FilePath $tmpFile `
|
||
-ArgumentList "-o`"$gitDir`"", "-y" `
|
||
-NoNewWindow -Wait -PassThru
|
||
if ($extractProc.ExitCode -ne 0) {
|
||
throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))"
|
||
}
|
||
}
|
||
Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue
|
||
|
||
# PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils)
|
||
# MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present)
|
||
$gitExe = "$gitDir\cmd\git.exe"
|
||
if (-not (Test-Path $gitExe)) {
|
||
throw "Git extraction did not produce git.exe at $gitExe"
|
||
}
|
||
|
||
# Add to session PATH so the rest of this install run can use git.
|
||
$env:Path = "$gitDir\cmd;$env:Path"
|
||
|
||
# Persist to User PATH so fresh shells see it. PortableGit needs
|
||
# cmd\ (for git.exe), bin\ (for bash.exe + core tools), and
|
||
# usr\bin\ (for perl, ssh, curl, and other POSIX coreutils).
|
||
$newPathEntries = @(
|
||
"$gitDir\cmd",
|
||
"$gitDir\bin",
|
||
"$gitDir\usr\bin"
|
||
)
|
||
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
|
||
$changed = $false
|
||
foreach ($entry in $newPathEntries) {
|
||
if ($userPathItems -notcontains $entry) {
|
||
$userPathItems += $entry
|
||
$changed = $true
|
||
}
|
||
}
|
||
if ($changed) {
|
||
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
|
||
}
|
||
|
||
$version = & $gitExe --version
|
||
Write-Success "Git $version installed to $gitDir (portable, user-scoped)"
|
||
Set-GitBashEnvVar
|
||
if (-not $script:GitBashPath) {
|
||
throw "PortableGit extraction did not produce a usable bash.exe"
|
||
}
|
||
if (-not (Test-GitBashCompatibility -BashPath $script:GitBashPath)) {
|
||
if (Test-MandatoryAslrEnabled) {
|
||
$script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath
|
||
} else {
|
||
$probeDetail = if ($script:GitBashProbeOutput) { " Probe output: $script:GitBashProbeOutput" } else { "" }
|
||
$script:GitInstallFailureReason = "Git Bash at $script:GitBashPath exists but cannot launch required MSYS programs.$probeDetail"
|
||
}
|
||
throw $script:GitInstallFailureReason
|
||
}
|
||
Write-Success "Git Bash can launch MSYS programs"
|
||
return $true
|
||
} catch {
|
||
if ($script:GitInstallFailureReason) {
|
||
Write-Err $script:GitInstallFailureReason
|
||
return $false
|
||
}
|
||
Write-Err "Could not install portable Git: $_"
|
||
Write-Info ""
|
||
Write-Info "Fallback: install Git manually from https://git-scm.com/download/win"
|
||
Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run"
|
||
Write-Info "shell commands (same as Claude Code and other coding agents)."
|
||
return $false
|
||
}
|
||
}
|
||
|
||
function Set-GitBashEnvVar {
|
||
<#
|
||
.SYNOPSIS
|
||
Locate ``bash.exe`` from an already-installed Git and persist the path in
|
||
``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before
|
||
PATH propagation completes in a newly-spawned shell.
|
||
#>
|
||
$script:GitBashPath = $null
|
||
$candidates = @()
|
||
|
||
# Our own portable Git install is ALWAYS checked first, so a broken
|
||
# system Git doesn't hijack us. If the user had a working system Git
|
||
# we'd have returned early from Install-Git's fast path and never called
|
||
# this with a system-Git-only installation anyway.
|
||
#
|
||
# Layouts:
|
||
# PortableGit (our default): $HermesHome\git\bin\bash.exe
|
||
# MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe
|
||
$candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary)
|
||
$candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback
|
||
|
||
# git.exe on PATH can tell us where the install root is
|
||
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
||
if ($gitCmd) {
|
||
$gitExe = $gitCmd.Source
|
||
# Git for Windows (full installer): <root>\cmd\git.exe + <root>\bin\bash.exe
|
||
# MinGit: <root>\cmd\git.exe + <root>\usr\bin\bash.exe
|
||
$gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent
|
||
$candidates += "$gitRoot\bin\bash.exe"
|
||
$candidates += "$gitRoot\usr\bin\bash.exe"
|
||
}
|
||
|
||
# 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().
|
||
$candidates += "${env:ProgramFiles}\Git\bin\bash.exe"
|
||
$pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)")
|
||
if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" }
|
||
$candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe"
|
||
|
||
foreach ($candidate in $candidates) {
|
||
if ($candidate -and (Test-Path $candidate)) {
|
||
[Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User")
|
||
$env:HERMES_GIT_BASH_PATH = $candidate
|
||
$script:GitBashPath = $candidate
|
||
Write-Info "Set HERMES_GIT_BASH_PATH=$candidate"
|
||
return
|
||
}
|
||
}
|
||
|
||
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."
|
||
}
|
||
|
||
# The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
|
||
# Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
|
||
# requires ^22.18.0 || >=24.11.0 -- so accepting 23/25 or an early Node 24
|
||
# only defers the failure to `npm ci` under engine-strict. Keep this in sync
|
||
# with the root package.json.
|
||
function Test-NodeVersionOk {
|
||
param([string]$Version)
|
||
if ($Version -match '-') { return $false }
|
||
try {
|
||
$v = [version]($Version -replace '^v', '')
|
||
} catch {
|
||
return $false
|
||
}
|
||
if ($v.Major -eq 22) { return ($v.Minor -ge 22) }
|
||
if ($v.Major -eq 24) { return ($v.Minor -ge 11) }
|
||
return ($v.Major -ge 26)
|
||
}
|
||
|
||
# Accept a system Node only when its companion npm also satisfies the same
|
||
# range used to provision the Hermes-managed tree. Keeping this probe separate
|
||
# lets the initial PATH check and the post-winget check share one authority.
|
||
function Test-SystemNodeReady {
|
||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
|
||
|
||
$version = node --version
|
||
if (Test-NodeVersionOk $version) {
|
||
Ensure-NodeExeOnPath | Out-Null
|
||
} else {
|
||
Write-Warn "Node.js $version is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+)"
|
||
return $false
|
||
}
|
||
|
||
$npmRange = Get-NpmRange
|
||
$npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue
|
||
if (-not $npmCmd) {
|
||
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
$npmVersion = $null
|
||
if ($npmCmd) {
|
||
try {
|
||
$npmVersion = (& $npmCmd --version 2>$null | Select-Object -First 1)
|
||
} catch { }
|
||
}
|
||
|
||
if ($npmVersion -and (Test-NpmVersionOk $npmVersion $npmRange)) {
|
||
Write-Success "Node.js $version with npm $npmVersion found"
|
||
return $true
|
||
}
|
||
|
||
if ($npmVersion) {
|
||
Write-Warn "Node.js $version uses npm $npmVersion, which does not satisfy Hermes requirement $npmRange"
|
||
} else {
|
||
Write-Warn "Node.js $version was found, but npm is missing or could not report its version"
|
||
}
|
||
return $false
|
||
}
|
||
|
||
function Test-Node {
|
||
Write-Info "Checking Node.js (for browser tools)..."
|
||
|
||
if (Test-SystemNodeReady) {
|
||
$script:HasNode = $true
|
||
return $true
|
||
}
|
||
|
||
Write-Info "Using a Hermes-managed Node.js installation instead..."
|
||
|
||
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
|
||
$managedNode = "$HermesHome\node\node.exe"
|
||
if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) {
|
||
$version = & $managedNode --version
|
||
$env:Path = "$HermesHome\node;$env:Path"
|
||
Set-ManagedNodeFirstOnUserPath "$HermesHome\node"
|
||
Write-Success "Node.js $version found (Hermes-managed)"
|
||
# A tree from an older install still has that Node major's bundled
|
||
# npm, which is below the current engines.npm floor. No-ops when the
|
||
# npm is already in range, so reruns cost one --version probe.
|
||
Update-ManagedNpm "$HermesHome\node" | Out-Null
|
||
$script:HasNode = $true
|
||
return $true
|
||
}
|
||
|
||
Write-Info "Installing Hermes-managed Node.js $NodeVersion LTS..."
|
||
|
||
# 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 = Get-WindowsArch
|
||
$indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/"
|
||
$indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
|
||
$zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value
|
||
|
||
if ($zipName) {
|
||
$downloadUrl = "${indexUrl}${zipName}"
|
||
$tmpZip = "$env:TEMP\$zipName"
|
||
$tmpDir = "$env:TEMP\hermes-node-extract"
|
||
|
||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
|
||
if (Test-Path $tmpDir) { Remove-Item -Recurse -Force $tmpDir }
|
||
Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force
|
||
|
||
$extractedDir = Get-ChildItem $tmpDir -Directory | Select-Object -First 1
|
||
if ($extractedDir) {
|
||
# Rename-swap instead of delete-then-move: the live tree is
|
||
# never removed before its replacement is fully extracted.
|
||
# Windows permits renaming a tree with running executables,
|
||
# but if a process holds it without FILE_SHARE_DELETE the
|
||
# rename fails with WinError 5 -- that refusal means the tree
|
||
# is in use, so defer instead of forcing the write (#80926).
|
||
# Best-effort sweep of staging/backup litter from interrupted
|
||
# runs; locked files simply stay for the next attempt. Only
|
||
# dirs older than 10 minutes are removed so a concurrent
|
||
# heal's in-flight swap is never disturbed.
|
||
Get-ChildItem "$HermesHome" -Directory -Filter "node.old-*" -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } |
|
||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||
Get-ChildItem "$HermesHome" -Directory -Filter "node.new-*" -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } |
|
||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||
$stamp = [Guid]::NewGuid().ToString("N")
|
||
$staged = "$HermesHome\node.new-$stamp"
|
||
$backup = "$HermesHome\node.old-$stamp"
|
||
# Stage to a sibling directory so the final swap is a
|
||
# same-volume rename (atomic), not a cross-volume Move-Item
|
||
# (copy+delete, non-atomic -- a partial copy would leave a
|
||
# broken tree). Move from $env:TEMP here, rename below.
|
||
try {
|
||
Move-Item $extractedDir.FullName $staged -ErrorAction Stop
|
||
} catch {
|
||
Write-Warn "Failed to stage the new Node.js tree; aborting the Node upgrade."
|
||
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
|
||
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
|
||
return $false
|
||
}
|
||
if (Test-Path "$HermesHome\node") {
|
||
try {
|
||
Rename-Item "$HermesHome\node" $backup -ErrorAction Stop
|
||
} catch {
|
||
Write-Warn "Hermes-managed Node.js is in use by a running app; deferring its upgrade. Close the app and re-run the update."
|
||
Remove-Item -Recurse -Force $staged -ErrorAction SilentlyContinue
|
||
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
|
||
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
|
||
return $false
|
||
}
|
||
# A rename preserves LastWriteTime, so a backup renamed
|
||
# from a long-lived tree would instantly look older than
|
||
# the litter-sweep cutoff to a concurrent heal. Touch it
|
||
# (best-effort) so the in-flight backup is never swept.
|
||
try {
|
||
(Get-Item $backup).LastWriteTime = Get-Date
|
||
} catch { }
|
||
try {
|
||
Rename-Item $staged "$HermesHome\node" -ErrorAction Stop
|
||
} catch {
|
||
# Restore the live tree before bailing. The swap is a
|
||
# same-volume rename, so a failure leaves no partial
|
||
# target to clear.
|
||
Rename-Item $backup "$HermesHome\node" -ErrorAction SilentlyContinue
|
||
Remove-Item -Recurse -Force $staged -ErrorAction SilentlyContinue
|
||
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
|
||
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
|
||
return $false
|
||
}
|
||
Remove-Item -Recurse -Force $backup -ErrorAction SilentlyContinue
|
||
} else {
|
||
try {
|
||
Rename-Item $staged "$HermesHome\node" -ErrorAction Stop
|
||
} catch {
|
||
Remove-Item -Recurse -Force $staged -ErrorAction SilentlyContinue
|
||
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
|
||
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# 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. See
|
||
# Set-ManagedNodeFirstOnUserPath for why this is a
|
||
# move-to-front and not an add-if-missing.
|
||
Set-ManagedNodeFirstOnUserPath "$HermesHome\node"
|
||
|
||
$version = & "$HermesHome\node\node.exe" --version
|
||
Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)"
|
||
# The zip's bundled npm is below the repo's engines.npm floor.
|
||
Update-ManagedNpm "$HermesHome\node" | Out-Null
|
||
$script:HasNode = $true
|
||
|
||
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
|
||
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
|
||
return $true
|
||
}
|
||
}
|
||
} catch {
|
||
Write-Warn "Portable Node.js download failed: $_"
|
||
}
|
||
|
||
# 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)..."
|
||
# Capture EAP outside the try block so the catch's restore call always
|
||
# has a meaningful value (see Install-Uv for the full rationale).
|
||
$prevEAP = $ErrorActionPreference
|
||
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.
|
||
$ErrorActionPreference = "Continue"
|
||
# On ARM64, force winget to fetch the ARM64 installer. Without
|
||
# the explicit override, winget on WoW64 sometimes still resolves
|
||
# to x64 manifests, leaving us with an emulated Node toolchain
|
||
# even after a "successful" install. The OpenJS manifest does
|
||
# publish an arm64 installer, so this is safe.
|
||
$wingetArgs = @(
|
||
'install','OpenJS.NodeJS','--silent',
|
||
'--accept-package-agreements','--accept-source-agreements'
|
||
)
|
||
if ((Get-WindowsArch) -eq 'arm64') {
|
||
$wingetArgs += @('--architecture','arm64')
|
||
}
|
||
winget @wingetArgs 2>&1 | Out-Null
|
||
$ErrorActionPreference = $prevEAP
|
||
# Refresh PATH
|
||
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||
if (Test-SystemNodeReady) {
|
||
$script:HasNode = $true
|
||
return $true
|
||
}
|
||
} catch {
|
||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||
}
|
||
}
|
||
|
||
|
||
Write-Info "Install manually: https://nodejs.org/en/download/"
|
||
$script:HasNode = $false
|
||
return $true
|
||
}
|
||
|
||
function Update-ProcessPathForPackages {
|
||
# Make freshly-installed shims (rg.exe, ffmpeg.exe) visible to Get-Command in
|
||
# THIS process without spawning a new shell, by folding the persisted
|
||
# User+Machine hives plus winget's alias-shim directory into $env:Path.
|
||
# Called after every package-manager attempt (winget/choco/scoop): previously
|
||
# PATH was only refreshed inside the winget branch, so a successful
|
||
# choco/scoop fallback -- or any install on a box without winget -- could be
|
||
# misreported as "not installed".
|
||
#
|
||
# MERGE rather than overwrite: start from the existing process PATH so any
|
||
# process-only entries added earlier in this installer run survive, then
|
||
# APPEND hive/winget-Links entries not already present (case-insensitive,
|
||
# order-preserving dedupe). A wholesale replace would silently drop those
|
||
# process-only entries.
|
||
$candidates = @()
|
||
$candidates += $env:Path
|
||
$candidates += [Environment]::GetEnvironmentVariable("Path", "User")
|
||
$candidates += [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||
$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"
|
||
if (Test-Path $wingetLinks) {
|
||
$candidates += $wingetLinks
|
||
}
|
||
$seen = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)
|
||
$ordered = New-Object System.Collections.Generic.List[string]
|
||
foreach ($chunk in $candidates) {
|
||
if ([string]::IsNullOrEmpty($chunk)) { continue }
|
||
foreach ($entry in $chunk.Split(';')) {
|
||
$trimmed = $entry.Trim()
|
||
if ($trimmed -and $seen.Add($trimmed)) {
|
||
$ordered.Add($trimmed)
|
||
}
|
||
}
|
||
}
|
||
$env:Path = [string]::Join(';', $ordered)
|
||
}
|
||
|
||
function Install-SystemPackages {
|
||
$script:HasRipgrep = $false
|
||
$script:HasFfmpeg = $false
|
||
$needRipgrep = $false
|
||
$needFfmpeg = $false
|
||
|
||
Write-Info "Checking ripgrep (fast file search)..."
|
||
if (Get-Command rg -ErrorAction SilentlyContinue) {
|
||
$version = rg --version | Select-Object -First 1
|
||
Write-Success "$version found"
|
||
$script:HasRipgrep = $true
|
||
} else {
|
||
$needRipgrep = $true
|
||
}
|
||
|
||
Write-Info "Checking ffmpeg (TTS voice messages)..."
|
||
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
|
||
Write-Success "ffmpeg found"
|
||
$script:HasFfmpeg = $true
|
||
} else {
|
||
$needFfmpeg = $true
|
||
}
|
||
|
||
if (-not $needRipgrep -and -not $needFfmpeg) { return }
|
||
|
||
# Build description and package lists for each package manager
|
||
$descParts = @()
|
||
$wingetPkgs = @()
|
||
$chocoPkgs = @()
|
||
$scoopPkgs = @()
|
||
|
||
if ($needRipgrep) {
|
||
$descParts += "ripgrep for faster file search"
|
||
$wingetPkgs += "BurntSushi.ripgrep.MSVC"
|
||
$chocoPkgs += "ripgrep"
|
||
$scoopPkgs += "ripgrep"
|
||
}
|
||
if ($needFfmpeg) {
|
||
$descParts += "ffmpeg for TTS voice messages"
|
||
$wingetPkgs += "Gyan.FFmpeg"
|
||
$chocoPkgs += "ffmpeg"
|
||
$scoopPkgs += "ffmpeg"
|
||
}
|
||
|
||
$description = $descParts -join " and "
|
||
$hasWinget = Get-Command winget -ErrorAction SilentlyContinue
|
||
$hasChoco = Get-Command choco -ErrorAction SilentlyContinue
|
||
$hasScoop = Get-Command scoop -ErrorAction SilentlyContinue
|
||
|
||
# Try winget first (most common on modern Windows)
|
||
if ($hasWinget) {
|
||
Write-Info "Installing $description via winget..."
|
||
# Per-package log paths -- key the lookup by package id so we can
|
||
# decide AFTER the post-install Get-Command check whether to keep
|
||
# the log (still missing -> keep as breadcrumb) or delete it (now
|
||
# present -> happy path, no clutter).
|
||
$pkgLogs = @{}
|
||
foreach ($pkg in $wingetPkgs) {
|
||
$log = "$env:TEMP\hermes-winget-$($pkg -replace '[^A-Za-z0-9]','_')-$(Get-Random).log"
|
||
$pkgLogs[$pkg] = $log
|
||
# --source winget pins us to the github-backed source. Without this,
|
||
# a broken msstore source (cert validation failures like 0x8a15005e
|
||
# are common on Windows-on-ARM and some corporate networks) makes
|
||
# winget bail with "please specify --source" *before* attempting any
|
||
# install -- and it exits 0, so the surrounding try/catch never fires.
|
||
# We don't ship anything from msstore, so pinning is safe.
|
||
try {
|
||
$output = winget install --exact --id $pkg --source winget --silent `
|
||
--accept-package-agreements --accept-source-agreements 2>&1
|
||
$code = $LASTEXITCODE
|
||
$output | Out-File -FilePath $log -Encoding utf8
|
||
"winget exit: $code" | Out-File -FilePath $log -Encoding utf8 -Append
|
||
# 0x8A15002B (-1978335189) = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE.
|
||
# winget treats `install` on a package it already has registered as
|
||
# an *upgrade*, finds no newer version, and bails with this code --
|
||
# even when the binary is gone from disk/PATH (stale registration,
|
||
# files removed outside winget, or a missing alias shim). We KNOW the
|
||
# command was missing (that's why we're here), so a plain install
|
||
# dead-ends forever. Force a reinstall to repair the registration so
|
||
# the shim reappears.
|
||
if ($code -eq -1978335189) {
|
||
"-> already-installed/no-upgrade; retrying with --force" | Out-File -FilePath $log -Encoding utf8 -Append
|
||
$output = winget install --exact --id $pkg --source winget --silent --force `
|
||
--accept-package-agreements --accept-source-agreements 2>&1
|
||
$output | Out-File -FilePath $log -Encoding utf8 -Append
|
||
"winget exit (force): $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append
|
||
}
|
||
} catch {
|
||
$_ | Out-File -FilePath $log -Encoding utf8 -Append
|
||
"winget exit: <exception>" | Out-File -FilePath $log -Encoding utf8 -Append
|
||
}
|
||
}
|
||
# Refresh PATH so packages winget exposed via "command line aliases" in
|
||
# %LOCALAPPDATA%\Microsoft\WinGet\Links (added to PATH only in
|
||
# newly-spawned shells, not this process) are visible to Get-Command below.
|
||
Update-ProcessPathForPackages
|
||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||
Write-Success "ripgrep installed"
|
||
$script:HasRipgrep = $true
|
||
$needRipgrep = $false
|
||
Remove-Item -Path $pkgLogs["BurntSushi.ripgrep.MSVC"] -ErrorAction SilentlyContinue
|
||
} elseif ($pkgLogs.ContainsKey("BurntSushi.ripgrep.MSVC")) {
|
||
Write-Warn "winget could not install ripgrep; details: $($pkgLogs['BurntSushi.ripgrep.MSVC'])"
|
||
}
|
||
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
||
Write-Success "ffmpeg installed"
|
||
$script:HasFfmpeg = $true
|
||
$needFfmpeg = $false
|
||
Remove-Item -Path $pkgLogs["Gyan.FFmpeg"] -ErrorAction SilentlyContinue
|
||
} elseif ($pkgLogs.ContainsKey("Gyan.FFmpeg")) {
|
||
Write-Warn "winget could not install ffmpeg; details: $($pkgLogs['Gyan.FFmpeg'])"
|
||
}
|
||
if (-not $needRipgrep -and -not $needFfmpeg) { return }
|
||
}
|
||
|
||
# Fallback: choco
|
||
if ($hasChoco -and ($needRipgrep -or $needFfmpeg)) {
|
||
Write-Info "Trying Chocolatey..."
|
||
foreach ($pkg in $chocoPkgs) {
|
||
try { choco install $pkg -y 2>&1 | Out-Null } catch { }
|
||
}
|
||
Update-ProcessPathForPackages
|
||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||
Write-Success "ripgrep installed via chocolatey"
|
||
$script:HasRipgrep = $true
|
||
$needRipgrep = $false
|
||
}
|
||
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
||
Write-Success "ffmpeg installed via chocolatey"
|
||
$script:HasFfmpeg = $true
|
||
$needFfmpeg = $false
|
||
}
|
||
}
|
||
|
||
# Fallback: scoop
|
||
if ($hasScoop -and ($needRipgrep -or $needFfmpeg)) {
|
||
Write-Info "Trying Scoop..."
|
||
foreach ($pkg in $scoopPkgs) {
|
||
try { scoop install $pkg 2>&1 | Out-Null } catch { }
|
||
}
|
||
Update-ProcessPathForPackages
|
||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||
Write-Success "ripgrep installed via scoop"
|
||
$script:HasRipgrep = $true
|
||
$needRipgrep = $false
|
||
}
|
||
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
||
Write-Success "ffmpeg installed via scoop"
|
||
$script:HasFfmpeg = $true
|
||
$needFfmpeg = $false
|
||
}
|
||
}
|
||
|
||
# Show manual instructions for anything still missing
|
||
if ($needRipgrep) {
|
||
Write-Warn "ripgrep not installed (file search will use findstr fallback)"
|
||
Write-Info " winget install BurntSushi.ripgrep.MSVC"
|
||
}
|
||
if ($needFfmpeg) {
|
||
Write-Warn "ffmpeg not installed (TTS voice messages will be limited)"
|
||
Write-Info " winget install Gyan.FFmpeg"
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# Installation
|
||
# ============================================================================
|
||
|
||
function Install-Repository {
|
||
Write-Info "Installing to $InstallDir..."
|
||
|
||
$didUpdate = $false
|
||
|
||
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
|
||
# 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. Three checks
|
||
# belt-and-braces: rev-parse (work tree), git status, and a resolvable
|
||
# HEAD (an initial commit). If any fails the repo is broken and we
|
||
# fall through to a fresh clone.
|
||
$repoValid = $false
|
||
if (Test-Path "$InstallDir\.git") {
|
||
Push-Location $InstallDir
|
||
try {
|
||
# Reset $LASTEXITCODE before the probe so we don't pick up
|
||
# a stale 0 from an earlier git call in this session.
|
||
$global:LASTEXITCODE = 0
|
||
$revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1
|
||
$revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true")
|
||
|
||
$global:LASTEXITCODE = 0
|
||
$null = & git -c windows.appendAtomically=false status --short 2>&1
|
||
$statusOk = ($LASTEXITCODE -eq 0)
|
||
|
||
# An interrupted previous clone leaves a repo with NO initial
|
||
# commit. rev-parse/status still succeed there, but the update
|
||
# path's `git stash` (and later `git checkout`) abort with
|
||
# "You do not have the initial commit yet" and fail the install
|
||
# (#40998). Require a resolvable HEAD so such partial checkouts
|
||
# are treated as broken and re-cloned fresh below.
|
||
$global:LASTEXITCODE = 0
|
||
$null = & git -c windows.appendAtomically=false rev-parse --verify HEAD 2>&1
|
||
$hasCommit = ($LASTEXITCODE -eq 0)
|
||
|
||
if ($revParseOk -and $statusOk -and $hasCommit) {
|
||
$repoValid = $true
|
||
}
|
||
} catch {}
|
||
Pop-Location
|
||
}
|
||
|
||
if ($repoValid) {
|
||
Write-Info "Existing installation found, updating..."
|
||
Push-Location $InstallDir
|
||
# Wrap the entire fetch+checkout block in EAP=Continue so git's
|
||
# routine stderr output (e.g. 'From <url>' info lines emitted by
|
||
# `git fetch`) doesn't terminate the script under the global
|
||
# EAP=Stop. We rely on $LASTEXITCODE for actual failures.
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
$autostashRef = ""
|
||
try {
|
||
# This is a MANAGED checkout, not a repo the user edits. Git for
|
||
# Windows defaults to core.autocrlf=true, which renormalizes the
|
||
# repo's LF-only text files to CRLF in the working tree -- so
|
||
# tracked files (.envrc, AGENTS.md, agent/*.py, workflows, ...)
|
||
# show as locally modified even though nobody touched them. A
|
||
# bare `git checkout` then aborts with "Your local changes would
|
||
# be overwritten by checkout", which is exactly the failure GUI
|
||
# users hit on update. Pin autocrlf=false so the dirt is never
|
||
# created in the first place.
|
||
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
|
||
Discard-LockfileChurn $InstallDir
|
||
# Preserve any real local changes before the checkout instead of
|
||
# discarding them with `reset --hard HEAD`. The old hard reset
|
||
# silently destroyed agent-edited source on managed clones (the
|
||
# #38542 data-loss class). Stash + restore mirrors install.sh:
|
||
# nothing is lost, and a failed restore leaves the work in a
|
||
# git stash for manual recovery. Untracked files are included so
|
||
# agent-created dirs (e.g. tinker-atropos/) survive too.
|
||
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
|
||
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
|
||
# A previously interrupted update can leave the index with
|
||
# unmerged entries. In that state `git stash` aborts with
|
||
# "could not write index" and the following `git checkout`
|
||
# aborts with "you need to resolve your current index first"
|
||
# -- the GUI "git checkout main failed (exit 1)" install
|
||
# failure. Clear the conflict markers with `git reset` first:
|
||
# working-tree changes are kept (and stashed just below); only
|
||
# the index conflict state is dropped. Mirrors the `hermes
|
||
# update` path (#4735).
|
||
$unmergedOut = git -c windows.appendAtomically=false ls-files --unmerged 2>$null
|
||
if (-not [string]::IsNullOrWhiteSpace(($unmergedOut -join "`n"))) {
|
||
Write-Info "Clearing unmerged index entries from a previous conflict..."
|
||
git -c windows.appendAtomically=false reset -q 2>$null
|
||
}
|
||
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
|
||
Write-Info "Local changes detected, stashing before update..."
|
||
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
|
||
if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" }
|
||
}
|
||
git -c windows.appendAtomically=false fetch origin $Branch
|
||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
|
||
# Precedence: Commit > Tag > Branch. Commit and Tag check
|
||
# out as detached HEAD intentionally -- they're meant to be
|
||
# reproducible pins, not branches the user pulls into.
|
||
if ($Commit) {
|
||
# Make sure we have the commit locally (a tag-less commit
|
||
# SHA isn't always reachable from any one branch fetch).
|
||
git -c windows.appendAtomically=false fetch origin $Commit
|
||
# A commit pin must never move an existing install
|
||
# BACKWARDS. hermes-setup.exe bakes its build-time commit
|
||
# into the binary (BUILD_PIN_COMMIT) and passes it as
|
||
# -Commit on every install-mode run -- including the retry
|
||
# the desktop's "Update didn't finish" screen kicks off. An
|
||
# installer built months ago would otherwise rewind a
|
||
# current checkout to its build commit, leaving ancient
|
||
# code against a current venv (npm workspaces and Python
|
||
# deps that no longer match: the #74xxx report). Skip the
|
||
# pin when the target is already an ancestor of HEAD; a
|
||
# fresh clone has no such ancestry and pins normally.
|
||
$skipRollback = $false
|
||
if (-not $ForceCommit) {
|
||
git -c windows.appendAtomically=false merge-base --is-ancestor $Commit HEAD 2>$null
|
||
$isAncestor = ($LASTEXITCODE -eq 0)
|
||
$pinnedSha = (& git -c windows.appendAtomically=false rev-parse "$Commit^{commit}" 2>$null)
|
||
$headSha = (& git -c windows.appendAtomically=false rev-parse HEAD 2>$null)
|
||
$skipRollback = $isAncestor -and ($pinnedSha -ne $headSha)
|
||
}
|
||
if ($skipRollback) {
|
||
Write-Warn "Ignoring -Commit $Commit`: the checkout is already newer."
|
||
Write-Warn "Pinning to it would roll this install back. Pass -ForceCommit to override."
|
||
} else {
|
||
git -c windows.appendAtomically=false checkout --detach $Commit
|
||
if ($LASTEXITCODE -ne 0) { throw "git checkout $Commit failed (exit $LASTEXITCODE)" }
|
||
}
|
||
} elseif ($Tag) {
|
||
git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}"
|
||
git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag"
|
||
if ($LASTEXITCODE -ne 0) { throw "git checkout tag $Tag failed (exit $LASTEXITCODE)" }
|
||
} else {
|
||
git -c windows.appendAtomically=false checkout $Branch
|
||
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
|
||
# Managed installs should follow origin/$Branch exactly. If
|
||
# the checkout has diverged (or has local-only commits),
|
||
# ff-only pull cannot succeed -- mirror ``hermes update`` and
|
||
# reset to the fetched remote so bootstrap/install can recover.
|
||
git -c windows.appendAtomically=false pull --ff-only origin $Branch
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Warn "Fast-forward not possible; resetting managed install to origin/$Branch..."
|
||
git -c windows.appendAtomically=false reset --hard "origin/$Branch"
|
||
if ($LASTEXITCODE -ne 0) { throw "git reset --hard origin/$Branch failed (exit $LASTEXITCODE)" }
|
||
}
|
||
}
|
||
|
||
if ($autostashRef) {
|
||
# Default to restoring so work is never silently dropped.
|
||
# Only prompt when we're certain a human can answer: an
|
||
# interactive session AND a real, non-redirected console on
|
||
# both stdin and stdout. The desktop "Update" button and
|
||
# bootstrap run the installer without a usable console -- in
|
||
# those cases Read-Host would hang or return empty, so we
|
||
# skip the prompt and just restore (the safe default).
|
||
$restoreNow = $true
|
||
$hasConsole = $false
|
||
try {
|
||
$hasConsole = (
|
||
[Environment]::UserInteractive `
|
||
-and (-not [Console]::IsInputRedirected) `
|
||
-and (-not [Console]::IsOutputRedirected) `
|
||
-and ($Host.Name -eq "ConsoleHost")
|
||
)
|
||
} catch { $hasConsole = $false }
|
||
if ($hasConsole) {
|
||
Write-Warn "Local changes were stashed before updating."
|
||
Write-Warn "Restoring them may reapply local customizations onto the updated codebase."
|
||
$restoreAnswer = Read-Host "Restore local changes now? [Y/n]"
|
||
if ($restoreAnswer -match '^(n|no)$') { $restoreNow = $false }
|
||
}
|
||
|
||
if ($restoreNow) {
|
||
Write-Info "Restoring local changes..."
|
||
$restoreOutput = @(git -c windows.appendAtomically=false stash apply $autostashRef 2>&1)
|
||
$restoreExit = $LASTEXITCODE
|
||
$conflictedFiles = @(
|
||
git -c windows.appendAtomically=false diff --name-only --diff-filter=U 2>$null
|
||
) | Where-Object { $_ -and $_.ToString().Trim() }
|
||
if (($restoreExit -eq 0) -and ($conflictedFiles.Count -eq 0)) {
|
||
git -c windows.appendAtomically=false stash drop $autostashRef 2>$null
|
||
Write-Warn "Local changes were restored on top of the updated codebase."
|
||
Write-Warn "Review git diff / git status if Hermes behaves unexpectedly."
|
||
} else {
|
||
Write-Err "Update pulled new code, but restoring local changes hit conflicts."
|
||
foreach ($line in $restoreOutput) {
|
||
if ($line -and $line.ToString().Trim()) {
|
||
Write-Host $line
|
||
}
|
||
}
|
||
if ($conflictedFiles.Count -gt 0) {
|
||
Write-Host ""
|
||
Write-Host "Conflicted files:"
|
||
foreach ($file in $conflictedFiles) {
|
||
Write-Host " - $file"
|
||
}
|
||
}
|
||
Write-Host ""
|
||
Write-Info "Your stashed changes are preserved -- nothing is lost."
|
||
Write-Info " Stash ref: $autostashRef"
|
||
git -c windows.appendAtomically=false reset --hard HEAD 2>$null | Out-Null
|
||
Write-Info "Working tree reset to clean state."
|
||
Write-Info "Restore your changes later with: git stash apply $autostashRef"
|
||
}
|
||
} else {
|
||
Write-Info "Skipped restoring local changes."
|
||
Write-Info "Your changes are still preserved in git stash."
|
||
Write-Info "Restore manually with: git stash apply $autostashRef"
|
||
}
|
||
$autostashRef = ""
|
||
}
|
||
} finally {
|
||
if ($autostashRef) {
|
||
# We stashed but never reached the restore block (a fetch/
|
||
# checkout/pull failure threw). Leave the stash in place and
|
||
# tell the user how to recover it -- never silently drop it.
|
||
Write-Warn "Update did not complete. Your local changes are preserved in git stash."
|
||
Write-Info "Restore manually with: git stash apply $autostashRef"
|
||
}
|
||
$ErrorActionPreference = $prevEAP
|
||
Pop-Location
|
||
}
|
||
$didUpdate = $true
|
||
} else {
|
||
# Directory exists but isn't a usable git repo -- e.g. an
|
||
# interrupted clone with no initial commit (#40998), or a leftover
|
||
# ``.git`` stub from a partial uninstall that used to lock the
|
||
# installer into the "update" branch forever. Move it aside rather
|
||
# than deleting it -- never destroy a directory the user might still
|
||
# want -- and fall through to a fresh clone.
|
||
$backupDir = "$InstallDir.broken-" + (Get-Date -Format "yyyyMMdd-HHmmss")
|
||
Write-Warn "Existing directory at $InstallDir is not a valid git repo."
|
||
Write-Warn "Moving it aside to $backupDir before re-cloning."
|
||
try {
|
||
Move-Item -LiteralPath $InstallDir -Destination $backupDir -ErrorAction Stop
|
||
} catch {
|
||
Write-Err "Could not move $InstallDir aside : $_"
|
||
Write-Info "Close any programs that might be using files in $InstallDir (editors,"
|
||
Write-Info "terminals, running hermes processes) and try again."
|
||
throw
|
||
}
|
||
}
|
||
}
|
||
|
||
if (-not $didUpdate) {
|
||
$cloneSuccess = $false
|
||
|
||
# Fix Windows git "copy-fd: write returned: Invalid argument" error.
|
||
# Git for Windows can fail on atomic file operations (hook templates,
|
||
# config lock files) due to antivirus, OneDrive, or NTFS filter drivers.
|
||
# The -c flag injects config before any file I/O occurs.
|
||
Write-Info "Configuring git for Windows compatibility..."
|
||
$env:GIT_CONFIG_COUNT = "1"
|
||
$env:GIT_CONFIG_KEY_0 = "windows.appendAtomically"
|
||
$env:GIT_CONFIG_VALUE_0 = "false"
|
||
git config --global windows.appendAtomically false 2>$null
|
||
|
||
# Try SSH first, then HTTPS, with -c flag for atomic write fix
|
||
Write-Info "Trying SSH clone..."
|
||
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||
try {
|
||
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir }
|
||
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
||
} catch { }
|
||
$env:GIT_SSH_COMMAND = $null
|
||
|
||
if (-not $cloneSuccess) {
|
||
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
||
Write-Info "SSH failed, trying HTTPS..."
|
||
try {
|
||
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir }
|
||
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
||
} catch { }
|
||
}
|
||
|
||
# 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..."
|
||
try {
|
||
# Pick the ZIP URL for the most-specific ref the caller asked
|
||
# for. GitHub supports archive URLs for commits, tags, and
|
||
# branches; we honour Commit > Tag > Branch.
|
||
if ($Commit) {
|
||
$zipUrl = "https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/archive/$Commit.zip"
|
||
$zipLabel = $Commit
|
||
} elseif ($Tag) {
|
||
$zipUrl = "https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/archive/$Tag.zip"
|
||
$zipLabel = $Tag
|
||
} else {
|
||
$zipUrl = "https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/archive/$Branch.zip"
|
||
$zipLabel = $Branch
|
||
}
|
||
$zipPath = "$env:TEMP\hermes-agent-$zipLabel.zip"
|
||
$extractPath = "$env:TEMP\hermes-agent-extract"
|
||
|
||
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
|
||
if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath }
|
||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||
|
||
# GitHub ZIPs extract to repo-branch/ subdirectory
|
||
$extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1
|
||
if ($extractedDir) {
|
||
New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null
|
||
Move-Item $extractedDir.FullName $InstallDir -Force
|
||
Write-Success "Downloaded and extracted"
|
||
|
||
# Initialize git repo so updates work later. A bare
|
||
# `git init` leaves NO HEAD -- desktop's write-build-stamp
|
||
# then hard-fails with "could not determine git commit"
|
||
# (#50823 / #61657). Fetch the requested ref and force-check
|
||
# it out (-f) so untracked ZIP files cannot block checkout.
|
||
Push-Location $InstallDir
|
||
git -c windows.appendAtomically=false init 2>$null
|
||
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
|
||
# Pin autocrlf=false BEFORE the checkout below. Git for Windows
|
||
# defaults to core.autocrlf=true, which would renormalize the
|
||
# repo's LF text files to CRLF in the working tree during
|
||
# `checkout -f FETCH_HEAD` -- leaving this freshly-created
|
||
# managed checkout dirty vs HEAD and aborting the next
|
||
# `hermes update` (see the notes at the shared clone-path
|
||
# config below and install.ps1:1461-1469). The later pin on
|
||
# the shared path is idempotent and still covers git clones.
|
||
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
|
||
git remote add origin $RepoUrlHttps 2>$null
|
||
$fetchRef = if ($Commit) { $Commit } elseif ($Tag) { "refs/tags/$Tag" } else { $Branch }
|
||
Write-Info "Fetching $fetchRef so the ZIP checkout has a resolvable HEAD..."
|
||
$prevZipEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
try {
|
||
git -c windows.appendAtomically=false fetch --depth 1 origin $fetchRef 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
if ($Commit -or $Tag) {
|
||
git -c windows.appendAtomically=false checkout -f --detach FETCH_HEAD 2>&1 | Out-Null
|
||
} else {
|
||
git -c windows.appendAtomically=false checkout -f -B $Branch FETCH_HEAD 2>&1 | Out-Null
|
||
}
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "ZIP checkout pinned to $fetchRef"
|
||
} else {
|
||
# Checkout blocked, but FETCH_HEAD still has a SHA we can stamp with.
|
||
$fetchSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
|
||
if ($LASTEXITCODE -eq 0 -and $fetchSha) {
|
||
if (-not $env:GITHUB_SHA) { $env:GITHUB_SHA = ("$fetchSha").Trim() }
|
||
Write-Warn "ZIP checkout failed; seeded GITHUB_SHA from FETCH_HEAD for desktop stamp"
|
||
} else {
|
||
Write-Warn "ZIP extract succeeded but git checkout failed -- desktop build may need `$env:GITHUB_SHA"
|
||
}
|
||
}
|
||
} else {
|
||
Write-Warn "ZIP extract succeeded but git fetch of $fetchRef failed -- desktop build may need `$env:GITHUB_SHA"
|
||
}
|
||
} finally {
|
||
$ErrorActionPreference = $prevZipEAP
|
||
}
|
||
Pop-Location
|
||
Write-Success "Git repo initialized for future updates"
|
||
|
||
$cloneSuccess = $true
|
||
}
|
||
|
||
# Cleanup temp files
|
||
Remove-Item -Force $zipPath -ErrorAction SilentlyContinue
|
||
Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue
|
||
} catch {
|
||
Write-Err "ZIP download also failed: $_"
|
||
}
|
||
}
|
||
|
||
if (-not $cloneSuccess) {
|
||
throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)"
|
||
}
|
||
}
|
||
|
||
# Set per-repo config (harmless if it fails)
|
||
Push-Location $InstallDir
|
||
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
|
||
# Pin autocrlf=false on the managed clone so git never renormalizes the
|
||
# repo's LF text files to CRLF in the working tree. Without this, the very
|
||
# next `hermes update` checkout aborts on a "dirty" tree the user never
|
||
# touched (see the update path above).
|
||
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
|
||
|
||
# Post-clone pin: when a clone (or ZIP-fallback init) just landed us on
|
||
# $Branch's tip, honour the higher-precedence $Commit / $Tag by checking
|
||
# the exact ref out as a detached HEAD. Skipped for the in-place update
|
||
# path (above) since that already routed via the same precedence.
|
||
if (-not $didUpdate) {
|
||
# Same EAP=Continue wrap as the update path -- git fetch's 'From <url>'
|
||
# info line goes to stderr and would terminate the script under the
|
||
# global EAP=Stop otherwise. We check $LASTEXITCODE for real errors.
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
try {
|
||
if ($Commit) {
|
||
Write-Info "Pinning to commit $Commit..."
|
||
git -c windows.appendAtomically=false fetch origin $Commit
|
||
git -c windows.appendAtomically=false checkout --detach $Commit
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "git checkout $Commit failed (exit $LASTEXITCODE)"
|
||
}
|
||
} elseif ($Tag) {
|
||
Write-Info "Pinning to tag $Tag..."
|
||
git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}"
|
||
git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag"
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "git checkout tag $Tag failed (exit $LASTEXITCODE)"
|
||
}
|
||
}
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
}
|
||
}
|
||
|
||
Write-Success "Repository ready"
|
||
}
|
||
|
||
function Install-Venv {
|
||
if ($NoVenv) {
|
||
Write-Info "Skipping virtual environment (-NoVenv)"
|
||
return
|
||
}
|
||
|
||
# Re-resolve the interpreter before creating the venv. Under Hermes-Setup.exe
|
||
# each stage runs in its own powershell.exe, so the fallback the `python`
|
||
# stage picked (e.g. 3.12 when 3.11 is absent) did NOT propagate into this
|
||
# fresh process -- $PythonVersion is back at its "3.11" default. Trusting it
|
||
# here made `uv venv venv --python 3.11` fail with exit 2 on machines without
|
||
# 3.11 even though the `python` stage reported success (issue #50769).
|
||
$resolvedPython = Resolve-AvailablePythonVersion
|
||
if (-not $resolvedPython) {
|
||
throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first."
|
||
}
|
||
|
||
Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..."
|
||
|
||
Push-Location $InstallDir
|
||
|
||
# Tasks we disabled below and must re-enable no matter how this stage
|
||
# exits. Populated only with tasks that were ENABLED before we touched
|
||
# them, so a task the user deliberately disabled is never re-armed.
|
||
$gatewayTasksDisabled = @()
|
||
$venvHadExistingVenv = $false
|
||
$venvBackupName = $null
|
||
$venvParked = $false
|
||
try {
|
||
if (Test-Path -LiteralPath "venv") {
|
||
$venvHadExistingVenv = $true
|
||
Write-Info "Virtual environment already exists, recreating..."
|
||
# On Windows, native Python extensions (e.g. _bcrypt.pyd, tornado's
|
||
# speedups.pyd) are loaded as DLLs by any running hermes process.
|
||
# Windows denies deletion of loaded DLLs, so every process running out
|
||
# of this venv must be stopped before retiring it. This keeps cleanup
|
||
# from accumulating locked stale trees and avoids carrying a live
|
||
# gateway into the replacement venv.
|
||
if ($env:OS -eq "Windows_NT") {
|
||
$myPid = $PID
|
||
Write-Info "Stopping any running hermes processes before recreating venv..."
|
||
# Disarm the respawner FIRST: the gateway autostart Scheduled Task
|
||
# relaunches a killed gateway within seconds, and losing that race
|
||
# re-locks the venv's .pyd files between our kill sweep and
|
||
# venv parking/cleanup (the July 2026 _brotlicffi.pyd incident). schtasks
|
||
# /End stops a running task instance; /Change /DISABLE stops it
|
||
# from re-firing mid-install. (The Startup-folder .vbs fallback is
|
||
# NOT touched: it only fires at logon, so it cannot respawn a
|
||
# gateway mid-install.) Re-enabled in the finally below -- including
|
||
# on failure -- but only for tasks that were enabled to begin with.
|
||
# Best-effort: a missing task just errors quietly.
|
||
try {
|
||
schtasks /Query /FO CSV 2>$null | ConvertFrom-Csv | Where-Object { $_.TaskName -like '*Hermes_Gateway*' } | ForEach-Object {
|
||
$tn = $_.TaskName
|
||
if ($_.Status -eq 'Disabled') {
|
||
Write-Info " gateway autostart task $tn is already disabled; leaving it that way"
|
||
return
|
||
}
|
||
schtasks /End /TN $tn 2>$null | Out-Null
|
||
schtasks /Change /TN $tn /DISABLE 2>$null | Out-Null
|
||
$gatewayTasksDisabled += $tn
|
||
Write-Info " disabled gateway autostart task $tn for the duration of the install"
|
||
}
|
||
} catch {
|
||
Write-Warn "Could not enumerate gateway scheduled tasks: $($_.Exception.Message)"
|
||
}
|
||
# The launcher CLI (hermes.exe) plus its child tree.
|
||
& taskkill /F /T /IM hermes.exe /FI "PID ne $myPid" 2>$null | Out-Null
|
||
# taskkill /IM hermes.exe is NOT enough: the gateway/agent that a
|
||
# scheduled task or watchdog autostarts runs as
|
||
# `pythonw.exe -m hermes_cli.main gateway run` straight out of
|
||
# venv\Scripts\, so its image name is python/pythonw, not hermes.exe.
|
||
# That process holds the venv's .pyd files open and re-triggers the
|
||
# access-denied failure. Select only roots whose executable lives
|
||
# under this venv, then stop each root's whole process tree. Some
|
||
# Hermes children re-exec through .hermes-runtime, so killing only
|
||
# the selected venv process can leave its child holding the install
|
||
# open. The path-prefix check still keeps unrelated Python processes
|
||
# outside this venv untouched.
|
||
#
|
||
# The gateway autostart task registers with /RL LIMITED as the current
|
||
# user (see hermes_cli/gateway_windows.py), so the installer always
|
||
# runs at equal-or-higher integrity and can read its executable path.
|
||
# Get-CimInstance is used over Get-Process because it returns a null
|
||
# ExecutablePath for a process it cannot inspect (a different session)
|
||
# instead of throwing, so an unreadable process is skipped rather than
|
||
# aborting the whole sweep.
|
||
#
|
||
# The sweep is a bounded LOOP, not single-shot: supervised processes
|
||
# (the Desktop app's backend, a watchdog-managed gateway) respawn in
|
||
# the window between one kill pass and venv parking. Each pass re-
|
||
# enumerates; three consecutive clean passes (or the attempt cap)
|
||
# ends the loop.
|
||
$venvPrefix = [System.IO.Path]::GetFullPath((Join-Path $InstallDir "venv")).TrimEnd('\') + '\'
|
||
$cleanPasses = 0
|
||
for ($sweep = 0; $sweep -lt 10 -and $cleanPasses -lt 3; $sweep++) {
|
||
$found = 0
|
||
try {
|
||
Get-CimInstance Win32_Process -ErrorAction Stop |
|
||
Where-Object { $_.ProcessId -ne $myPid -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($venvPrefix, [System.StringComparison]::OrdinalIgnoreCase) } |
|
||
ForEach-Object {
|
||
$found++
|
||
$treePid = [string]$_.ProcessId
|
||
Write-Info " stopping process tree at PID $treePid ($($_.Name)) running from venv"
|
||
& taskkill /F /T /PID $treePid 2>$null | Out-Null
|
||
}
|
||
} catch {
|
||
Write-Warn "Could not enumerate venv processes: $($_.Exception.Message)"
|
||
break
|
||
}
|
||
if ($found -eq 0) { $cleanPasses++ } else { $cleanPasses = 0 }
|
||
Start-Sleep -Milliseconds 400
|
||
}
|
||
}
|
||
# Move the old venv aside before creating its replacement. A directory
|
||
# rename is atomic on the same volume and does not require deleting
|
||
# files mapped as DLLs. NEVER fall back to deleting the live venv
|
||
# (#83149): Remove-Item -Recurse can delete most of site-packages and
|
||
# then fail on one locked .pyd, leaving a gutted venv with no usable
|
||
# interpreter and no rollback source. Abort with the previous install
|
||
# intact so the user can close holders and retry.
|
||
$venvBackupName = "venv.stale.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
|
||
try {
|
||
Rename-Item -LiteralPath "venv" -NewName $venvBackupName -ErrorAction Stop
|
||
$venvParked = $true
|
||
} catch {
|
||
$renameErr = $_.Exception.Message
|
||
throw (
|
||
"Could not move the existing venv aside ($renameErr). " +
|
||
"A process still has the install directory open (often a non-Hermes " +
|
||
"python.exe that resolved into this venv via PATH). Close those " +
|
||
"processes and retry - the previous install was left intact."
|
||
)
|
||
}
|
||
}
|
||
|
||
# Pass the already-validated private interpreter path and prohibit uv from
|
||
# resolving or downloading a different Python during venv creation. Use
|
||
# ProcessStartInfo because the desktop bootstrapper redirects this script;
|
||
# Windows PowerShell 5.1 can otherwise lose nested native output/exit state.
|
||
$venvProcess = New-Object System.Diagnostics.Process
|
||
try {
|
||
$venvStartInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||
$venvStartInfo.FileName = $UvCmd
|
||
$venvStartInfo.Arguments = "venv venv --python `"$($resolvedPython.Path)`" --managed-python --no-python-downloads --no-config"
|
||
$venvStartInfo.WorkingDirectory = $InstallDir
|
||
$venvStartInfo.UseShellExecute = $false
|
||
$venvStartInfo.CreateNoWindow = $true
|
||
$venvStartInfo.RedirectStandardOutput = $true
|
||
$venvStartInfo.RedirectStandardError = $true
|
||
$venvProcess.StartInfo = $venvStartInfo
|
||
if (-not $venvProcess.Start()) {
|
||
throw "Failed to start uv while creating the virtual environment"
|
||
}
|
||
$venvStdoutTask = $venvProcess.StandardOutput.ReadToEndAsync()
|
||
$venvStderrTask = $venvProcess.StandardError.ReadToEndAsync()
|
||
$venvProcess.WaitForExit()
|
||
$venvStdout = $venvStdoutTask.Result
|
||
$venvStderr = $venvStderrTask.Result
|
||
$venvExitCode = $venvProcess.ExitCode
|
||
if ($venvStdout) { Write-Host $venvStdout.TrimEnd() }
|
||
if ($venvStderr) { Write-Host $venvStderr.TrimEnd() }
|
||
} finally {
|
||
$venvProcess.Dispose()
|
||
}
|
||
# Fail fast so the stage cannot report ok=true when uv failed.
|
||
if ($venvExitCode -ne 0) {
|
||
throw "Failed to create virtual environment (uv venv exited with $venvExitCode)"
|
||
}
|
||
|
||
# uv can return success without leaving the interpreter expected by the
|
||
# installer (for example after an interrupted filesystem operation). Treat
|
||
# that as a failed transaction so the previous venv can be restored.
|
||
$venvPythonExe = Join-Path $InstallDir "venv\Scripts\python.exe"
|
||
if (-not (Test-Path -LiteralPath $venvPythonExe -PathType Leaf)) {
|
||
throw "uv reported success but venv interpreter is missing at $venvPythonExe"
|
||
}
|
||
|
||
# The replacement has a working interpreter, but the transaction is only
|
||
# committed after Install-Dependencies' baseline-import gate passes -- the
|
||
# bootstrap runs the stages as separate processes, and every dependency
|
||
# tier (or the import validation) can still fail after this stage
|
||
# succeeds. Record the parked backup so the dependency stage can restore
|
||
# it on failure and commit its cleanup only after validation (#83149).
|
||
if ($venvParked) {
|
||
Set-Content -LiteralPath (Join-Path $InstallDir "venv.pending-backup") -Value $venvBackupName -Encoding ascii
|
||
Write-Info "Previous venv parked at $venvBackupName until the dependency install is verified"
|
||
}
|
||
|
||
# Clean up parked venvs from previous installs whose handles have since
|
||
# been released. Best-effort -- a still-held tree just stays for next time.
|
||
# The backup parked THIS run is excluded: it is the rollback source until
|
||
# Install-Dependencies commits the transaction.
|
||
Get-ChildItem -Directory -Filter "venv.stale.*" -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Name -ne $venvBackupName } | ForEach-Object {
|
||
Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
# Neutralize any inherited UV_PYTHON (e.g. $env:UV_PYTHON = "3.14" left in
|
||
# the user's shell). uv honours UV_PYTHON over an existing venv for the
|
||
# later `uv sync` / `uv pip install` tiers, so without this it would
|
||
# silently delete this 3.11 venv and recreate it at the inherited version
|
||
# -- building Rust transitives that have no wheel for that version from
|
||
# source via maturin, which fails. Pinning UV_PYTHON to the interpreter we
|
||
# just created forces every subsequent uv command onto it.
|
||
$env:UV_PYTHON = $venvPythonExe
|
||
} catch {
|
||
$originalError = $_
|
||
$rollbackError = $null
|
||
|
||
if ($venvParked -and $venvBackupName -and (Test-Path -LiteralPath $venvBackupName)) {
|
||
try {
|
||
if (Test-Path -LiteralPath "venv") {
|
||
$failedVenvName = "venv.failed.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
|
||
Rename-Item -LiteralPath "venv" -NewName $failedVenvName -ErrorAction Stop
|
||
Write-Warn "Failed replacement parked at $failedVenvName"
|
||
}
|
||
Rename-Item -LiteralPath $venvBackupName -NewName "venv" -ErrorAction Stop
|
||
Write-Warn "Restored previous virtual environment after failed recreate"
|
||
} catch {
|
||
$rollbackError = $_.Exception.Message
|
||
}
|
||
|
||
if ($rollbackError) {
|
||
throw "Virtual environment recreate failed: $($originalError.Exception.Message). Rollback failed: $rollbackError. Previous venv remains at $venvBackupName."
|
||
}
|
||
} elseif (-not $venvHadExistingVenv -and (Test-Path -LiteralPath "venv")) {
|
||
# Preserve a partial first install too. This branch must not touch a
|
||
# pre-existing venv whose move-aside failed above.
|
||
try {
|
||
$failedVenvName = "venv.failed.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
|
||
Rename-Item -LiteralPath "venv" -NewName $failedVenvName -ErrorAction Stop
|
||
Write-Warn "Partial virtual environment parked at $failedVenvName"
|
||
} catch {
|
||
$rollbackError = $_.Exception.Message
|
||
}
|
||
if ($rollbackError) {
|
||
throw "Virtual environment creation failed: $($originalError.Exception.Message). Could not park partial venv: $rollbackError"
|
||
}
|
||
}
|
||
|
||
throw $originalError
|
||
} finally {
|
||
Pop-Location
|
||
# Re-arm the gateway autostart tasks disabled during the venv teardown
|
||
# -- in a finally so a failed teardown/creation can never strand the
|
||
# user's gateway autostart in the disabled state. Same function scope,
|
||
# so the list survives even under the stage-per-process bootstrap.
|
||
# Deliberately NOT started here -- dependencies aren't installed yet;
|
||
# the task fires normally on next logon and `hermes update` / the
|
||
# gateway resume path handles the immediate restart.
|
||
if ($gatewayTasksDisabled -and $gatewayTasksDisabled.Count -gt 0) {
|
||
foreach ($tn in $gatewayTasksDisabled) {
|
||
schtasks /Change /TN $tn /ENABLE 2>$null | Out-Null
|
||
}
|
||
Write-Info "Re-enabled gateway autostart task(s): $($gatewayTasksDisabled -join ', ')"
|
||
}
|
||
}
|
||
|
||
Write-Success "Virtual environment ready (Python $($resolvedPython.Version))"
|
||
}
|
||
|
||
function Get-PendingVenvBackup {
|
||
# Rollback source recorded by Install-Venv (#83149). Returns the parked
|
||
# directory name, or $null when there is nothing to roll back to. A marker
|
||
# pointing at a directory that no longer exists is stale -- drop it.
|
||
$markerPath = Join-Path $InstallDir "venv.pending-backup"
|
||
if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { return $null }
|
||
$name = (Get-Content -LiteralPath $markerPath -ErrorAction SilentlyContinue | Select-Object -First 1)
|
||
if ($name) { $name = $name.Trim() }
|
||
if (-not $name -or -not (Test-Path -LiteralPath (Join-Path $InstallDir $name))) {
|
||
Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue
|
||
return $null
|
||
}
|
||
return $name
|
||
}
|
||
|
||
function Complete-VenvTransaction {
|
||
# Commit: dependency install + baseline imports passed, so the previous
|
||
# venv is no longer needed as a rollback source. Best-effort delete; a
|
||
# tree still held open just stays parked for the next install's sweep.
|
||
$backupName = Get-PendingVenvBackup
|
||
if (-not $backupName) { return }
|
||
$backupPath = Join-Path $InstallDir $backupName
|
||
Remove-Item -LiteralPath $backupPath -Recurse -Force -ErrorAction SilentlyContinue
|
||
if (Test-Path -LiteralPath $backupPath) {
|
||
Write-Warn "Old venv parked at $backupName (a process still holds files in it); it will be cleaned up on the next install"
|
||
}
|
||
Remove-Item -LiteralPath (Join-Path $InstallDir "venv.pending-backup") -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
function Restore-VenvBackup {
|
||
# Rollback: the dependency stage failed after Install-Venv replaced the
|
||
# venv. Park the unusable replacement and restore the previous working
|
||
# venv so Hermes (and the venv-blocker probe) stay usable (#83149).
|
||
$backupName = Get-PendingVenvBackup
|
||
if (-not $backupName) { return }
|
||
try {
|
||
if (Test-Path -LiteralPath (Join-Path $InstallDir "venv")) {
|
||
$failedVenvName = "venv.failed.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
|
||
Rename-Item -LiteralPath (Join-Path $InstallDir "venv") -NewName $failedVenvName -ErrorAction Stop
|
||
Write-Warn "Failed replacement parked at $failedVenvName"
|
||
}
|
||
Rename-Item -LiteralPath (Join-Path $InstallDir $backupName) -NewName "venv" -ErrorAction Stop
|
||
Remove-Item -LiteralPath (Join-Path $InstallDir "venv.pending-backup") -Force -ErrorAction SilentlyContinue
|
||
Write-Warn "Restored previous virtual environment after failed dependency install"
|
||
} catch {
|
||
Write-Warn "Could not restore previous venv (still parked at $backupName): $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
function Install-Dependencies {
|
||
Write-Info "Installing dependencies..."
|
||
|
||
Push-Location $InstallDir
|
||
|
||
if (-not $NoVenv) {
|
||
# Tell uv to install into our venv (no activation needed)
|
||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
||
}
|
||
|
||
# Re-pin UV_PYTHON to the venv interpreter. Install-Venv already does this,
|
||
# but the bootstrap runs install stages (venv, python-deps) as separate
|
||
# processes, so the env var set in Install-Venv does NOT survive into a
|
||
# separate python-deps invocation. Re-deriving it here covers that path.
|
||
# Without it, an inherited $env:UV_PYTHON = "3.14" makes the uv sync/pip
|
||
# tiers below recreate the venv at 3.14 and fail the maturin source build
|
||
# (no cp314 wheels yet).
|
||
if (-not $NoVenv) {
|
||
$venvPythonExe = Join-Path $InstallDir "venv\Scripts\python.exe"
|
||
if (Test-Path $venvPythonExe) {
|
||
$env:UV_PYTHON = $venvPythonExe
|
||
}
|
||
}
|
||
|
||
# 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
|
||
# when the lockfile is stale, missing, or out-of-sync with the
|
||
# current extras spec, NOT because they're equivalent in posture.
|
||
#
|
||
# Everything through the baseline-import gate runs inside the venv
|
||
# transaction opened by Install-Venv (#83149): on any failure the parked
|
||
# previous venv is restored before the error propagates, and the parked
|
||
# tree is deleted only after the imports prove the replacement usable.
|
||
try {
|
||
if (Test-Path "uv.lock") {
|
||
Write-Info "Trying tier: hash-verified (uv.lock) ..."
|
||
# Critical flag choice: `--extra all`, NOT `--all-extras`.
|
||
# --all-extras = every [project.optional-dependencies] key,
|
||
# bypassing the curated [all] extra. On Windows
|
||
# that means [matrix] -> python-olm (no wheel,
|
||
# needs `make` to build from sdist) and the
|
||
# install fails.
|
||
# --extra all = just the [all] extra's contents (curated).
|
||
#
|
||
# 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\
|
||
# 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.)
|
||
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
|
||
Invoke-NativeWithRelaxedErrorAction { & $UvCmd sync --extra all --locked }
|
||
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
|
||
# complete, hash-verified install.
|
||
$skipPipFallback = $true
|
||
} else {
|
||
Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
|
||
$skipPipFallback = $false
|
||
}
|
||
} else {
|
||
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.
|
||
#
|
||
# 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
|
||
# to drift out of sync.
|
||
# 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
|
||
# in [all] should be installable; populate with the names of extras
|
||
# whose deps are temporarily unavailable.
|
||
$brokenExtras = @()
|
||
|
||
# Parse [project.optional-dependencies].all from pyproject.toml.
|
||
# tomllib is stdlib on Python 3.11+ which the bootstrap guarantees.
|
||
$pythonExeForParse = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
|
||
$allExtras = @()
|
||
if (Test-Path $pythonExeForParse) {
|
||
$parsed = & $pythonExeForParse -c @"
|
||
import re, sys, tomllib
|
||
try:
|
||
with open('pyproject.toml', 'rb') as fh:
|
||
data = tomllib.load(fh)
|
||
specs = data['project']['optional-dependencies']['all']
|
||
out = []
|
||
for s in specs:
|
||
m = re.search(r'hermes-agent\[([\w-]+)\]', s)
|
||
if m: out.append(m.group(1))
|
||
print(','.join(out))
|
||
except Exception:
|
||
sys.exit(1)
|
||
"@ 2>$null
|
||
if ($LASTEXITCODE -eq 0 -and $parsed) {
|
||
$allExtras = $parsed.Trim().Split(',')
|
||
}
|
||
}
|
||
if (-not $allExtras -or $allExtras.Count -eq 0) {
|
||
Write-Warn "Could not parse [all] from pyproject.toml; Tier 2 will be a no-op."
|
||
$safeAll = "all"
|
||
} else {
|
||
$safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join ","
|
||
}
|
||
$brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" }
|
||
|
||
$installTiers = @(
|
||
@{ Name = "all"; Spec = ".[all]" },
|
||
@{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" },
|
||
@{ Name = "core only (no extras)"; Spec = "." }
|
||
)
|
||
$installed = $skipPipFallback
|
||
if (-not $skipPipFallback) {
|
||
foreach ($tier in $installTiers) {
|
||
Write-Info "Trying tier: $($tier.Name) ..."
|
||
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e $tier.Spec }
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "Main package installed ($($tier.Name))"
|
||
$script:InstalledTier = $tier.Name
|
||
$installed = $true
|
||
break
|
||
}
|
||
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
|
||
}
|
||
}
|
||
if (-not $installed) {
|
||
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
|
||
}
|
||
|
||
# Baseline-import gate. Even if a tier reported success above, the
|
||
# actual deps may have landed somewhere other than $InstallDir\venv\
|
||
# (e.g. uv 0.5+ syncing into a sibling .venv\ when UV_PROJECT_ENVIRONMENT
|
||
# isn't set, leaving venv\ empty and hermes.exe broken with
|
||
# `ModuleNotFoundError: No module named 'dotenv'` on first run).
|
||
# We probe via the venv's own python so a misdirected sync is caught
|
||
# here, not 30 seconds later when the user runs `hermes`.
|
||
if (-not $NoVenv) {
|
||
$venvPython = "$InstallDir\venv\Scripts\python.exe"
|
||
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, close Hermes processes and preserve existing venv directories before retrying. Do not delete venv in place."
|
||
}
|
||
# 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
|
||
$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\. Close Hermes processes, preserve the existing venv, and rerun the installer so the transactional recovery path can move directories safely."
|
||
} else {
|
||
"Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked"
|
||
}
|
||
throw "Baseline imports failed in $InstallDir\venv (dotenv/openai/rich/prompt_toolkit). The install completed but dependencies are not in the venv. $hint"
|
||
}
|
||
Write-Success "Baseline imports verified in venv"
|
||
}
|
||
|
||
# Commit the venv transaction: the dependency install completed and the
|
||
# baseline imports passed, so the previous venv is no longer needed as a
|
||
# rollback source (#83149).
|
||
Complete-VenvTransaction
|
||
} catch {
|
||
# Dependency install or import validation failed: restore the previous
|
||
# working venv (parked by Install-Venv) before surfacing the error, so
|
||
# a failed update leaves Hermes and its blocker probe usable.
|
||
Restore-VenvBackup
|
||
Pop-Location
|
||
throw
|
||
}
|
||
|
||
if (-not $NoVenv) {
|
||
# uv on Windows can register hermes.exe in dist-info/RECORD but fail to
|
||
# materialise the .exe (file lock during self-update, distlib edge case).
|
||
# Catch it here so a fresh install/update does not finish with a broken
|
||
# `hermes` command while hermes-agent.exe / hermes-acp.exe exist
|
||
$scriptsDir = Join-Path $InstallDir "venv\Scripts"
|
||
$pythonExe = Join-Path $scriptsDir "python.exe"
|
||
if ((Test-Path $scriptsDir) -and (Test-Path $pythonExe)) {
|
||
$scriptNames = & $pythonExe -c @"
|
||
import tomllib
|
||
with open('pyproject.toml', 'rb') as fh:
|
||
scripts = tomllib.load(fh).get('project', {}).get('scripts', {}) or {}
|
||
print(','.join(scripts))
|
||
"@ 2>$null
|
||
if ($LASTEXITCODE -eq 0 -and $scriptNames) {
|
||
$expected = @($scriptNames.Trim().Split(',') | Where-Object { $_ })
|
||
$missing = @()
|
||
foreach ($name in $expected) {
|
||
$exe = Join-Path $scriptsDir "$name.exe"
|
||
if (-not (Test-Path $exe)) { $missing += "$name.exe" }
|
||
}
|
||
if ($missing.Count -gt 0) {
|
||
Write-Warn "Console entry point(s) missing: $($missing -join ', ')"
|
||
Write-Info "Reinstalling entry points..."
|
||
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
|
||
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install --reinstall -e . }
|
||
$stillMissing = @()
|
||
foreach ($name in $expected) {
|
||
$exe = Join-Path $scriptsDir "$name.exe"
|
||
if (-not (Test-Path $exe)) { $stillMissing += "$name.exe" }
|
||
}
|
||
if ($stillMissing.Count -gt 0) {
|
||
Write-Warn "Entry points still missing after repair: $($stillMissing -join ', ')"
|
||
Write-Info "Workaround: `"$pythonExe`" -m hermes_cli.main <command>"
|
||
} else {
|
||
Write-Success "Console entry points restored"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# 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
|
||
$webServerSyntaxOk = $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 { }
|
||
try {
|
||
& $pythonExe -m py_compile "$InstallDir\hermes_cli\web_server.py" 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) { $webServerSyntaxOk = $true }
|
||
} catch { }
|
||
$ErrorActionPreference = $prevEAP
|
||
if (-not $webOk) {
|
||
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) {
|
||
Write-Success "[web] extra installed; `hermes dashboard` should now work."
|
||
} else {
|
||
Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`""
|
||
}
|
||
}
|
||
if (-not $webServerSyntaxOk) {
|
||
throw "dashboard backend source failed syntax check: hermes_cli/web_server.py"
|
||
}
|
||
}
|
||
|
||
Pop-Location
|
||
|
||
Write-Success "All dependencies installed"
|
||
}
|
||
|
||
function Install-HermesCommandLaunchers {
|
||
param(
|
||
[Parameter(Mandatory=$true)] [string]$Root,
|
||
[Parameter(Mandatory=$true)] [string]$Destination
|
||
)
|
||
|
||
# Expose ONLY the hermes launchers on PATH -- never the whole
|
||
# venv\Scripts directory, which contains python.exe / pip.exe and
|
||
# silently hijacks the `python` command in every terminal (#83797).
|
||
# Requiring hermes.exe before creating the destination keeps the PATH
|
||
# stage from reporting success with an unusable command (PR #92092).
|
||
$scriptsDir = Join-Path $Root "venv\Scripts"
|
||
$requiredSource = Join-Path $scriptsDir "hermes.exe"
|
||
if (-not (Test-Path -LiteralPath $requiredSource -PathType Leaf)) {
|
||
throw "Cannot set up the hermes command: required launcher not found: $requiredSource"
|
||
}
|
||
|
||
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
||
|
||
# Launcher form depends on the venv (keep in lockstep with
|
||
# hermes_cli/_install_repair.py): a normal venv's exe trampoline
|
||
# embeds an absolute interpreter path and survives copying; a
|
||
# relocatable venv's trampoline (managed_uv rebuilds use
|
||
# --relocatable) resolves relative to its own location, and a copy
|
||
# dies with 'uv trampoline failed to canonicalize script path' --
|
||
# those get a .cmd delegator invoking the in-venv exe instead.
|
||
$pyvenvCfg = Join-Path $Root "venv\pyvenv.cfg"
|
||
$venvRelocatable = $false
|
||
if (Test-Path -LiteralPath $pyvenvCfg) {
|
||
$venvRelocatable = [bool](Select-String -Path $pyvenvCfg -Pattern '^\s*relocatable\s*=\s*true\s*$' -Quiet)
|
||
}
|
||
foreach ($launcher in @("hermes", "hermes-acp")) {
|
||
$src = Join-Path $scriptsDir "$launcher.exe"
|
||
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { continue }
|
||
if ($venvRelocatable) {
|
||
Remove-Item (Join-Path $Destination "$launcher.exe") -Force -ErrorAction SilentlyContinue
|
||
Set-Content -Path (Join-Path $Destination "$launcher.cmd") -Value "@echo off`r`n`"$src`" %*" -Encoding Ascii
|
||
} else {
|
||
Remove-Item (Join-Path $Destination "$launcher.cmd") -Force -ErrorAction SilentlyContinue
|
||
Copy-Item -Force -LiteralPath $src -Destination (Join-Path $Destination "$launcher.exe")
|
||
}
|
||
}
|
||
|
||
# Verify either staged form before the caller mutates PATH.
|
||
$requiredExe = Join-Path $Destination "hermes.exe"
|
||
$requiredCmd = Join-Path $Destination "hermes.cmd"
|
||
if (-not ((Test-Path -LiteralPath $requiredExe -PathType Leaf) -or
|
||
(Test-Path -LiteralPath $requiredCmd -PathType Leaf))) {
|
||
throw "Cannot set up the hermes command: launcher was not installed: $requiredExe"
|
||
}
|
||
return $Destination
|
||
}
|
||
|
||
function Set-PathVariable {
|
||
# The desktop passes its isolated HERMES_HOME to every child. Do not
|
||
# replace the user's Hermes CLI, PATH, or global HERMES_HOME registry key.
|
||
if ($env:AITURK_DESKTOP_BOOTSTRAP -eq '1') {
|
||
$env:HERMES_HOME = $HermesHome
|
||
Write-Success "AITURK IDE runtime prepared; existing CLI settings preserved"
|
||
return
|
||
}
|
||
Write-Info "Setting up hermes command..."
|
||
|
||
if ($NoVenv) {
|
||
$hermesBin = "$InstallDir"
|
||
} else {
|
||
# $HermesHome\bin is the managed binary dir (shared with the managed
|
||
# uv), OUTSIDE the git checkout: `hermes update`'s autostash
|
||
# (git stash push --include-untracked) deletes untracked files from
|
||
# the working tree, which silently removed the launchers an earlier
|
||
# installer staged under hermes-agent\bin. No git operation can ever
|
||
# touch this dir. Staging and verification live in
|
||
# Install-HermesCommandLaunchers, which throws BEFORE any PATH
|
||
# mutation when the launchers cannot be staged.
|
||
$hermesBin = "$HermesHome\bin"
|
||
Install-HermesCommandLaunchers -Root $InstallDir -Destination $hermesBin | Out-Null
|
||
}
|
||
|
||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||
|
||
# Migrate older layouts off the user PATH:
|
||
# venv\Scripts -- shadowed the user's python (#83797)
|
||
# hermes-agent\bin -- lived inside the git checkout, where the update
|
||
# autostash could sweep the launchers off disk
|
||
# The hermes-agent\bin FILES are left in place on purpose: editor/ACP
|
||
# configs that captured absolute launcher paths keep working, and the
|
||
# dir is git-ignored so it cannot dirty the checkout.
|
||
if (-not $NoVenv) {
|
||
$legacyEntries = @("$InstallDir\venv\Scripts", "$InstallDir\bin")
|
||
$items = @(($currentPath -split ';') | Where-Object { $_ })
|
||
$cleaned = @($items | Where-Object { $legacyEntries -notcontains $_ })
|
||
if ($cleaned.Count -ne $items.Count) {
|
||
$currentPath = $cleaned -join ";"
|
||
[Environment]::SetEnvironmentVariable("Path", $currentPath, "User")
|
||
Write-Info "Removed legacy launcher entries from user PATH (kept hermes via $hermesBin)"
|
||
}
|
||
}
|
||
|
||
if ($currentPath -notlike "*$hermesBin*") {
|
||
[Environment]::SetEnvironmentVariable(
|
||
"Path",
|
||
"$hermesBin;$currentPath",
|
||
"User"
|
||
)
|
||
Write-Success "Added to user PATH: $hermesBin"
|
||
} else {
|
||
Write-Info "PATH already configured"
|
||
}
|
||
|
||
# Set HERMES_HOME so the Python code finds config/data in the right place.
|
||
# Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead
|
||
# of the Unix default ~/.hermes
|
||
$currentHermesHome = [Environment]::GetEnvironmentVariable("HERMES_HOME", "User")
|
||
if (-not $currentHermesHome -or $currentHermesHome -ne $HermesHome) {
|
||
[Environment]::SetEnvironmentVariable("HERMES_HOME", $HermesHome, "User")
|
||
Write-Success "Set HERMES_HOME=$HermesHome"
|
||
}
|
||
$env:HERMES_HOME = $HermesHome
|
||
|
||
# Update current session
|
||
$env:Path = "$hermesBin;$env:Path"
|
||
|
||
Write-Success "hermes command ready"
|
||
}
|
||
|
||
function Write-BootstrapMarker {
|
||
# Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes
|
||
# desktop app (apps/desktop/electron/main.ts) "install.ps1 ran
|
||
# successfully -- DON'T trigger the legacy first-launch bootstrap
|
||
# runner."
|
||
#
|
||
# Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap
|
||
# Complete() expect. Keep this in lockstep when either side changes:
|
||
# apps/desktop/electron/main.ts 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..."
|
||
|
||
# Create the HERMES_HOME directory structure ($HermesHome, default %LOCALAPPDATA%\hermes)
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
|
||
|
||
|
||
# Create .env
|
||
$envPath = "$HermesHome\.env"
|
||
if (-not (Test-Path $envPath)) {
|
||
$examplePath = "$InstallDir\.env.example"
|
||
if (Test-Path $examplePath) {
|
||
Copy-Item $examplePath $envPath
|
||
Write-Success "Created $envPath from template"
|
||
} else {
|
||
New-Item -ItemType File -Force -Path $envPath | Out-Null
|
||
Write-Success "Created $envPath"
|
||
}
|
||
} else {
|
||
Write-Info "$envPath already exists, keeping it"
|
||
}
|
||
|
||
# Create config.yaml
|
||
$configPath = "$HermesHome\config.yaml"
|
||
if (-not (Test-Path $configPath)) {
|
||
$examplePath = "$InstallDir\cli-config.yaml.example"
|
||
if (Test-Path $examplePath) {
|
||
Copy-Item $examplePath $configPath
|
||
Write-Success "Created $configPath from template"
|
||
}
|
||
} else {
|
||
Write-Info "$configPath already exists, keeping it"
|
||
}
|
||
|
||
# Create SOUL.md if it doesn't exist (global persona file).
|
||
# IMPORTANT: write without a BOM. Windows PowerShell 5.1's
|
||
# ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark
|
||
# (the default PS5 behaviour), and Hermes's prompt-injection scanner
|
||
# 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
|
||
# PowerShell version.
|
||
$soulPath = "$HermesHome\SOUL.md"
|
||
if (-not (Test-Path $soulPath)) {
|
||
# MUST match DEFAULT_SOUL_MD in hermes_cli/default_soul.py. The runtime
|
||
# upgrades the old comment-only scaffold to this text on next run, so
|
||
# drift is self-healing, but keep them in sync to avoid first-run churn.
|
||
$soulContent = @"
|
||
You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask -- a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned -- give it when the user asks for detail, teaches, or the stakes demand it, not by default.
|
||
"@
|
||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
|
||
Write-Success "Created $soulPath (edit to customize personality)"
|
||
}
|
||
|
||
Write-Success "Configuration directory ready: $HermesHome"
|
||
|
||
# Seed bundled skills into $HermesHome\skills (manifest-based, one-time per skill)
|
||
Write-Info "Syncing bundled skills to $HermesHome\skills ..."
|
||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||
if (Test-Path $pythonExe) {
|
||
try {
|
||
# Force the child python.exe to emit UTF-8 on its stdout/stderr.
|
||
# On non-UTF-8 Windows locales (CP936/GBK zh-CN) Python defaults
|
||
# its stream encoding to the active codepage and crashes on glyphs
|
||
# like the checkmark (U+2713) that the codepage can't encode; the
|
||
# resulting non-UTF-8 bytes break this script's JSON result frame on
|
||
# stdout and abort the config-templates stage. Scope to this call
|
||
# only. (Comment kept ASCII per this file's PS 5.1 contract above.)
|
||
$prevPythonioencoding = $env:PYTHONIOENCODING
|
||
$prevPythonutf8 = $env:PYTHONUTF8
|
||
$env:PYTHONIOENCODING = "utf-8"
|
||
$env:PYTHONUTF8 = "1"
|
||
try {
|
||
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
|
||
} finally {
|
||
$env:PYTHONIOENCODING = $prevPythonioencoding
|
||
$env:PYTHONUTF8 = $prevPythonutf8
|
||
}
|
||
Write-Success "Skills synced to $HermesHome\skills"
|
||
} catch {
|
||
# Fallback: simple directory copy
|
||
$bundledSkills = "$InstallDir\skills"
|
||
$userSkills = "$HermesHome\skills"
|
||
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
|
||
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
|
||
Write-Success "Skills copied to $HermesHome\skills"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function Install-NodeDeps {
|
||
if (-not $HasNode) {
|
||
# Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME
|
||
# in a fresh powershell.exe) means $script:HasNode set by Stage-Node
|
||
# in the previous process isn't visible here. Re-probe rather than
|
||
# trust the stale global -- Stage-Node already ran successfully or
|
||
# the bootstrap would've aborted, so npm is reachable.
|
||
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||
Write-Info "Skipping Node.js dependencies (Node not installed)"
|
||
return
|
||
}
|
||
}
|
||
|
||
# npm lifecycle scripts need node.exe on the PATH visible to child
|
||
# cmd.exe processes. Stage-Node may have run in a prior process, so
|
||
# re-apply here before any npm install (regression #48130).
|
||
Ensure-NodeExeOnPath | Out-Null
|
||
|
||
# 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
|
||
# 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.
|
||
#
|
||
# Strategy: look next to the npm shim we found and prefer npm.cmd if
|
||
# it exists in the same directory. Fall back to whatever Get-Command
|
||
# 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-Info "Open a new PowerShell window and re-run 'hermes setup tools' later."
|
||
return
|
||
}
|
||
$npmExe = $npmCmd.Source
|
||
if ($npmExe -like "*.ps1") {
|
||
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
|
||
if (Test-Path $npmCmdSibling) {
|
||
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-Info " If it fails, either enable PS script execution or install Node via winget."
|
||
}
|
||
}
|
||
|
||
# Wall-clock ceiling for each npm / Playwright invocation in this stage.
|
||
# scripts/install.sh has time-boxed the same work with
|
||
# ``run_with_timeout "$NODE_DEPS_TIMEOUT"`` (600s default) since #39219;
|
||
# Windows never got the guard, so a stalled registry fetch or a wedged
|
||
# Chromium extraction (#76222, #84614) froze the installer forever -- one
|
||
# user left it running 12+ hours overnight. Same env override as bash
|
||
# for very slow links.
|
||
$nodeDepsTimeoutSec = 600
|
||
if ($env:NODE_DEPS_TIMEOUT -match '^\d+$') {
|
||
$nodeDepsTimeoutSec = [int]$env:NODE_DEPS_TIMEOUT
|
||
}
|
||
|
||
# Helper: run a native command with a hard wall-clock timeout while
|
||
# still streaming its output live. Returns the exit code, or 124 on
|
||
# timeout (the same convention as coreutils ``timeout`` and bash's
|
||
# run_with_timeout).
|
||
#
|
||
# Launcher notes: ``Start-Process -FilePath npm.cmd`` fails with
|
||
# ``%1 is not a valid Win32 application`` on some PowerShell versions
|
||
# because Start-Process bypasses cmd.exe / PATHEXT and expects a real
|
||
# PE file -- so route through cmd.exe, which IS a real PE, honours .cmd
|
||
# batch shims, and performs the stdout+stderr merge into the log file
|
||
# natively. The parent then tails the log into the console each poll
|
||
# tick, preserving the live progress that makes a 3-minute download
|
||
# distinguishable from a hang (the whole reason _Run-NpmInstall streams
|
||
# output in the first place). ``Wait-Job -Timeout`` was rejected: jobs
|
||
# swallow live output, and Stop-Job leaves the npm child running.
|
||
# taskkill /T kills the real process tree. Works on Windows PowerShell
|
||
# 5.1 -- no pwsh-only primitives.
|
||
function _Invoke-NativeWithTimeout(
|
||
[string]$exePath, [string]$argLine, [string]$workDir,
|
||
[string]$logPath, [int]$timeoutSec
|
||
) {
|
||
$cmdLine = "/d /s /c "" ""$exePath"" $argLine > ""$logPath"" 2>&1 """
|
||
$proc = Start-Process -FilePath $env:ComSpec -ArgumentList $cmdLine `
|
||
-WorkingDirectory $workDir -NoNewWindow -PassThru
|
||
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSec)
|
||
$shown = 0
|
||
function _Drain-NewLines([string]$path, [ref]$count) {
|
||
$lines = @(Get-Content $path -ErrorAction SilentlyContinue)
|
||
if ($lines.Count -gt $count.Value) {
|
||
$lines[$count.Value..($lines.Count - 1)] | ForEach-Object {
|
||
Write-Host " $_" -ForegroundColor DarkGray
|
||
}
|
||
$count.Value = $lines.Count
|
||
}
|
||
}
|
||
while (-not $proc.HasExited) {
|
||
if ([DateTime]::UtcNow -gt $deadline) {
|
||
& taskkill /T /F /PID $proc.Id 2>&1 | Out-Null
|
||
return 124
|
||
}
|
||
Start-Sleep -Milliseconds 750
|
||
_Drain-NewLines $logPath ([ref]$shown)
|
||
}
|
||
_Drain-NewLines $logPath ([ref]$shown)
|
||
return $proc.ExitCode
|
||
}
|
||
|
||
# Helper: run "npm install" in a given directory and surface the real
|
||
# error when it fails. Returns $true on success.
|
||
function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
|
||
Push-Location $installDir
|
||
# Capture EAP outside the try block so the catch's restore call always
|
||
# has a meaningful value (see Install-Uv for the full rationale).
|
||
$prevEAP = $ErrorActionPreference
|
||
try {
|
||
# The helper streams npm's output to BOTH the console and the log
|
||
# file, so the user watches real progress instead of a frozen
|
||
# "Installing..." line (on a fresh VM the install is 1-3 minutes;
|
||
# total silence is indistinguishable from a hang) -- and the
|
||
# wall-clock ceiling turns a genuinely stalled install (#76222
|
||
# class) into a diagnosable failure instead of an overnight freeze.
|
||
#
|
||
# Relax EAP around the invocation: with EAP=Stop (set at the top
|
||
# of this script), PowerShell can wrap stray stderr from the
|
||
# launcher plumbing as ErrorRecord objects and throw 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 the returned exit code, which is reliable regardless of
|
||
# stderr noise.
|
||
$ErrorActionPreference = "Continue"
|
||
$code = _Invoke-NativeWithTimeout $npmPath "install --silent" `
|
||
$installDir $logPath $nodeDepsTimeoutSec
|
||
$ErrorActionPreference = $prevEAP
|
||
if ($code -eq 0) {
|
||
Write-Success "$label dependencies installed"
|
||
Remove-Item -Force $logPath -ErrorAction SilentlyContinue
|
||
return $true
|
||
}
|
||
if ($code -eq 124) {
|
||
Write-Warn "$label npm install timed out after $([math]::Round($nodeDepsTimeoutSec / 60)) minutes -- a stalled download, wedged extraction, or file lock is the usual cause."
|
||
Write-Info " Re-run the installer to retry (completed stages are skipped)."
|
||
Write-Info " Slow connection? Raise the ceiling: set NODE_DEPS_TIMEOUT to seconds (default 600)."
|
||
} else {
|
||
Write-Warn "$label npm install failed -- exit code $code"
|
||
}
|
||
if (Test-Path $logPath) {
|
||
$errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue)
|
||
if ($errText) {
|
||
$snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText }
|
||
Write-Info " npm output:"
|
||
foreach ($line in $snippet -split "`n") {
|
||
Write-Host " $line" -ForegroundColor DarkGray
|
||
}
|
||
Write-Info " Full log: $logPath"
|
||
Show-NpmCertHint $errText | Out-Null
|
||
Write-NpmDebugLogTail -NpmOutput $errText
|
||
}
|
||
}
|
||
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 {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
# Browser tools
|
||
if (Test-Path "$InstallDir\package.json") {
|
||
Write-Info "Installing Node.js dependencies (browser tools)..."
|
||
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
|
||
$browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe
|
||
|
||
# Install Playwright Chromium (mirrors scripts/install.sh behaviour for
|
||
# Linux). Without this, tools/browser_tool.py::check_browser_requirements
|
||
# 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.
|
||
if ($browserNpmOk) {
|
||
Write-Info "Installing browser engine (Playwright Chromium)..."
|
||
# npx lives next to npm in the same bin dir. Prefer .cmd to dodge
|
||
# the same execution-policy gotcha that affects npm.ps1 (see above).
|
||
$npmDir = Split-Path $npmExe -Parent
|
||
$npxExe = $null
|
||
foreach ($cand in @("npx.cmd", "npx.exe", "npx")) {
|
||
$try = Join-Path $npmDir $cand
|
||
if (Test-Path $try) { $npxExe = $try; break }
|
||
}
|
||
if (-not $npxExe) {
|
||
$npxCmd = Get-Command npx -ErrorAction SilentlyContinue
|
||
if ($npxCmd) { $npxExe = $npxCmd.Source }
|
||
}
|
||
if (-not $npxExe) {
|
||
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
|
||
# Capture EAP outside the try block so the catch's restore call
|
||
# always has a meaningful value (see Install-Uv for the full
|
||
# rationale).
|
||
$prevEAP = $ErrorActionPreference
|
||
try {
|
||
# 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. The launcher merges
|
||
# stderr into the log natively, but keep EAP relaxed so
|
||
# stray plumbing stderr can't fire the catch block with a
|
||
# mangled banner even though the install succeeded. Check
|
||
# the returned exit code instead, which is the reliable
|
||
# signal.
|
||
#
|
||
# The wall-clock ceiling is the #76222 / #84614 fix: the
|
||
# Chromium download reaches 100% and the extraction wedges
|
||
# (or the registry fetch stalls), and without a bound the
|
||
# installer sits on this line forever. bash has carried
|
||
# the same 600s guard via run_playwright_install since
|
||
# #39219.
|
||
$ErrorActionPreference = "Continue"
|
||
$pwCode = _Invoke-NativeWithTimeout $npxExe "--yes playwright install chromium" `
|
||
$InstallDir $pwLog $nodeDepsTimeoutSec
|
||
$ErrorActionPreference = $prevEAP
|
||
if ($pwCode -eq 0) {
|
||
Write-Success "Playwright Chromium installed (browser tools ready)"
|
||
Remove-Item -Force $pwLog -ErrorAction SilentlyContinue
|
||
} elseif ($pwCode -eq 124) {
|
||
Write-Warn "Playwright Chromium install timed out after $([math]::Round($nodeDepsTimeoutSec / 60)) minutes."
|
||
Write-Warn "This usually means a stalled download or a wedged archive extraction (a locked previous browser version can also cause it)."
|
||
Write-Warn "Browser tools will not work until Chromium is installed."
|
||
if (Test-Path $pwLog) { Write-Info " Partial log: $pwLog" }
|
||
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
|
||
} else {
|
||
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
|
||
if ($pwErr) {
|
||
$snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr }
|
||
Write-Info " playwright output:"
|
||
foreach ($line in $snippet -split "`n") {
|
||
Write-Host " $line" -ForegroundColor DarkGray
|
||
}
|
||
Write-Info " Full log: $pwLog"
|
||
}
|
||
}
|
||
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 {
|
||
Pop-Location
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# TUI
|
||
$tuiDir = "$InstallDir\ui-tui"
|
||
if (Test-Path "$tuiDir\package.json") {
|
||
Write-Info "Installing TUI dependencies..."
|
||
$tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
|
||
[void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
|
||
}
|
||
|
||
Install-BrowserUseCli
|
||
Install-CuaDriver
|
||
}
|
||
|
||
# The Browser Use CLI is the default browser backend when it is runnable
|
||
# (tools/browser_use_cli.py). Provision it at install time so fresh installs
|
||
# don't silently fall back to the built-in browser tools. Best-effort: any
|
||
# failure is non-fatal (browser_exec can still run via uvx, and `hermes tools`
|
||
# can install it later).
|
||
function Install-BrowserUseCli {
|
||
if (-not $script:UvCmd) { Resolve-UvCmd }
|
||
if (-not $script:UvCmd) {
|
||
Write-Info "Skipping Browser Use CLI install (uv unavailable)"
|
||
return
|
||
}
|
||
$managedBin = Join-Path $HermesHome "bin"
|
||
$managedBu = Join-Path $managedBin "browser-use.exe"
|
||
# MANAGED-FIRST: only Hermes' managed copy short-circuits. A browser-use
|
||
# on the user's PATH is a side install -- resolution prefers the managed
|
||
# copy, so it must be provisioned regardless.
|
||
if (Test-Path $managedBu) {
|
||
Write-Success "Browser Use CLI already installed"
|
||
return
|
||
}
|
||
|
||
Write-Info "Installing Browser Use CLI (default browser backend)..."
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
try {
|
||
# UV_TOOL_BIN_DIR keeps the binary inside Hermes' managed bin dir,
|
||
# where the browser tool resolves it -- no reliance on the user PATH.
|
||
$env:UV_TOOL_BIN_DIR = $managedBin
|
||
$env:UV_NO_CONFIG = "1"
|
||
& $script:UvCmd tool install browser-use 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "Browser Use CLI installed"
|
||
} else {
|
||
Write-Warn "Browser Use CLI install failed (exit $LASTEXITCODE) -- browser automation falls back to built-in tools."
|
||
Write-Info "Install later with: uv tool install browser-use (or via 'hermes tools')"
|
||
}
|
||
} catch {
|
||
Write-Warn "Browser Use CLI install failed: $_"
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
Remove-Item Env:\UV_TOOL_BIN_DIR -ErrorAction SilentlyContinue
|
||
Remove-Item Env:\UV_NO_CONFIG -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
function Test-CuaDriverRuntimeContract {
|
||
param([Parameter(Mandatory = $true)][string]$DriverPath)
|
||
|
||
try {
|
||
$versionOutput = (& $DriverPath --version 2>$null | Out-String).Trim()
|
||
if ($LASTEXITCODE -ne 0) {
|
||
return $false
|
||
}
|
||
$versionMatch = [regex]::Match($versionOutput, '(\d+\.\d+\.\d+)')
|
||
if (-not $versionMatch.Success) {
|
||
return $false
|
||
}
|
||
if ([version]($versionMatch.Groups[1].Value) -lt [version]'0.20.0') {
|
||
return $false
|
||
}
|
||
|
||
$manifestOutput = (& $DriverPath manifest 2>$null | Out-String).Trim()
|
||
if ($LASTEXITCODE -ne 0 -or -not $manifestOutput) {
|
||
return $false
|
||
}
|
||
$manifest = $manifestOutput | ConvertFrom-Json
|
||
if (-not $manifest.mcp_invocation.args) {
|
||
return $false
|
||
}
|
||
|
||
$required = @{
|
||
mcp = @('--socket', '--grant')
|
||
serve = @(
|
||
'--socket', '--permission-mode', '--capability-manifest',
|
||
'--approve-capability-manifest', '--embedded'
|
||
)
|
||
stop = @('--socket')
|
||
}
|
||
foreach ($commandName in $required.Keys) {
|
||
$command = $manifest.subcommands | Where-Object { $_.name -eq $commandName }
|
||
if (-not $command) {
|
||
return $false
|
||
}
|
||
$argNames = @($command.args | ForEach-Object { $_.name })
|
||
foreach ($requiredArg in $required[$commandName]) {
|
||
if ($requiredArg -notin $argNames) {
|
||
return $false
|
||
}
|
||
}
|
||
}
|
||
return $true
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# cua-driver powers the computer_use toolset (background desktop control).
|
||
# Provision it at install time so enabling the tool later -- via `hermes
|
||
# tools`, the dashboard, or the desktop app -- is a config flip, not a
|
||
# surprise multi-minute binary fetch. Best-effort and non-fatal: the enable
|
||
# paths still lazy-install via install_cua_driver() (hermes_cli/tools_config)
|
||
# when this step was skipped or failed.
|
||
function Install-CuaDriver {
|
||
if ($SkipComputerUse) {
|
||
Write-Info "Skipping Computer Use (cua-driver) install (-SkipComputerUse)"
|
||
return
|
||
}
|
||
$existingCuaDriver = Get-Command cua-driver -ErrorAction SilentlyContinue
|
||
if ($existingCuaDriver) {
|
||
if (Test-CuaDriverRuntimeContract -DriverPath $existingCuaDriver.Source) {
|
||
Write-Success "Computer Use driver (cua-driver) already installed and compatible"
|
||
return
|
||
}
|
||
Write-Warn "Existing cua-driver is old or incomplete; repairing it"
|
||
}
|
||
|
||
Write-Info "Installing Computer Use driver (cua-driver)..."
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "Continue"
|
||
try {
|
||
# Same upstream installer `hermes computer-use install` runs. Bounded
|
||
# via a background job: the upstream installer serializes with its own
|
||
# lock (600s stale window), so the ceiling sits above that -- matching
|
||
# Hermes' _CUA_INSTALLER_TIMEOUT (660s).
|
||
$job = Start-Job -ScriptBlock {
|
||
Invoke-RestMethod -UseBasicParsing "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1" | Invoke-Expression
|
||
}
|
||
if (Wait-Job $job -Timeout 660) {
|
||
Receive-Job $job -ErrorAction SilentlyContinue | Out-Null
|
||
Remove-Job $job -Force -ErrorAction SilentlyContinue
|
||
$installedCuaDriver = Get-Command cua-driver -ErrorAction SilentlyContinue
|
||
if ($installedCuaDriver -and (Test-CuaDriverRuntimeContract -DriverPath $installedCuaDriver.Source)) {
|
||
Write-Success "Computer Use driver installed (enable via 'hermes tools' -> Computer Use)"
|
||
} else {
|
||
Write-Warn "Computer Use driver install did not produce a compatible runtime -- repair it before enabling the tool."
|
||
Write-Info "Install later with: hermes computer-use install"
|
||
}
|
||
} else {
|
||
Stop-Job $job -ErrorAction SilentlyContinue
|
||
Remove-Job $job -Force -ErrorAction SilentlyContinue
|
||
Write-Warn "Computer Use driver install timed out -- it will install on demand when you enable the tool."
|
||
Write-Info "Install later with: hermes computer-use install"
|
||
}
|
||
} catch {
|
||
Write-Warn "Computer Use driver install failed: $_"
|
||
Write-Info "Install later with: hermes computer-use install"
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
}
|
||
}
|
||
|
||
# Clear the cached Electron download + any half-written unpacked output so the
|
||
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
|
||
# the per-user Electron download cache - most often a partial download resumed
|
||
# into the same file, leaving concatenated junk - makes electron-builder's
|
||
# `app-builder unpack-electron` extract a tree MISSING the electron binary, so
|
||
# the final `electron` -> `Hermes` rename dies with ENOENT and every re-run
|
||
# repeats the broken extraction forever.
|
||
#
|
||
# We deliberately do not validate the zip ourselves: the common
|
||
# prepended/concatenated-junk corruption slips past naive checks, so a
|
||
# self-rolled gate would skip the real-world case. We unconditionally drop the
|
||
# cached electron-*.zip (loose copy and any @electron/get hash-subdir copy) plus
|
||
# the stale unpacked dir, then let the caller retry once - @electron/get
|
||
# re-downloads with its own SHASUM verification, the real source of truth.
|
||
#
|
||
# Returns the removed paths. Best-effort: never throws.
|
||
function Clear-ElectronBuildCache {
|
||
param([string]$DesktopDir)
|
||
$removed = @()
|
||
|
||
# Per-user Electron download cache dirs, honoring the overrides @electron/get
|
||
# respects, then the Windows default (%LOCALAPPDATA%\electron\Cache).
|
||
$cacheDirs = @()
|
||
if ($env:electron_config_cache) { $cacheDirs += $env:electron_config_cache }
|
||
if ($env:ELECTRON_CACHE) { $cacheDirs += $env:ELECTRON_CACHE }
|
||
if ($env:LOCALAPPDATA) { $cacheDirs += (Join-Path $env:LOCALAPPDATA 'electron\Cache') }
|
||
$cacheDirs += (Join-Path $HOME 'AppData\Local\electron\Cache')
|
||
|
||
foreach ($dir in $cacheDirs) {
|
||
if (-not (Test-Path -LiteralPath $dir)) { continue }
|
||
# Recurse: the bad copy may be the top-level zip OR a copy inside an
|
||
# @electron/get hash subdir.
|
||
$removed += @(Get-ChildItem -LiteralPath $dir -Recurse -Filter 'electron-*.zip' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||
try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop; $_.FullName } catch { }
|
||
})
|
||
}
|
||
|
||
# A half-written unpacked dir from an interrupted prior pack poisons the
|
||
# rename even after the zip is fixed (win-unpacked / win-arm64-unpacked).
|
||
$releaseDir = Join-Path $DesktopDir 'release'
|
||
if (Test-Path -LiteralPath $releaseDir) {
|
||
$removed += @(Get-ChildItem -LiteralPath $releaseDir -Directory -Filter '*-unpacked' -ErrorAction SilentlyContinue | ForEach-Object {
|
||
try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop; $_.FullName } catch { }
|
||
})
|
||
}
|
||
|
||
return $removed
|
||
}
|
||
|
||
# Last-resort Electron mirror after GitHub download fails (#47266).
|
||
$script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/"
|
||
|
||
# Electron package dir -- workspace-local nest first, then root hoist.
|
||
function Get-ElectronDir {
|
||
param([string]$InstallDir)
|
||
$desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron'
|
||
if (Test-Path -LiteralPath $desktopLocal) { return $desktopLocal }
|
||
return (Join-Path $InstallDir 'node_modules\electron')
|
||
}
|
||
|
||
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs).
|
||
function Test-ElectronDist {
|
||
param([string]$InstallDir)
|
||
$electronDir = Get-ElectronDir -InstallDir $InstallDir
|
||
$distExe = Join-Path $electronDir 'dist\electron.exe'
|
||
return (Test-Path -LiteralPath $distExe)
|
||
}
|
||
|
||
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
|
||
function Restore-ElectronDist {
|
||
param([string]$InstallDir, [string]$Mirror)
|
||
if (Test-ElectronDist -InstallDir $InstallDir) { return $true }
|
||
|
||
$electronDir = Get-ElectronDir -InstallDir $InstallDir
|
||
$distExe = Join-Path $electronDir 'dist\electron.exe'
|
||
$installer = Join-Path $electronDir 'install.js'
|
||
if (-not (Test-Path -LiteralPath $installer)) { return $false }
|
||
$node = Get-Command node -ErrorAction SilentlyContinue
|
||
if (-not $node) { return $false }
|
||
|
||
$distDir = Join-Path $electronDir 'dist'
|
||
if (Test-Path -LiteralPath $distDir) {
|
||
Remove-Item -LiteralPath $distDir -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
Remove-Item -LiteralPath (Join-Path $electronDir 'path.txt') -Force -ErrorAction SilentlyContinue
|
||
|
||
$prevMirror = $env:ELECTRON_MIRROR
|
||
if ($Mirror) { $env:ELECTRON_MIRROR = $Mirror }
|
||
try {
|
||
# Out-Host so the downloader's progress shows on the console WITHOUT
|
||
# leaking into this function's return value (PowerShell returns every
|
||
# object left on the output stream, so a bare pipe here would make the
|
||
# boolean below ambiguous).
|
||
& $node.Source $installer 2>&1 | ForEach-Object { "$_" } | Out-Host
|
||
} catch {
|
||
} finally {
|
||
$env:ELECTRON_MIRROR = $prevMirror
|
||
}
|
||
return (Test-Path -LiteralPath $distExe)
|
||
}
|
||
|
||
function Test-ElectronPkgStagedMissingDist {
|
||
param([string]$InstallDir)
|
||
$electronDir = Get-ElectronDir -InstallDir $InstallDir
|
||
return (
|
||
(Test-Path -LiteralPath (Join-Path $electronDir 'package.json')) -and
|
||
(Test-Path -LiteralPath (Join-Path $electronDir 'install.js')) -and
|
||
(-not (Test-ElectronDist -InstallDir $InstallDir))
|
||
)
|
||
}
|
||
|
||
function Try-RestoreElectronDist {
|
||
param([string]$InstallDir)
|
||
if (Restore-ElectronDist -InstallDir $InstallDir) { return $true }
|
||
if ($env:ELECTRON_MIRROR) { return $false }
|
||
return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror
|
||
}
|
||
|
||
function Install-DesktopVoiceDeps {
|
||
# Desktop ships with working voice out of the box: eagerly install the
|
||
# wake-word + local-STT stacks ([wake] + [voice] extras) instead of
|
||
# leaving them to lazy first-use install. Policy change (Teknium, July
|
||
# 2026, #70509 testing): the first ear-click used to trigger a
|
||
# multi-minute onnxruntime pip install that froze the UI and blew RPC
|
||
# timeouts. Best-effort -- lazy install remains the fallback for anything
|
||
# this step fails to fetch.
|
||
if (-not $script:UvCmd) { Resolve-UvCmd }
|
||
if (-not $script:UvCmd) {
|
||
Write-Warn "uv unavailable -- voice/wake deps will lazy-install at first use instead"
|
||
return
|
||
}
|
||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
||
Write-Info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper -- 1-3min)..."
|
||
Push-Location $InstallDir
|
||
try {
|
||
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e ".[wake,voice]" }
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "Voice + wake-word dependencies installed"
|
||
} else {
|
||
Write-Warn "Voice/wake dependency install failed (exit $LASTEXITCODE) -- they will lazy-install at first use"
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Install-Desktop {
|
||
# Build apps/desktop into a launchable Hermes.exe. Only called from
|
||
# Stage-Desktop, which is itself only included in the manifest when
|
||
# -IncludeDesktop was passed to install.ps1.
|
||
#
|
||
# The workspace npm install at repo root (done by Install-NodeDeps for
|
||
# browser tools) does NOT pull apps/desktop's dependencies, because the
|
||
# browser-tools workspace at $InstallDir\package.json is a separate
|
||
# workspace from apps/*. We do a full root-level `npm install` here
|
||
# so the workspace resolves apps/desktop's deps (including Electron
|
||
# itself, ~150MB), then run `npm run pack` in apps/desktop which
|
||
# produces the unpacked binary at apps/desktop/release/<os>-unpacked/.
|
||
#
|
||
# The Tauri bootstrap installer's launch_hermes_desktop command
|
||
# resolves apps/desktop/release/win-unpacked/Hermes.exe directly,
|
||
# so an "unpacked" build (electron-builder --dir) is enough -- we
|
||
# don't need to produce an NSIS/MSI artifact here.
|
||
|
||
# Always re-resolve Node here. Stages run in separate PowerShell processes,
|
||
# so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node
|
||
# enforces the supported Node lines and prepends the Hermes-managed Node to
|
||
# PATH, so the build never runs on an unsupported system Node -- the cause
|
||
# of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on
|
||
# old Node).
|
||
Test-Node | Out-Null
|
||
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||
Write-Warn "Skipping desktop build (Node.js / npm not on PATH)"
|
||
$script:_StageSkippedReason = "Node.js not available"
|
||
return
|
||
}
|
||
|
||
$desktopDir = "$InstallDir\apps\desktop"
|
||
if (-not (Test-Path "$desktopDir\package.json")) {
|
||
Write-Warn "Skipping desktop build (apps/desktop not present in checkout)"
|
||
$script:_StageSkippedReason = "apps/desktop not present"
|
||
return
|
||
}
|
||
|
||
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
|
||
if (-not $npmCmd) {
|
||
Write-Warn "Skipping desktop build (npm not on PATH)"
|
||
$script:_StageSkippedReason = "npm not found"
|
||
return
|
||
}
|
||
$npmExe = $npmCmd.Source
|
||
if ($npmExe -like "*.ps1") {
|
||
$sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
|
||
if (Test-Path $sibling) { $npmExe = $sibling }
|
||
}
|
||
|
||
# 1. Workspace-level install so apps/desktop's deps (Electron, Vite,
|
||
# node-pty prebuilds, etc.) actually land in node_modules. This is
|
||
# the SAME `npm install` Install-NodeDeps does for browser tools,
|
||
# but at the root rather than the browser-tools workspace, so all
|
||
# apps/* workspaces resolve.
|
||
Write-Info "Installing desktop workspace dependencies (this includes Electron ~150MB, takes 1-3min)..."
|
||
Push-Location $InstallDir
|
||
$prevEAP = $ErrorActionPreference
|
||
try {
|
||
$ErrorActionPreference = "Continue"
|
||
# Drop --silent so npm emits its full progress + error trail.
|
||
# When this fails on a non-dev box (e.g. native-module build
|
||
# without VS Build Tools, ETARGET on a transitive, etc.), the
|
||
# actual reason needs to reach the Tauri installer's log; with
|
||
# --silent it was completely suppressed and the user just saw
|
||
# "exit 1" with no actionable detail.
|
||
#
|
||
# The streaming sink in bootstrap.rs's run_install_script
|
||
# captures every stdout/stderr line as it's emitted, so we don't
|
||
# need a side TEMP log file -- the installer's bootstrap log
|
||
# IS the artifact a support engineer reads.
|
||
#
|
||
# Prefer `npm ci`: it wipes node_modules and reinstalls from the
|
||
# lockfile, always producing a complete tree. Bare `npm install`
|
||
# can report "up to date" against a stale
|
||
# node_modules\.package-lock.json marker while node_modules is
|
||
# actually empty (Windows workspace-hoisting flake), leaving
|
||
# tsc/typescript unresolved so `npm run pack`'s `tsc -b` dies with
|
||
# no obvious cause. Fall back to `npm install` only if `npm ci`
|
||
# fails (lockfile out of sync / very old npm without ci).
|
||
#
|
||
# Tee the merged output into $npmOut while still emitting every line
|
||
# live. We don't need a side log file (the bootstrap streaming sink
|
||
# is the artifact), but on failure we scan $npmOut for the TLS-trust
|
||
# signature so corporate-proxy users get the NODE_EXTRA_CA_CERTS hint
|
||
# instead of an opaque "exit 1" (issue #38016).
|
||
& $npmExe ci 2>&1 | ForEach-Object { "$_" } | Tee-Object -Variable npmOut
|
||
$code = $LASTEXITCODE
|
||
if ($code -ne 0) {
|
||
Write-Info " npm ci failed (exit $code) -- retrying with npm install..."
|
||
& $npmExe install 2>&1 | ForEach-Object { "$_" } | Tee-Object -Variable npmOut
|
||
$code = $LASTEXITCODE
|
||
}
|
||
$ErrorActionPreference = $prevEAP
|
||
if ($code -ne 0) {
|
||
if (Test-ElectronPkgStagedMissingDist -InstallDir $InstallDir) {
|
||
Write-Warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
|
||
Try-RestoreElectronDist -InstallDir $InstallDir | Out-Null
|
||
} else {
|
||
Show-NpmCertHint ($npmOut -join "`n") | Out-Null
|
||
# Replay npm's own debug log into our stream: the terse
|
||
# summary above rarely contains the postinstall stderr
|
||
# (e.g. Electron's install.js) that explains the failure.
|
||
Write-NpmDebugLogTail -NpmOutput ($npmOut -join "`n")
|
||
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
|
||
}
|
||
} else {
|
||
Write-Success "Desktop workspace dependencies installed"
|
||
}
|
||
} catch {
|
||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||
Pop-Location
|
||
throw
|
||
}
|
||
Pop-Location
|
||
|
||
# 2. Build apps/desktop. `npm run pack` runs:
|
||
# assert-root-install + write-build-stamp + stage-native-deps +
|
||
# tsc -b + vite build + electron-builder --dir
|
||
# The --dir mode produces an unpacked Hermes.exe in
|
||
# apps/desktop/release/win-unpacked/ without bundling NSIS/MSI;
|
||
# we don't need a distributable installer artifact, just a
|
||
# launchable binary the Tauri installer can spawn.
|
||
#
|
||
# CSC_IDENTITY_AUTO_DISCOVERY=false tells electron-builder we are
|
||
# NOT signing the output. Combined with signAndEditExecutable=false in
|
||
# apps/desktop/package.json's build.win block, electron-builder never
|
||
# invokes signtool and therefore never fetches/extracts winCodeSign
|
||
# (whose macOS symlinks crash 7-Zip on non-admin Windows -- a dead end we
|
||
# are NOT trying to work around). The Hermes icon + product name are
|
||
# stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)
|
||
# AFTER this build, completely decoupled from electron-builder signing.
|
||
#
|
||
# WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD explicitly cleared as
|
||
# belt-and-suspenders: if the user's environment has them set
|
||
# for some other tool, electron-builder would still try to sign.
|
||
Write-Info "Building desktop app (this takes 1-3 minutes)..."
|
||
$buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log"
|
||
# Seed GITHUB_SHA for write-build-stamp.mjs. The stamp prefers CI env vars
|
||
# over `git rev-parse`, so this covers: (1) node can't find git.exe on PATH
|
||
# even though this PowerShell session can, (2) ZIP/init trees that still
|
||
# lack a HEAD after a failed post-extract fetch. Without it the desktop
|
||
# pack dies with "could not determine git commit" (#50823).
|
||
if (-not $env:GITHUB_SHA) {
|
||
if ($Commit) {
|
||
$env:GITHUB_SHA = $Commit
|
||
} else {
|
||
Push-Location $InstallDir
|
||
try {
|
||
$global:LASTEXITCODE = 0
|
||
$resolvedSha = & git -c windows.appendAtomically=false rev-parse HEAD 2>$null
|
||
if ($LASTEXITCODE -ne 0 -or -not $resolvedSha) {
|
||
# ZIP path may have FETCH_HEAD after a fetch even when HEAD is unset.
|
||
$global:LASTEXITCODE = 0
|
||
$resolvedSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
|
||
}
|
||
if ($LASTEXITCODE -eq 0 -and $resolvedSha) {
|
||
$env:GITHUB_SHA = ("$resolvedSha").Trim()
|
||
}
|
||
} catch { } finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
}
|
||
if (-not $env:GITHUB_REF_NAME) {
|
||
$env:GITHUB_REF_NAME = if ($Branch) { $Branch } else { "main" }
|
||
}
|
||
if ($env:GITHUB_SHA) {
|
||
$shaPreview = if ($env:GITHUB_SHA.Length -ge 12) { $env:GITHUB_SHA.Substring(0, 12) } else { $env:GITHUB_SHA }
|
||
Write-Info "Desktop build stamp: $shaPreview ($($env:GITHUB_REF_NAME))"
|
||
} else {
|
||
Write-Warn "Could not resolve a git commit for the desktop stamp -- write-build-stamp will use its non-git fallback"
|
||
}
|
||
Push-Location $desktopDir
|
||
$prevEAP = $ErrorActionPreference
|
||
$prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY
|
||
$prevWinCscLink = $env:WIN_CSC_LINK
|
||
$prevWinCscKeyPassword = $env:WIN_CSC_KEY_PASSWORD
|
||
try {
|
||
$ErrorActionPreference = "Continue"
|
||
$env:CSC_IDENTITY_AUTO_DISCOVERY = "false"
|
||
$env:WIN_CSC_LINK = ""
|
||
$env:WIN_CSC_KEY_PASSWORD = ""
|
||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||
$code = $LASTEXITCODE
|
||
if ($code -ne 0) {
|
||
$purged = @()
|
||
$restored = $false
|
||
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
|
||
$purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir)
|
||
$restored = Restore-ElectronDist -InstallDir $InstallDir
|
||
}
|
||
if ($restored) {
|
||
Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:"
|
||
foreach ($p in $purged) { Write-Info " - $p" }
|
||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||
$code = $LASTEXITCODE
|
||
}
|
||
}
|
||
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
|
||
$mirror = $script:DesktopElectronFallbackMirror
|
||
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
|
||
Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:"
|
||
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
||
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
|
||
Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror | Out-Null
|
||
}
|
||
$prevMirror = $env:ELECTRON_MIRROR
|
||
$env:ELECTRON_MIRROR = $mirror
|
||
try {
|
||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||
$code = $LASTEXITCODE
|
||
} finally {
|
||
$env:ELECTRON_MIRROR = $prevMirror
|
||
}
|
||
}
|
||
$ErrorActionPreference = $prevEAP
|
||
if ($code -ne 0) {
|
||
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue
|
||
if ($errText) {
|
||
$snippet = if ($errText.Length -gt 1800) { $errText.Substring(0, 1800) + "..." } else { $errText }
|
||
Write-Info " desktop build output:"
|
||
foreach ($line in $snippet -split "`n") { Write-Host " $line" -ForegroundColor DarkGray }
|
||
Write-Info " Full log: $buildLog"
|
||
}
|
||
# `npm run pack` failures (lifecycle script exits) also land in
|
||
# npm's debug log; replay it so the bootstrap log carries the
|
||
# full evidence even when $buildLog's tail cuts off the cause.
|
||
Write-NpmDebugLogTail -NpmOutput $errText
|
||
throw "apps/desktop build failed (exit $code)"
|
||
}
|
||
Write-Success "Desktop app built"
|
||
Remove-Item -LiteralPath $buildLog -Force -ErrorAction SilentlyContinue
|
||
} catch {
|
||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||
Pop-Location
|
||
throw
|
||
} finally {
|
||
# Restore env to whatever the caller had -- don't leak our
|
||
# signing-off override into anything install.ps1 invokes later
|
||
# (Stage-PlatformSdks, etc.).
|
||
$env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto
|
||
$env:WIN_CSC_LINK = $prevWinCscLink
|
||
$env:WIN_CSC_KEY_PASSWORD = $prevWinCscKeyPassword
|
||
}
|
||
Pop-Location
|
||
|
||
# 3. Sanity-check the produced binary. Probe both arches so this works
|
||
# on x64 and arm64 build machines.
|
||
$exeCandidates = @(
|
||
"$desktopDir\release\win-unpacked\Hermes.exe",
|
||
"$desktopDir\release\win-arm64-unpacked\Hermes.exe"
|
||
)
|
||
$found = $false
|
||
$desktopExe = $null
|
||
foreach ($cand in $exeCandidates) {
|
||
if (Test-Path $cand) {
|
||
Write-Success "Desktop ready: $cand"
|
||
$desktopExe = $cand
|
||
$found = $true
|
||
break
|
||
}
|
||
}
|
||
if (-not $found) {
|
||
throw "Desktop build completed but no Hermes.exe was found under $desktopDir\release\*-unpacked\"
|
||
}
|
||
|
||
# 3b. The Hermes icon + identity are stamped onto Hermes.exe by the
|
||
# electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs)
|
||
# during `npm run pack` above -- for every build, so the installer's
|
||
# --update rebuild stays branded too. No separate stamp step needed here.
|
||
# electron-builder's own rcedit step stays disabled (signAndEditExecutable
|
||
# =false) because enabling it drags in signtool -> winCodeSign -> the
|
||
# unfixable symlink crash; the afterPack hook runs rcedit directly.
|
||
|
||
# 3c. Grant ALL APPLICATION PACKAGES (S-1-15-2-2) RX on the unpacked app
|
||
# directory. Chromium's GPU/renderer sandboxes CHECK-fail with
|
||
# 0x80000003 when this ACE is missing alongside orphan AppContainer
|
||
# SIDs under %LOCALAPPDATA% (electron/electron#51761, hermes-agent#38216).
|
||
# Best-effort -- never fail an otherwise-good install over ACL repair.
|
||
try {
|
||
$appDir = Split-Path -Parent $desktopExe
|
||
& icacls $appDir /grant "*S-1-15-2-2:(OI)(CI)(RX)" /T /C /Q | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "Granted AppContainer read access on $appDir"
|
||
} else {
|
||
Write-Warn "icacls AppContainer grant returned exit $LASTEXITCODE for $appDir"
|
||
}
|
||
} catch {
|
||
Write-Warn "Could not grant AppContainer ACL: $($_.Exception.Message)"
|
||
}
|
||
|
||
# 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
|
||
# Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
|
||
# command rebuilds (npm install + electron-builder) on every launch,
|
||
# which would cost minutes each time. The packed exe is the consumer --
|
||
# launching it directly is instant, and updates flow through the
|
||
# installer's --update path (which rebuilds once, then relaunches).
|
||
New-DesktopShortcuts -TargetExe $desktopExe
|
||
}
|
||
|
||
function New-DesktopShortcuts {
|
||
param([Parameter(Mandatory = $true)][string]$TargetExe)
|
||
|
||
# Best-effort: a shortcut failure must never fail an otherwise-good install.
|
||
try {
|
||
$shell = New-Object -ComObject WScript.Shell
|
||
$workDir = Split-Path -Parent $TargetExe
|
||
|
||
# Prefer the standalone icon.ico (shipped beside the exe via
|
||
# electron-builder extraResources -> resources/icon.ico) over the exe's
|
||
# embedded resource. An explicit .ico path is more stable across update
|
||
# cycles: pointing at "$TargetExe,0" makes Windows cache the icon it
|
||
# extracted from the exe at shortcut-creation time, and that cached
|
||
# bitmap can persist (showing the OLD/Electron icon) even after the exe
|
||
# is re-stamped on update. A dedicated .ico sidesteps that extraction.
|
||
$iconIco = Join-Path $workDir 'resources\icon.ico'
|
||
if (Test-Path $iconIco) {
|
||
$iconLocation = "$iconIco,0"
|
||
} else {
|
||
$iconLocation = "$TargetExe,0"
|
||
}
|
||
|
||
$targets = @(
|
||
(Join-Path ([Environment]::GetFolderPath('Programs')) 'Hermes.lnk'),
|
||
(Join-Path ([Environment]::GetFolderPath('Desktop')) 'Hermes.lnk')
|
||
)
|
||
|
||
foreach ($lnkPath in $targets) {
|
||
try {
|
||
$parent = Split-Path -Parent $lnkPath
|
||
if (-not (Test-Path $parent)) {
|
||
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
||
}
|
||
$sc = $shell.CreateShortcut($lnkPath)
|
||
$sc.TargetPath = $TargetExe
|
||
$sc.WorkingDirectory = $workDir
|
||
$sc.IconLocation = $iconLocation
|
||
$sc.Description = 'Hermes Agent'
|
||
$sc.Save()
|
||
Write-Success "Shortcut created: $lnkPath"
|
||
} catch {
|
||
Write-Warn "Could not create shortcut $lnkPath : $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
# Bust the Windows shell icon cache so the desktop/Start-Menu shortcut
|
||
# repaints with the (possibly newly-stamped) icon instead of a stale
|
||
# cached bitmap. Critical on the --update path: the exe was re-stamped
|
||
# with the Hermes icon, but without this the shortcut can keep drawing
|
||
# the old Electron icon until the user manually refreshes / reboots.
|
||
# Best-effort and silent -- never fail the install over a cosmetic cache.
|
||
try {
|
||
& ie4uinit.exe -show 2>$null
|
||
} catch {
|
||
# ie4uinit may be absent/renamed on some SKUs -- ignore.
|
||
}
|
||
} catch {
|
||
Write-Warn "Skipping shortcut creation: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
function Install-PlatformSdks {
|
||
# Ensure messaging-platform SDKs matching tokens the user added to
|
||
# ~/.hermes/.env are importable. Two problems this solves:
|
||
#
|
||
# 1. The tiered `uv pip install` cascade above can fall through to a
|
||
# lower tier when the first fails (common when RL git deps choke),
|
||
# 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.
|
||
#
|
||
# Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then
|
||
# for each token set in .env, verify the matching SDK imports. If not,
|
||
# run one targeted `pip install` as last-chance recovery. Keeps fresh
|
||
# Windows installs from hitting silent "python-telegram-bot not installed"
|
||
# at runtime.
|
||
if ($NoVenv) {
|
||
Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)"
|
||
return
|
||
}
|
||
|
||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||
if (-not (Test-Path $pythonExe)) {
|
||
Write-Warn "Skipping platform-SDK verification: $pythonExe not found"
|
||
return
|
||
}
|
||
|
||
$envPath = "$HermesHome\.env"
|
||
if (-not (Test-Path $envPath)) { return }
|
||
$envLines = Get-Content $envPath -ErrorAction SilentlyContinue
|
||
|
||
# Map: env var set in .env -> (import name, pip spec matching [messaging] extra).
|
||
# Specs mirror pyproject.toml to avoid version drift.
|
||
$sdkMap = @(
|
||
@{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" },
|
||
@{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" },
|
||
@{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" },
|
||
@{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" },
|
||
@{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" }
|
||
)
|
||
|
||
# Which tokens are actually set (not placeholder)?
|
||
$needed = @()
|
||
foreach ($sdk in $sdkMap) {
|
||
$match = $envLines | Where-Object {
|
||
$_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") `
|
||
-and $_ -notmatch "your-token-here" `
|
||
-and $_ -notmatch "^\s*#"
|
||
}
|
||
if ($match) { $needed += $sdk }
|
||
}
|
||
if ($needed.Count -eq 0) { return }
|
||
|
||
Write-Host ""
|
||
Write-Info "Verifying platform SDKs for tokens found in $envPath ..."
|
||
|
||
# Verify each SDK's import without triggering side-effect imports.
|
||
# Quirk: PowerShell wraps non-zero-exit native stderr as a
|
||
# NativeCommandError that prints even with `2>$null` / `*> $null`
|
||
# unless we set $ErrorActionPreference to SilentlyContinue for the
|
||
# span. Save + restore rather than nuking globally.
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "SilentlyContinue"
|
||
try {
|
||
$missing = @()
|
||
foreach ($sdk in $needed) {
|
||
& $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
$missing += $sdk
|
||
Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))"
|
||
} else {
|
||
Write-Success " $($sdk.Import) OK"
|
||
}
|
||
}
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
}
|
||
if ($missing.Count -eq 0) { return }
|
||
|
||
# Bootstrap pip into the venv if it isn't there. `uv` creates venvs
|
||
# without pip; ensurepip is the stdlib-blessed way to add it.
|
||
$prevEAP = $ErrorActionPreference
|
||
$ErrorActionPreference = "SilentlyContinue"
|
||
try {
|
||
& $pythonExe -m pip --version 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
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-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`""
|
||
return
|
||
}
|
||
}
|
||
|
||
foreach ($sdk in $missing) {
|
||
Write-Info " Installing $($sdk.Spec) ..."
|
||
& $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success " Installed $($sdk.Import)"
|
||
} else {
|
||
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`""
|
||
}
|
||
}
|
||
} finally {
|
||
$ErrorActionPreference = $prevEAP
|
||
}
|
||
}
|
||
|
||
function Invoke-SetupWizard {
|
||
if ($SkipSetup) {
|
||
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
|
||
}
|
||
|
||
function Start-GatewayIfConfigured {
|
||
$envPath = "$HermesHome\.env"
|
||
if (-not (Test-Path $envPath)) { return }
|
||
|
||
$hasMessaging = $false
|
||
$content = Get-Content $envPath -ErrorAction SilentlyContinue
|
||
foreach ($var in @("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WHATSAPP_ENABLED")) {
|
||
$match = $content | Where-Object { $_ -match "^${var}=.+" -and $_ -notmatch "your-token-here" }
|
||
if ($match) { $hasMessaging = $true; break }
|
||
}
|
||
|
||
if (-not $hasMessaging) { return }
|
||
|
||
$hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
|
||
if (-not (Test-Path $hermesCmd)) {
|
||
$hermesCmd = "hermes"
|
||
}
|
||
|
||
# If WhatsApp is enabled but not yet paired, run foreground for QR scan
|
||
$whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" }
|
||
$whatsappSession = "$HermesHome\whatsapp\session\creds.json"
|
||
if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) {
|
||
Write-Host ""
|
||
Write-Info "WhatsApp is enabled but not yet paired."
|
||
Write-Info "Running 'hermes whatsapp' to pair via QR code..."
|
||
Write-Host ""
|
||
# 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)."
|
||
}
|
||
}
|
||
|
||
Write-Host ""
|
||
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]") {
|
||
Write-Info "Starting gateway in background..."
|
||
try {
|
||
$logFile = "$HermesHome\logs\gateway.log"
|
||
Start-Process -FilePath $hermesCmd -ArgumentList "gateway" `
|
||
-RedirectStandardOutput $logFile `
|
||
-RedirectStandardError "$HermesHome\logs\gateway-error.log" `
|
||
-WindowStyle Hidden
|
||
Write-Success "Gateway started! Your bot is now online."
|
||
Write-Info "Logs: $logFile"
|
||
Write-Info "To stop: close the gateway process from Task Manager"
|
||
} catch {
|
||
Write-Warn "Failed to start gateway. Run manually: hermes gateway"
|
||
}
|
||
} else {
|
||
Write-Info "Skipped. Start the gateway later with: hermes gateway"
|
||
}
|
||
}
|
||
|
||
function Write-Completion {
|
||
Write-Host ""
|
||
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 ""
|
||
Write-Host " Config: " -NoNewline -ForegroundColor Yellow
|
||
Write-Host "$HermesHome\config.yaml"
|
||
Write-Host " API Keys: " -NoNewline -ForegroundColor Yellow
|
||
Write-Host "$HermesHome\.env"
|
||
Write-Host " Data: " -NoNewline -ForegroundColor Yellow
|
||
Write-Host "$HermesHome\cron\, sessions\, logs\"
|
||
Write-Host " Code: " -NoNewline -ForegroundColor Yellow
|
||
Write-Host "$HermesHome\hermes-agent\"
|
||
Write-Host ""
|
||
|
||
Write-Host "---------------------------------------------------------" -ForegroundColor Cyan
|
||
Write-Host ""
|
||
Write-Host "* Commands:" -ForegroundColor Cyan
|
||
Write-Host ""
|
||
Write-Host " hermes " -NoNewline -ForegroundColor Green
|
||
Write-Host "Start chatting"
|
||
Write-Host " hermes setup " -NoNewline -ForegroundColor Green
|
||
Write-Host "Configure API keys & settings"
|
||
Write-Host " hermes config " -NoNewline -ForegroundColor Green
|
||
Write-Host "View/edit configuration"
|
||
Write-Host " hermes config edit " -NoNewline -ForegroundColor Green
|
||
Write-Host "Open config in editor"
|
||
Write-Host " hermes gateway " -NoNewline -ForegroundColor Green
|
||
Write-Host "Start messaging gateway (Telegram, Discord, etc.)"
|
||
Write-Host " hermes update " -NoNewline -ForegroundColor Green
|
||
Write-Host "Update to latest version"
|
||
Write-Host ""
|
||
|
||
Write-Host "---------------------------------------------------------" -ForegroundColor Cyan
|
||
Write-Host ""
|
||
Write-Host "[*] Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow
|
||
Write-Host ""
|
||
|
||
if (-not $HasNode) {
|
||
Write-Host "Note: Node.js could not be installed automatically." -ForegroundColor Yellow
|
||
Write-Host "Browser tools need Node.js. Install manually:" -ForegroundColor Yellow
|
||
Write-Host " https://nodejs.org/en/download/" -ForegroundColor Yellow
|
||
Write-Host ""
|
||
}
|
||
|
||
if (-not $HasRipgrep) {
|
||
Write-Host "Note: ripgrep (rg) was not installed. For faster file search:" -ForegroundColor Yellow
|
||
Write-Host " winget install BurntSushi.ripgrep.MSVC" -ForegroundColor Yellow
|
||
Write-Host ""
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# 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 <name> 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 <name>):
|
||
#
|
||
# {
|
||
# "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 = "uv paket yöneticisi kuruluyor"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" }
|
||
@{ Name = "git"; Title = "Git kuruluyor"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" }
|
||
@{ Name = "node"; Title = "Node.js algılanıyor"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" }
|
||
@{ Name = "system-packages"; Title = "ripgrep ve ffmpeg kuruluyor"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" }
|
||
@{ Name = "repository"; Title = "AITURK kaynak kodu indiriliyor"; 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 = "Python $PythonVersion doğrulanıyor"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" }
|
||
@{ Name = "venv"; Title = "Python sanal ortamı oluşturuluyor"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" }
|
||
@{ Name = "dependencies"; Title = "Python bağımlılıkları kuruluyor"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" }
|
||
@{ Name = "node-deps"; Title = "Node.js bağımlılıkları kuruluyor"; 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 = "AITURK çalışma ortamı hazırlanıyor"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" }
|
||
@{ Name = "config-templates"; Title = "Yapılandırma şablonları yazılıyor"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" }
|
||
@{ Name = "platform-sdks"; Title = "Mesajlaşma platformları kuruluyor"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" }
|
||
@{ Name = "bootstrap-marker"; Title = "Kurulum tamamlanıyor"; 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# Main
|
||
# ============================================================================
|
||
|
||
function Invoke-AllStages {
|
||
Step-OutOfInstallDir
|
||
foreach ($s in $InstallStages) {
|
||
Invoke-Stage -StageDef $s
|
||
}
|
||
}
|
||
|
||
function Invoke-EnsureMode {
|
||
param([string]$Deps)
|
||
$depList = $Deps -split ","
|
||
foreach ($dep in $depList) {
|
||
$dep = $dep.Trim()
|
||
switch ($dep) {
|
||
"node" {
|
||
[void](Test-Node)
|
||
if (-not $script:HasNode) {
|
||
Write-Err "Node.js could not be installed"
|
||
exit 1
|
||
}
|
||
}
|
||
"browser" {
|
||
[void](Test-Node)
|
||
if ($script:HasNode) {
|
||
Install-AgentBrowser
|
||
} else {
|
||
Write-Err "Node.js is required for browser tools but could not be installed"
|
||
exit 1
|
||
}
|
||
}
|
||
"ripgrep" {
|
||
Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)"
|
||
}
|
||
"ffmpeg" {
|
||
Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)"
|
||
}
|
||
default {
|
||
Write-Err "Unknown dependency: $dep"
|
||
exit 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function Invoke-PostInstallMode {
|
||
Write-Info "Running post-install setup..."
|
||
Invoke-EnsureMode -Deps "node,browser"
|
||
Write-Info "Post-install complete"
|
||
}
|
||
|
||
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.
|
||
|
||
# Dot-sourcing loads the installer's real functions for isolated behavioral
|
||
# tests without running an install. Normal script and `irm | iex` entry points
|
||
# are unchanged.
|
||
if ($MyInvocation.InvocationName -eq ".") {
|
||
return
|
||
}
|
||
|
||
try {
|
||
if ($Ensure -ne "") {
|
||
if ($PSBoundParameters.ContainsKey("Stage")) {
|
||
Write-Err "Cannot use -Ensure and -Stage simultaneously"
|
||
exit 1
|
||
}
|
||
Invoke-EnsureMode -Deps $Ensure
|
||
exit 0
|
||
}
|
||
if ($PostInstall) {
|
||
Invoke-PostInstallMode
|
||
exit 0
|
||
}
|
||
|
||
if ($ProtocolVersion) {
|
||
Write-Output $InstallStageProtocolVersion
|
||
exit 0
|
||
}
|
||
|
||
if ($ShowResolvedPaths) {
|
||
$script:ResolvedPathReport | ConvertTo-Json -Depth 5 -Compress | Write-Output
|
||
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
|
||
}
|
||
|
||
# Use PSBoundParameters rather than $Stage truthiness so that an
|
||
# explicit `-Stage ""` from a misbehaving driver doesn't fall through
|
||
# to the full-install Main path and silently kick off a destructive
|
||
# operation. Empty string is a contract violation; surface it as
|
||
# unknown-stage exit 2 with a structured JSON frame.
|
||
if ($PSBoundParameters.ContainsKey("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 -- BUT only if
|
||
# Invoke-Stage didn't already emit one for this same failure.
|
||
# The inner finally emits the authoritative per-stage frame
|
||
# (with duration_ms + skipped fields); a second emit here
|
||
# would produce two concatenated JSON objects on stdout and
|
||
# break drivers that parse one-line-per-invocation.
|
||
if (-not $script:_StageEmittedErrorFrame) {
|
||
$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 ""
|
||
Write-Info "If the error is unclear, try downloading and running the script directly:"
|
||
Write-Host " Invoke-WebRequest -Uri 'https://hermes-agent.nousresearch.com/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow
|
||
Write-Host " .\install.ps1" -ForegroundColor Yellow
|
||
Write-Host ""
|
||
}
|