Compare commits

...

108 Commits

Author SHA1 Message Date
ethernet
10047b9ab6 w 2026-06-20 13:17:16 -04:00
ethernet
0542ecfa26 wwww 2026-06-20 13:10:56 -04:00
ethernet
e3c079a95c wwwww 2026-06-20 13:08:47 -04:00
ethernet
27bcbefde2 concluciosn 2026-06-20 13:08:15 -04:00
ethernet
4ba42d7363 errorstdout 2026-06-20 12:58:55 -04:00
ethernet
4d1ec85187 wwwwwww 2026-06-20 12:56:27 -04:00
ethernet
e3da311fe0 wip wip 2026-06-20 12:35:19 -04:00
ethernet
62aab6625f wwwwwwwwww 2026-06-20 10:07:03 -04:00
ethernet
3521201313 run the install! 2026-06-20 10:04:50 -04:00
ethernet
dea5d7065a aight can we cop UV better 2026-06-19 11:57:53 -04:00
ethernet
c741366975 irm iem 2026-06-19 11:46:32 -04:00
ethernet
ac3d142fc3 rip out useless tests
these just assert stuff in source code. they don't test behavior. this
is ridiculous lol
2026-06-19 11:46:24 -04:00
ethernet
4b981ae846 oopsie bnix hashes 2026-06-19 11:30:34 -04:00
ethernet
2502b28f27 nix hashes 2026-06-19 11:23:55 -04:00
ethernet
729d5a0b8e bindir 2026-06-19 11:23:49 -04:00
ethernet
8b7603cfe8 nice 2026-06-18 18:53:56 -04:00
ethernet
b02c6c4efc wwww 2026-06-18 18:46:56 -04:00
ethernet
257f3a4f04 logs? 2026-06-18 18:35:44 -04:00
ethernet
8f5acbf070 wwwwwww 2026-06-18 18:24:46 -04:00
ethernet
d2bd8685b3 logs 2026-06-18 18:07:08 -04:00
ethernet
72ddbf3eab asdasd 2026-06-18 17:55:29 -04:00
ethernet
b355ce8ece clicky 2026-06-18 17:55:04 -04:00
ethernet
e313e3e121 install 2026-06-18 17:43:38 -04:00
ethernet
290ad7aee5 mousescreen 2026-06-18 17:35:53 -04:00
ethernet
182d5da698 lick fix 2026-06-18 17:31:51 -04:00
ethernet
8bafb27fe0 f 2026-06-18 17:06:32 -04:00
ethernet
defa573d94 hehe 2026-06-18 17:03:19 -04:00
ethernet
25be4ec471 fmt 2026-06-18 17:01:10 -04:00
ethernet
5acc0e8f38 a 2026-06-18 16:56:49 -04:00
ethernet
151806bd95 wwwww 2026-06-18 16:52:20 -04:00
ethernet
f739a035db wwwwww 2026-06-18 16:48:36 -04:00
ethernet
4b8d696e88 www 2026-06-18 16:45:21 -04:00
ethernet
7de9695e8b keep going 2026-06-18 16:43:25 -04:00
ethernet
c601b4db3a w 2026-06-18 16:35:21 -04:00
ethernet
e591272d3e logs 2026-06-18 16:33:10 -04:00
ethernet
adb9f46eef ggg 2026-06-18 16:26:15 -04:00
ethernet
60b2bc99cd asdasdfasdf 2026-06-18 16:12:21 -04:00
ethernet
915f658da9 wwww 2026-06-18 16:12:21 -04:00
ethernet
4d88c0f161 wwwww 2026-06-18 16:12:21 -04:00
ethernet
7bf9e37167 wwwwwwwwwwwwwwwww 2026-06-18 16:12:21 -04:00
ethernet
0e59384410 ccccca 2026-06-18 16:12:21 -04:00
ethernet
e4457e75ca cont 2026-06-18 16:12:21 -04:00
ethernet
ce98264423 www\ 2026-06-18 16:12:21 -04:00
ethernet
a1564c0555 wwwwww 2026-06-18 16:12:21 -04:00
ethernet
a2abd0ca72 env? 2026-06-18 16:12:21 -04:00
ethernet
b4131d7ac6 fix cache weird idk uploady 2026-06-18 16:12:21 -04:00
ethernet
d14d35db5d wwwwwww 2026-06-18 16:12:21 -04:00
ethernet
12bb9725d3 g 2026-06-18 16:12:21 -04:00
ethernet
bdbc75df51 lalala
`
2026-06-18 16:12:21 -04:00
ethernet
7eb3c1fdd2 gg 2026-06-18 16:12:21 -04:00
ethernet
c57cbe6ad6 wwwww 2026-06-18 16:12:21 -04:00
ethernet
3d4317ea8a why 2026-06-18 16:12:21 -04:00
ethernet
664f4f285c wwww 2026-06-18 16:12:21 -04:00
ethernet
b19f0f0020 fix 2026-06-18 16:12:21 -04:00
ethernet
f8166541d6 a 2026-06-18 16:12:21 -04:00
ethernet
0b84b8f09f keep goin 2026-06-18 16:12:21 -04:00
ethernet
3ad131eddf www 2026-06-18 16:12:14 -04:00
ethernet
db10a1d535 mkv 2026-06-18 16:12:14 -04:00
ethernet
c30a20b097 aawawa 2026-06-18 16:12:14 -04:00
ethernet
d65c427b46 cache bust 2026-06-18 16:12:14 -04:00
ethernet
e0e6f9fa8f dont pin to build commit 2026-06-18 16:12:14 -04:00
ethernet
d8d3ac120f babababa 2026-06-18 16:12:14 -04:00
ethernet
64a59a8ecd stdin redir? 2026-06-18 16:12:14 -04:00
ethernet
fdddf8c2f6 ffmpeg2 2026-06-18 16:12:14 -04:00
ethernet
f65ae38f2c yay 2026-06-18 16:12:14 -04:00
ethernet
9b6eef489b asdasd 2026-06-18 16:12:14 -04:00
ethernet
c099065064 ww 2026-06-18 16:12:14 -04:00
ethernet
fe1a02ae0b ffmpeg 2026-06-18 16:12:14 -04:00
ethernet
c2b2034dd5 www 2026-06-18 16:12:14 -04:00
ethernet
83f893f6a2 dirs 2026-06-18 16:12:14 -04:00
ethernet
f4652ac940 dir 2026-06-18 16:12:14 -04:00
ethernet
c9f40a5118 installdir 2026-06-18 16:12:14 -04:00
ethernet
e17d3174c5 ww 2026-06-18 16:12:14 -04:00
ethernet
2f7bd2f425 www 2026-06-18 16:12:14 -04:00
ethernet
9c1377dd71 typo 2026-06-18 16:12:14 -04:00
ethernet
4b20e00a14 bs 2026-06-18 16:12:14 -04:00
ethernet
5b8309ee07 www 2026-06-18 16:12:14 -04:00
ethernet
e865350442 fix weird checkout 2026-06-18 16:12:14 -04:00
ethernet
c8021ba5ed ffmpreg 2026-06-18 16:12:14 -04:00
ethernet
b9b52dde9b heckout optimization 2026-06-18 16:12:14 -04:00
ethernet
042de5911e fixie 2026-06-18 16:12:14 -04:00
ethernet
bf05cf270c ahk and thing 2026-06-18 16:12:14 -04:00
ethernet
e69d782577 done 2026-06-18 16:12:14 -04:00
ethernet
1f509b9459 ahk dir correct 2026-06-18 16:12:14 -04:00
ethernet
63ab33ec76 aaa 2026-06-18 16:12:14 -04:00
ethernet
5e0cd3e75c w 2026-06-18 16:12:14 -04:00
ethernet
22b5329532 w 2026-06-18 16:12:14 -04:00
ethernet
7cc5123ca1 windwos ache 2026-06-18 16:12:14 -04:00
ethernet
81b9f8bc8f dl artifact 2026-06-18 16:12:14 -04:00
ethernet
13417bb1b9 simpler installer cache 2026-06-18 16:12:14 -04:00
ethernet
a9a9e5addf wip 2026-06-18 16:12:14 -04:00
ethernet
5bc4c4b950 wwwwwwww 2026-06-18 16:12:14 -04:00
ethernet
7fdc7211f6 wip wip installer 2026-06-18 16:12:14 -04:00
ethernet
d844bc2f71 cache 2026-06-18 16:12:14 -04:00
ethernet
f87ed7ba03 always npm 2026-06-18 16:12:14 -04:00
ethernet
fea41c2585 ww 2026-06-18 16:12:14 -04:00
ethernet
8c11153d44 ahk 2026-06-18 16:12:14 -04:00
ethernet
f66b676423 check 2026-06-18 16:12:14 -04:00
ethernet
841926ca4f winget paths 2026-06-18 16:12:14 -04:00
ethernet
8977a5e3b4 installers 2026-06-18 16:12:14 -04:00
ethernet
3bedf4bd97 wwwwwwww 2026-06-18 16:12:14 -04:00
ethernet
71ca055988 ahk 2026-06-18 16:12:14 -04:00
ethernet
2617034684 t 2026-06-18 16:12:14 -04:00
ethernet
fd86766077 longer timeout 2026-06-18 16:12:14 -04:00
ethernet
4c2d7661fd p 2026-06-18 16:12:14 -04:00
ethernet
8a91c3034f tc 2026-06-18 16:12:14 -04:00
ethernet
5ebc28516e w 2026-06-18 16:12:14 -04:00
ethernet
d33975816b wip e2e 2026-06-18 16:12:14 -04:00
14 changed files with 889 additions and 327 deletions

View File

@@ -1,100 +0,0 @@
name: Build Windows Installer
on:
workflow_dispatch:
permissions:
contents: read
jobs:
# Gate: workflow_dispatch is already restricted to users with write access,
# but we want ADMIN-only. Explicitly check the triggering actor's repo
# permission via the API and fail fast for anyone below admin.
authorize:
name: Authorize (admins only)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check actor is a repo admin
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
perm=$(gh api \
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
--jq '.permission')
echo "Actor '${ACTOR}' has permission: ${perm}"
if [ "${perm}" != "admin" ]; then
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
exit 1
fi
echo "Authorized: '${ACTOR}' is an admin."
build:
name: Hermes-Setup.exe
needs: authorize
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
# Required for OIDC auth to Azure (azure/login federated credentials).
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Install npm dependencies
run: npm ci
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Cache Rust targets
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/bootstrap-installer/src-tauri
- name: Build installer
run: npm run tauri:build
working-directory: apps/bootstrap-installer
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
with:
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
# Sign both the raw exe and the bundled NSIS installer.
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
files-folder-filter: exe
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Upload NSIS installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-installer
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
- name: Upload raw exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-exe
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe

366
.github/workflows/e2e-windows.yml vendored Normal file
View File

@@ -0,0 +1,366 @@
name: E2E Windows Desktop
on:
push:
branches: [ethie/e2e]
workflow_dispatch:
concurrency:
group: e2e-windows-${{ github.ref }}
cancel-in-progress: true
jobs:
# this is separated so we don't have node.js and stuff
build-installer:
name: Build Hermes-Setup.exe
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: checkout cache inputs
uses: actions/checkout@v4
with:
sparse-checkout: |
package-lock.json
apps/bootstrap-installer
sparse-checkout-cone-mode: true
# The cache key is the exact installer build fingerprint. A hit means
# this package-lock + bootstrap-installer source combo was already built,
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
- name: Restore installer build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
id: installer-cache
with:
path: Hermes-Setup.exe
key: hermes-installer-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
- name: Setup Node.js
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Setup Rust
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: dtolnay/rust-toolchain@1.96.0 # stable
- name: checkout full tree on cache miss
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/checkout@v4
- name: Install npm dependencies
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build installer
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm run tauri:build
working-directory: apps/bootstrap-installer
timeout-minutes: 10
env:
HERMES_BUILD_PIN_BRANCH: "main" # build the installer exactly as it would build on `main`. we'll override this when running it.
# Only runs on cache miss. Pick the exe the Tauri build produced and
# normalize its name so downstream jobs always know what to download.
- name: Normalize installer artifact name
if: steps.installer-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$candidates = @(
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
)
$installer = $null
foreach ($c in $candidates) {
if (Test-Path $c) {
$installer = Resolve-Path $c
break
}
}
if (-not $installer) {
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $installer) {
throw 'Could not find built Hermes-Setup.exe'
}
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
e2e:
name: Run installer test
needs: build-installer
runs-on: windows-latest
timeout-minutes: 60
env:
# Isolated install directory so the real install flow doesn't touch the
# runner's user profile. Kept under the workspace for easy cleanup.
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
steps:
- uses: actions/checkout@v4
with:
path: source
- name: Restore installer from build cache
id: installer-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: Hermes-Setup.exe
key: hermes-installer-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
- name: Restore cached test tools
id: test-tools-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: test-bins
key: test-bins-${{ runner.os }}-v1
- name: Install AutoHotkey v2 and ffmpeg
if: steps.test-tools-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
# Install fresh when the cache missed.
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
if (-not (Test-Path $ahkDir)) {
throw "AutoHotkey install directory not found: $ahkDir"
}
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
# ffmpeg : just install into dir
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
- name: Add test-bins to PATH
shell: pwsh
run: |
ls "$PWD\test-bins\ffmpeg"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
# actions/checkout already has the right commit; just mirror it into the
# isolated home so the installer doesn't need to reach GitHub.
- name: Move checked-out workspace into isolated HERMES_HOME
shell: pwsh
run: |
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
# ── Run the headed installer + AHK helper ───────────────────────
- name: Launch Hermes-Setup.exe and install it
shell: pwsh
timeout-minutes: 10
env:
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
run: |
$installer = "Hermes-Setup.exe"
# ── Start screen recording (live stdin pipe) ──────────────────
# ffmpeg must be started, fed, and stopped from the SAME step: the
# graceful-stop signal is the character 'q' written to ffmpeg's live
# stdin. A separate teardown step can't do this because the process
# that owns the writable stdin pipe dies when this step ends.
#
# Start-Process / -RedirectStandardInput <file> does NOT work: it
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
# the file on disk never reaches the running process. We need a real
# writable pipe, which only System.Diagnostics.Process exposes.
#
# -t 600 is a belt-and-suspenders cap so the container still
# finalizes even if the 'q' is somehow missed; -pix_fmt yuv420p keeps
# the output broadly playable.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "ffmpeg"
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop -t 600 " +
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
$ffmpeg.Id | Out-File ffmpeg.pid
# Note: stderr is intentionally left attached to the console so it is
# captured in the step log. Do NOT redirect a stream we don't drain --
# ffmpeg's progress output would fill the pipe buffer and block.
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id))"
$installerSuccess = $false
try {
# Launch the real installer
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
$proc.Id | Out-File installer.pid
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
-ArgumentList "$env:INSTALL_DIR\e2e\windows\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow `
-ErrorStdOut
-RedirectStandardOutput ahk-stdout.log -RedirectStandardError ahk-stderr.log
# Wait for AHK helper to finish. can take a long time for installer!
$ahkProc | Wait-Process -Timeout (60 * 8) -ErrorAction SilentlyContinue
if (-not $ahkProc.HasExited) {
Write-Host "AHK helper is still running; stopping it"
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
}
# Installer should have exited
if ($proc.HasExited) {
# TODO check exit code once we add exit code in installer
$installerSuccess = $true
} else {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
finally {
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
# the container header/index are finalized and the mkv is playable.
# This runs in the same step that owns the pipe, even on failure.
if ($ffmpeg -and -not $ffmpeg.HasExited) {
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
try {
$ffmpeg.StandardInput.Write("q")
$ffmpeg.StandardInput.Close()
} catch {
Write-Host "Failed to write q to ffmpeg stdin: $_"
}
if (-not $ffmpeg.WaitForExit(15000)) {
Write-Host "ffmpeg did not exit after 15s; killing"
$ffmpeg.Kill()
}
}
Write-Host "ffmpeg stopped"
if (-not $installerSuccess) {
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
}
}
# ── Run Playwright against the installed binary ─────────────────
# (placeholder: will be enabled once installer completes successfully.)
- name: Launch installed app and run e2e
if: false
working-directory: source/apps/desktop
run: npx playwright test e2e/ --reporter=list
env:
# Point the e2e spec at the desktop binary that the installer built.
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
# ── Teardown & artifacts ────────────────────────────────────────
# ffmpeg is normally started AND gracefully stopped inside the launch
# step (so 'q' reaches its live stdin pipe). This step is only a
# safety net: if the launch step timed out or crashed before its
# finally block ran, force-kill any orphaned ffmpeg so the runner can
# release recording.mkv for upload. The mkv container survives a hard
# kill (only the trailing seek index is lost), so the artifact is still
# usable for coordinate discovery even on this fallback path.
- name: Stop orphaned screen recording (safety net)
if: always()
shell: pwsh
run: |
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
if ($ffmpegpid) {
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
} else {
Write-Host "ffmpeg already exited cleanly; nothing to do"
}
}
- name: Burn debug overlay into recording
if: always()
shell: pwsh
run: |
$logPath = Join-Path $PWD 'ahk.log'
$x = $y = $w = $h = $null
if (Test-Path $logPath) {
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
if ($line) {
$x = [int]$line.Matches[0].Groups[1].Value
$y = [int]$line.Matches[0].Groups[2].Value
$w = [int]$line.Matches[0].Groups[3].Value
$h = [int]$line.Matches[0].Groups[4].Value
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
} else {
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
}
} else {
Write-Host "ahk.log not found; only timestamp will be burned"
}
# Build the timestamp overlay
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
# Window border + 16px grid only over the window + axis labels
$grid = "drawbox=x=$x`:y=$y`:w=$w`:h=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
}
Write-Host "Overlay filter: $vf"
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
if ($LASTEXITCODE -ne 0) {
throw "ffmpeg overlay failed"
}
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
Write-Host "Overlayed recording saved as recording.mkv"
- name: Upload screen recording
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: upload-recording
if: always()
with:
name: screen-recording-${{ github.sha }}
path: recording.mkv
retention-days: 1
archive: false
overwrite: true
- name: Link screen recording in job summary
if: always()
shell: pwsh
env:
MKV_URL: ${{ steps.upload-recording.outputs.artifact-url }}
GIF_URL: ${{ steps.upload-gif.outputs.artifact-url }}
run: |
Add-Content $env:GITHUB_STEP_SUMMARY "## Installer screen recording"
Add-Content $env:GITHUB_STEP_SUMMARY ""
Add-Content $env:GITHUB_STEP_SUMMARY "[download recording.mkv]($env:MKV_URL)"
- name: Bootstrap Installer log
if: always()
shell: pwsh
run: |
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
- name: Autohotkey log
if: always()
shell: pwsh
run: |
Get-Content ahk.log
- name: Autohotkey stdout
if: always()
shell: pwsh
run: |
Get-Content ahk-stdout.log
- name: Autohotkey stderr
if: always()
shell: pwsh
run: |
Get-Content ahk-stderr.log

