diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 8974519ef4a5..746b5090faaf 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -443,7 +443,7 @@ function Get-PowerShellHostExe { } function Install-Uv { - # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there — + # 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. @@ -528,7 +528,7 @@ function Ensure-NodeExeOnPath { # 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 +# 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 @@ -544,7 +544,7 @@ function Resolve-UvCmd { # Stale; fall through to re-discover. } - # Check the managed location first — this is where Install-Uv puts it. + # 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 @@ -1516,7 +1516,7 @@ function Install-Repository { 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 + # 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) { @@ -1573,11 +1573,11 @@ function Install-Repository { Write-Host "" Write-Host "Conflicted files:" foreach ($file in $conflictedFiles) { - Write-Host " • $file" + Write-Host " - $file" } } Write-Host "" - Write-Info "Your stashed changes are preserved — nothing is lost." + 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." @@ -1798,8 +1798,8 @@ function Install-Venv { # /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. + # 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 { @@ -1893,7 +1893,7 @@ function Install-Venv { } # Clean up parked venvs from previous installs whose handles have since - # been released. Best-effort — a still-held tree just stays for next time. + # been released. Best-effort -- a still-held tree just stays for next time. Get-ChildItem -Directory -Filter "venv.stale.*" -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue } @@ -1926,10 +1926,10 @@ function Install-Venv { } 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 + # -- 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; + # 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) { @@ -2247,7 +2247,7 @@ function Set-PathVariable { 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 + # successfully -- DON'T trigger the legacy first-launch bootstrap # runner." # # Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap @@ -2268,7 +2268,7 @@ function Write-BootstrapMarker { # 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. + # >= 7) -- better to be invalid than wrong. $pinnedCommit = $Commit if (-not $pinnedCommit) { # PS 5.1 doesn't support the ?. null-conditional operator, so @@ -2283,7 +2283,7 @@ function Write-BootstrapMarker { $pinnedCommit = $resolved.Trim() } } catch { - # Ignore — pinnedCommit stays empty, marker stays invalid, + # Ignore -- pinnedCommit stays empty, marker stays invalid, # desktop falls through to its legacy bootstrap path. } finally { Pop-Location @@ -2302,7 +2302,7 @@ function Write-BootstrapMarker { pinnedCommit = $pinnedCommit pinnedBranch = $pinnedBranch completedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") - # desktopVersion field intentionally omitted — only the desktop + # 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). @@ -2311,7 +2311,7 @@ function Write-BootstrapMarker { # 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 + # 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. @@ -2411,7 +2411,7 @@ function Install-NodeDeps { # 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 + # 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)" @@ -2688,7 +2688,7 @@ function Clear-ElectronBuildCache { # 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. +# 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' @@ -2769,7 +2769,7 @@ function Install-Desktop { # # 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 + # 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, @@ -2823,7 +2823,7 @@ function Install-Desktop { # # 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 + # 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 @@ -2878,7 +2878,7 @@ function Install-Desktop { # 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 + # (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. @@ -2949,7 +2949,7 @@ function Install-Desktop { Pop-Location throw } finally { - # Restore env to whatever the caller had — don't leak our + # 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 @@ -2980,7 +2980,7 @@ function Install-Desktop { # 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 + # 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 @@ -2990,7 +2990,7 @@ function Install-Desktop { # 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. + # 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 @@ -3006,7 +3006,7 @@ function Install-Desktop { # 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 — + # 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 @@ -3062,11 +3062,11 @@ function New-DesktopShortcuts { # 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. + # 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. + # ie4uinit may be absent/renamed on some SKUs -- ignore. } } catch { Write-Warn "Skipping shortcut creation: $($_.Exception.Message)" diff --git a/tests/test_install_ps1_ascii_only.py b/tests/test_install_ps1_ascii_only.py new file mode 100644 index 000000000000..71638620c2a5 --- /dev/null +++ b/tests/test_install_ps1_ascii_only.py @@ -0,0 +1,55 @@ +"""Regression: install.ps1 must stay pure ASCII. + +Issues #66994 / #67000 reported the Windows GUI installer (Hermes-Setup.exe) +crashing before it did anything, with a cascade of PowerShell parser errors at +lines 1619 / 1770 ("Missing argument in parameter list", "A 'using' statement +must appear before any other statements in a script"). + +Root cause: ``scripts/install.ps1`` has no UTF-8 BOM, and a commit added two +non-ASCII characters *inside double-quoted string literals* (a bullet and an +em-dash). Windows PowerShell 5.1 -- which the bootstrap runs the cached script +under -- reads a BOM-less ``.ps1`` in the system ANSI code page (e.g. CP1252), +not UTF-8. The em-dash's UTF-8 tail byte (0x94) decodes to a "smart" close-quote +(U+201D), which the PowerShell tokenizer treats as a string delimiter. That +prematurely closes the string and desyncs the parser for the rest of the file, +surfacing as unrelated syntax errors far downstream. + +Non-ASCII bytes inside ``#`` comments are harmless (the tokenizer skips a +comment to end-of-line regardless of what it contains), which is why the file +carried em-dashes in comments for months without breaking -- only a non-ASCII +byte in *code* (a string literal) triggers the desync. + +This test is source-level because Linux CI cannot execute the PowerShell +installer. Keeping the whole file ASCII-only is the transport-independent +invariant: pure ASCII cannot be misdecoded under any code page, BOM or not, so +the bug class cannot recur -- in a comment or a string. +""" + +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" + + +def test_install_ps1_is_pure_ascii() -> None: + raw = INSTALL_PS1.read_bytes() + + offenders = [] + line_no = 1 + for byte in raw: + if byte == 0x0A: + line_no += 1 + elif byte >= 0x80: + offenders.append(line_no) + + assert not offenders, ( + "scripts/install.ps1 must be pure ASCII so Windows PowerShell 5.1 " + "(which reads a BOM-less .ps1 in the system ANSI code page, not UTF-8) " + "cannot misdecode a byte into a stray quote and desync the parser " + "(issues #66994 / #67000). Non-ASCII bytes found on line(s): " + f"{sorted(set(offenders))}. Use ASCII equivalents (em-dash -> '--', " + "bullet -> '-')." + )