View File

@@ -0,0 +1,208 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
/**
* E2E smoke tests for the built Hermes desktop app.
*
* These tests launch the real packaged Electron binary (produced by
* `npm run pack` → `electron-builder --dir`) with:
* - HERMES_DESKTOP_BOOT_FAKE=1 — simulates boot progress without
* spawning a real Hermes backend
* - HERMES_DESKTOP_USER_DATA_DIR — isolated electron userData
* - HERMES_DESKTOP_IGNORE_EXISTING=1 — forces the bootstrap path
* - HERMES_HOME — isolated throwaway directory
* - All credential env vars stripped
*
* The binary path is resolved per-platform to match electron-builder's
* output layout. On Windows CI the app is built with `npm run pack`
* before these tests run.
*/
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const BINARY_PATH: string = (() => {
const downloadsExe = path.join(os.homedir(), 'Downloads', 'Hermes-Setup.exe')
if (fs.existsSync(downloadsExe)) {
return downloadsExe
}
const platform = process.platform
if (platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
if (platform === 'win32') {
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
})()
// Credential-suffix filter — matches test-desktop.mjs's isCredentialEnvVar.
const CREDENTIAL_SUFFIXES: string[] = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN',
]
const CREDENTIAL_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL',
])
function isCredentialEnvVar(name: string): boolean {
if (CREDENTIAL_NAMES.has(name)) {return true}
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
}
function buildSandboxEnv(): { env: Record<string, string>; sandbox: string } {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-e2e-'))
const userDataDir = path.join(sandbox, 'electron-user-data')
const hermesHome = path.join(sandbox, 'hermes-home')
fs.mkdirSync(userDataDir, { recursive: true })
fs.mkdirSync(hermesHome, { recursive: true })
// Strip credentials, inject sandboxed env.
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {continue}
if (isCredentialEnvVar(key)) {continue}
env[key] = value
}
// Fake boot: simulates progress steps without spawning the real backend.
env.HERMES_DESKTOP_BOOT_FAKE = '1'
env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS = '120'
// Force bootstrap path even if a hermes install exists on the runner.
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
// Isolate electron's userData and HERMES_HOME to the sandbox.
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
env.HERMES_HOME = hermesHome
// Clear any dev-server override — we want the packaged renderer, not vite.
delete env.HERMES_DESKTOP_DEV_SERVER
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
return { env, sandbox }
}
let app: ElectronApplication
let page: Page
let sandbox: string
test.beforeAll(async () => {
test.skip(
!fs.existsSync(BINARY_PATH),
`Built app binary not found: ${BINARY_PATH}. Run 'npm run pack' first.`
)
const { env, sandbox: dir } = buildSandboxEnv()
sandbox = dir
app = await _electron.launch({
executablePath: BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox', '--disable-software-rasterizer'],
env,
})
page = await app.firstWindow()
})
test.afterAll(async () => {
await app?.close().catch(() => undefined)
try {
if (sandbox) {fs.rmSync(sandbox, { recursive: true, force: true })}
} catch {
// best-effort cleanup
}
})
test('window opens with the Hermes title', async () => {
// The main.cjs sets the window title to APP_NAME ('Hermes') during
// createBrowserWindow. Verify it before anything else.
const title = await page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx). Give it a generous timeout for cold boot on CI.
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
// The root should have children after React hydrates — the boot overlay
// or the main app shell.
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
// With BOOT_FAKE mode the app simulates boot progress steps. Without a
// real backend, boot will eventually fail — the app shows a
// BootFailureOverlay. Either outcome (success → overlay disappears,
// failure → error overlay renders) proves the renderer is working.
//
// Wait for one of:
// (a) the boot overlay disappears (renderer.ready), OR
// (b) an error message becomes visible (boot failure path)
//
// Use a waitForFunction so we don't depend on specific CSS selectors
// that might change between refactors.
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {return false}
const text = root.textContent ?? ''
// Error path: boot failure overlay renders an error message.
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
return true
}
// Success path: overlay disappears and the app renders. Look for
// a chat input, sidebar, or settings gear as indicators.
// If there's no "boot" / "starting" / "installing" text visible,
// boot has completed (either to the main UI or to onboarding).
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
const lower = text.toLowerCase()
return !bootIndicators.some((word) => lower.includes(word))
},
{ timeout: 60_000 }
)
})
test('can capture a screenshot for the CI artifact', async () => {
// This doubles as both a sanity check (page is renderable) and a
// useful CI artifact — the screenshot is attached to the test report.
const screenshot = await page.screenshot()
expect(screenshot.byteLength).toBeGreaterThan(0)
})

View File

@@ -43,6 +43,8 @@
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt",
"test:e2e": "playwright test e2e/",
"test:e2e:headed": "cross-env HEADED=1 playwright test e2e/",
"test:ui": "vitest run --environment jsdom",
"preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174"
},
@@ -106,6 +108,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",

View File

@@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
/* Test files live under e2e/ so they never collide with the vitest suite
* under src/ or the node:test files under electron/. */
testDir: './e2e',
/* The desktop app can take a while to bootstrap on cold CI runners — 90 s
* per test gives us headroom without masking real hangs. */
timeout: 90_000,
retries: process.env.CI ? 1 : 0,
/* Each test gets its own worker so the Electron process is fully isolated. */
fullyParallel: false,
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
use: {
/* Capture traces and videos on failure — invaluable when the CI runner
* has no display we can watch live. */
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
})

View File

@@ -21,5 +21,6 @@
}
},
"include": ["src", "../shared/src"],
"exclude": ["e2e", "electron", "playwright.config.ts"],
"references": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,90 @@
#Requires AutoHotkey v2.0
#SingleInstance Force
SetWorkingDir(A_ScriptDir)
CoordMode("Pixel", "Screen")
CoordMode("Mouse", "Screen")
logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log"
ClickWithMarker(x, y, button := "Left") {
Click(x, y, button)
Sleep(10)
ToolTip(Format("Clicking at {1}, {2}", x, y))
size := 20
g := Gui("-Caption +AlwaysOnTop +ToolWindow")
g.BackColor := "Red"
g.Show(Format(
"x{} y{} w{} h{} NoActivate"
, x - size // 2
, y - size // 2
, size
, size
))
hRegion := DllCall(
"CreateEllipticRgn"
, "Int", 0
, "Int", 0
, "Int", size
, "Int", size
, "Ptr"
)
DllCall("SetWindowRgn", "Ptr", g.Hwnd, "Ptr", hRegion, "Int", true)
WinSetTransparent(255, g.Hwnd)
SetTimer(() => g.Destroy(), -500)
}
ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250)
{
WinGetPos(&wx, &wy, &ww, &wh, winTitle)
img := LoadPicture(imageFile, , &imgType)
width := img.W
height := img.H
startTime := A_TickCount
timeLeft := (A_TickCount - startTime) - timeoutMs
while (timeLeft > 0)
{
try
{
if ImageSearch(&x, &y, wx, wy, wx + ww, wy + wh, imageFile)
{
ClickWithMarker(x + Floor(width / 2), y + Floor(height / 2))
return
}
}
Sleep intervalMs
timeLeft := (A_TickCount - startTime) - timeoutMs
ToolTip(Format("Searching for button {} in window {}... {}s left", imageFile, winTitle, timeLeft / 1000))
}
throw Error("failed to find button in window.")
}
ToolTip("Waiting for the installer window to appear...")
winTitle := "Hermes"
try {
WinWait(winTitle, , 30)
} catch {
FileAppend("ERROR: Hermes installer window did not appear within 30s`n", logPath)
ExitApp(1)
}
WinGetPos(&x, &y, &w, &h, winTitle)
FileAppend(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h), logPath)
ToolTip(Format("Installer window appeared at x={1} y={2} w={3} h={4}. Sleeping for a few seconds.....", x, y, w, h))
ClickCenterOfImageInWindow(winTitle, "install-button.png")
Sleep(10000)
ClickCenterOfImageInWindow(winTitle, "install-button.png", 60 * 60 * 20)
; done
ExitApp(0)

View File

@@ -16,6 +16,8 @@ import platform
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import Optional
@@ -239,16 +241,70 @@ def _install_uv_posix(env: dict[str, str]) -> None:
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
"""Download the uv binary zip directly from GitHub releases.
We intentionally do NOT run the astral installer script
(``irm https://astral.sh/uv/install.ps1 | iex``) anymore. That script
calls ``Get-ExecutionPolicy`` internally (from the
``Microsoft.PowerShell.Security`` module), and on some Windows installs
that module fails to load -- killing the installer before it can download
anything. Downloading the zip ourselves with stdlib avoids spawning any
PowerShell child process at all, sidestepping the broken module entirely.
"""
# Detect the real OS architecture. platform.machine() reports the
# emulated view (AMD64 on ARM under Prism), so prefer the env vars that
# reflect the actual hardware. Mirrors Get-WindowsArch in install.ps1.
proc_arch = (
os.environ.get("PROCESSOR_ARCHITEW6432")
or os.environ.get("PROCESSOR_ARCHITECTURE", "")
).upper()
if proc_arch in ("ARM64",):
target_triple = "aarch64-pc-windows-msvc"
elif proc_arch in ("AMD64", "X64"):
target_triple = "x86_64-pc-windows-msvc"
elif proc_arch in ("X86",):
target_triple = "i686-pc-windows-msvc"
else:
# Fallback: platform.machine(). On native x64 this is "AMD64".
machine = platform.machine().upper()
if machine in ("ARM64", "AARCH64"):
target_triple = "aarch64-pc-windows-msvc"
elif machine in ("AMD64", "X64"):
target_triple = "x86_64-pc-windows-msvc"
else:
target_triple = "i686-pc-windows-msvc"
zip_name = f"uv-{target_triple}.zip"
urls = [
f"https://github.com/astral-sh/uv/releases/latest/download/{zip_name}",
f"https://releases.astral.sh/github/uv/releases/latest/download/{zip_name}",
]
with tempfile.TemporaryDirectory() as tmp:
zip_path = Path(tmp) / zip_name
last_err: Exception | None = None
for url in urls:
try:
logging.debug("Downloading uv from %s", url)
urllib.request.urlretrieve(url, zip_path)
break
except Exception as exc:
last_err = exc
logging.debug("Download failed from %s: %s", url, exc)
else:
raise RuntimeError(
f"Failed to download uv from all mirrors: {last_err}"
) from last_err
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(tmp)
# Move every .exe from the archive into the target's parent (the
# managed bin dir). The zip layout is flat (uv.exe, uvx.exe) but
# handle nested just in case.
bin_dir = env.get("UV_INSTALL_DIR") or str(Path(env.get("UV_UNMANAGED_INSTALL", "")).parent)
for exe in Path(tmp).rglob("*.exe"):
shutil.copy2(exe, Path(bin_dir) / exe.name)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
True # dont remove me. ask ethernet

View File

@@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo=";
npmDepsHash = "sha256-k6v5qo56UQ3vx/thUrtDe3lX9jQTSZMIp2rQ9HotbL4=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;

64
package-lock.json generated
View File

@@ -120,6 +120,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
@@ -2589,6 +2590,22 @@
"node": ">=14.18.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
@@ -14614,6 +14631,53 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",

View File

@@ -330,36 +330,6 @@ function Install-AgentBrowser {
# 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.
@@ -375,20 +345,72 @@ function Install-Uv {
}
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
$binDir = Join-Path $HermesHome "bin"
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
# Download the uv binary zip directly from GitHub releases instead of
# running the astral installer script (`irm https://astral.sh/uv/install.ps1 | iex`).
# The astral installer calls Get-ExecutionPolicy internally (from the
# Microsoft.PowerShell.Security module), and on some Windows installs that
# module fails to load -- killing the installer before it can download
# anything (field report: "The 'Get-ExecutionPolicy' command was found in
# the module 'Microsoft.PowerShell.Security', but the module could not be
# loaded"). Downloading the zip ourselves sidesteps the broken module
# entirely: no child powershell spawn, no execution-policy check, no
# script parsing. The astral installer is just a wrapper around this zip
# anyway.
$arch = Get-WindowsArch
$targetTriple = switch ($arch) {
"x64" { "x86_64-pc-windows-msvc" }
"arm64" { "aarch64-pc-windows-msvc" }
"x86" { "i686-pc-windows-msvc" }
default { throw "Unsupported Windows architecture for uv: $arch" }
}
$zipName = "uv-$targetTriple.zip"
# The /latest/download/ URL always serves the most recent release zip.
$downloadUrls = @(
"https://github.com/astral-sh/uv/releases/latest/download/$zipName",
"https://releases.astral.sh/github/uv/releases/latest/download/$zipName"
)
$tempZip = [System.IO.Path]::GetTempFileName() + ".zip"
$tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
$downloaded = $false
foreach ($url in $downloadUrls) {
try {
Write-Info "Downloading uv from $url ..."
Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing -ErrorAction Stop
$downloaded = $true
break
} catch {
Write-Warn "Download failed from $url : $_"
}
}
if (-not $downloaded) {
Write-Err "Failed to download uv from all mirrors"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
return $false
}
# 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
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null
Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop
# The zip contains uv.exe (and uvx.exe) either at the root or inside
# a subdirectory. Find and move them to the managed bin dir.
$executables = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.exe" -ErrorAction SilentlyContinue
if (-not $executables) {
Write-Err "uv zip did not contain any executables"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
}
foreach ($exe in $executables) {
Copy-Item -Path $exe.FullName -Destination $binDir -Force -ErrorAction Stop
}
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
@@ -397,14 +419,16 @@ function Install-Uv {
return $true
}
Write-Err "uv installed but not found at $managedUv"
Write-Err "uv.exe not found at $managedUv after extraction"
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-Err "Failed to extract uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} finally {
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
Remove-Item $tempExtract -Recurse -Force -ErrorAction SilentlyContinue
}
}

View File

@@ -1,94 +0,0 @@
"""Regression tests for #48352: Windows PowerShell 5.1 native stderr.
PowerShell 5.1 turns stderr from native commands into ``NativeCommandError``
records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a
few git/uv calls where stderr can be normal progress output, so those calls must
run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
def _install_ps1() -> str:
return INSTALL_PS1.read_text(encoding="utf-8")
def _assert_relaxed_call(text: str, command_pattern: str) -> None:
helper_block_pattern = (
r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*"
+ command_pattern
+ r"[^}]*\}"
)
inline_pattern = (
r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?"
+ command_pattern
)
assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), (
f"install.ps1 must relax ErrorActionPreference around {command_pattern}"
)
def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None:
text = _install_ps1()
assert "function Invoke-NativeWithRelaxedErrorAction" in text
_assert_relaxed_call(
text,
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir",
)
_assert_relaxed_call(
text,
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir",
)
def test_uv_venv_and_dependency_installs_relax_eap() -> None:
text = _install_ps1()
_assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion")
_assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked")
_assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec")
def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None:
"""Relaxing EAP must not let a genuine `uv venv` failure pass as success.
Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its
own, so install.ps1 must capture $LASTEXITCODE right after the call and fail
fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits
ok=true) when no venv was created. Regression guard for the gap caught while
reviewing #48372 (the explicit check originally proposed in #48463).
"""
text = _install_ps1()
# The uv-venv invocation, then an exit-code capture, then a throw — all
# within a small window after the relaxed call.
guard = re.search(
r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?"
r"\$LASTEXITCODE[\s\S]{0,200}?"
r"-ne 0[\s\S]{0,200}?throw",
text,
)
assert guard is not None, (
"install.ps1 must capture uv venv's exit code and throw on failure after "
"relaxing ErrorActionPreference, so a genuine venv-creation failure isn't "
"reported as a successful stage"
)
def test_native_eap_helper_always_restores_previous_preference() -> None:
text = _install_ps1()
m = re.search(
r"function Invoke-NativeWithRelaxedErrorAction \{(?P<body>[\s\S]*?)^\}",
text,
re.MULTILINE,
)
assert m is not None, "expected a shared helper for NativeCommandError-safe calls"
body = m.group("body")
assert "$prevEAP = $ErrorActionPreference" in body
assert '$ErrorActionPreference = "Continue"' in body
assert "finally" in body
assert "$ErrorActionPreference = $prevEAP" in body

View File

@@ -1,77 +0,0 @@
"""Regression: the Windows installer must not spawn a bare ``powershell``.
A user on Windows reported the installer getting stuck; running
``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the
uv step with::
[X] Failed to install uv: The term 'powershell' is not recognized as the
name of a cmdlet, function, script file, or operable program.
Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded
bare ``powershell`` command. That name resolves only to *Windows PowerShell*
and only when its System32 directory is on ``PATH``. Under PowerShell 7+
(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the
spawn dies and uv installation aborts.
The fix resolves the PowerShell host executable (preferring the absolute path
of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and
invokes *that* instead of a bare name. These tests lock that contract at the
source level (the script only runs on Windows, so there's no runner to
execute it on Linux CI).
"""
from pathlib import Path
import pytest
_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1"
@pytest.fixture(scope="module")
def source() -> str:
return _INSTALL_PS1.read_text(encoding="utf-8")
def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str):
"""The exact failing literal must be gone."""
forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv'
assert forbidden not in source, (
"Install-Uv still spawns the astral uv installer via a bare "
"`powershell` — it must use the resolved PowerShell host exe so it "
"works under pwsh / when powershell isn't on PATH."
)
def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str):
"""The astral uv installer line must use the call operator on a variable.
i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...``
rather than naming a fixed executable.
"""
lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln]
# Exactly one invocation line carries the astral installer.
invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln]
assert invocation, "astral uv install invocation line not found"
for ln in invocation:
stripped = ln.strip()
assert stripped.startswith("& $"), (
f"astral uv installer must be invoked via the call operator on a "
f"resolved host variable (`& $...`), got: {stripped!r}"
)
def test_powershell_host_resolver_is_defined_and_portable(source: str):
"""A host-resolver helper must exist and be PATH-independent + pwsh-aware."""
assert "function Get-PowerShellHostExe" in source, (
"expected a Get-PowerShellHostExe helper that resolves the host exe"
)
# PATH-independent: derive the absolute path of the running host.
assert "Get-Process -Id $PID" in source, (
"resolver must derive the current host's absolute path "
"(Get-Process -Id $PID), which is independent of PATH"
)
# pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`.
assert "pwsh" in source, (
"resolver must fall back to pwsh (PowerShell 7) when powershell is "
"unavailable"
)