feat(desktop): thin installer + first-launch install.ps1 bootstrap

Converges the Windows packaged desktop installer onto a single canonical
install topology: drop the Electron shell only (~80MB instead of ~500MB),
clone Hermes Agent at a build-time-pinned commit on first launch via
install.ps1's stage protocol, and treat the resulting git checkout at
%LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location
(same path the CLI installer uses).  Future updates flow through the
existing applyUpdates() git-pull path.

Replaces the previous fat-installer architecture where the .exe bundled
a pre-staged hermes-agent source tree under resources/hermes-agent/ that
was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated
factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT
mismatch on path resolve, isGitCheckout guard regressions, pyproject
hash drift detection inside the sync loop).

Architecture overview
---------------------

  Build time
    apps/desktop/scripts/write-build-stamp.cjs writes
    apps/desktop/build/install-stamp.json with {commit, branch, builtAt,
    dirty}.  Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to
    `git rev-parse HEAD` locally.

    apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset
    of @homebridge/node-pty-prebuilt-multiarch from the workspace-root
    node_modules into apps/desktop/build/native-deps/.  Workspace dedup
    hoists this dep to the root, out of reach of electron-builder's
    `files:`-restricted collector; staging gives us a deterministic
    path to extraResources.

    electron-builder ships both into resources/install-stamp.json and
    resources/native-deps/ respectively.

  Boot resolver (electron/main.cjs)
    Resolver order:
      1. HERMES_DESKTOP_HERMES_ROOT override
      2. SOURCE_REPO_ROOT (dev mode)
      3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete
         marker -- the post-install fast path
      4. `hermes` on PATH (CLI-installed user adding the desktop)
      5. pip-installed hermes_cli via system Python
      6. bootstrap-needed sentinel -> hand off to runBootstrap

    Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER /
    syncTreeExcludingVenv machinery (-200 lines).  The isGitCheckout
    guard that bit us in the install.ps1 PR is gone.

  First-launch bootstrap (electron/bootstrap-runner.cjs)
    1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else
       download from GitHub raw at INSTALL_STAMP.commit (cached at
       HERMES_HOME\bootstrap-cache\install-<sha>.ps1).
    2. Fetch the stage manifest via install.ps1 -Manifest -Commit X
       -Branch Y.
    3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json
       -Commit X -Branch Y per stage.
    4. On all stages green: write the .hermes-bootstrap-complete
       marker with {schemaVersion, pinnedCommit, pinnedBranch,
       completedAt, desktopVersion}.

    Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log.  Cancellation
    via AbortSignal.  Manifest cache so retries don't re-download.

  Install overlay (src/components/desktop-install-overlay.tsx)
    Mounted alongside the existing onboarding overlay; flexbox card
    with header (static) + middle (scrollable) + footer (failure-only,
    static).  Subscribes to hermes:bootstrap:event IPC + resyncs from
    hermes:bootstrap:get on mount/reload.  Renders:
      - 14-stage checklist with per-stage state icons
      - Overall progress bar + current-stage spotlight
      - Auto-expanded installer-output panel on failure
      - "Copy output" button (full ring buffer + error to clipboard)
      - "Reload and retry" wired through hermes:bootstrap:reset to
        clear main.cjs's latched failure
    Synthetic empty-manifest event from main.cjs flips the overlay to
    'active' immediately so the slow install.ps1 download doesn't
    leave the user staring at the generic Preparing splash.

  Failure latching (main.cjs)
    bootstrapFailure module-scope variable holds the rejection after
    install.ps1 fails.  startHermes() throws the latched error
    immediately when set, bypassing the entire ensureRuntime +
    runBootstrap chain.  Without this, the renderer's ensureGatewayOpen
    retries would re-run install.ps1 in a 5-10 min hot loop while the
    user was still reading the failure overlay.  Cleared via
    hermes:bootstrap:reset on user-driven retry.

  Unsupported-platform overlay (1F)
    macOS / Linux packaged builds (no install.sh stage protocol yet)
    emit an unsupported-platform event with a copy-pasteable install
    command + docs URL.  Dedicated overlay branch with "Copy command"
    + "I've run it -- retry" buttons.

install.ps1 additions (Phase 1F.3 + 1F.5)
-----------------------------------------

  New -Commit and -Tag string params.  Precedence Commit > Tag >
  Branch.  Honoured by all three code paths (update / fresh clone /
  ZIP fallback), with archive URL selection that handles each
  ref-type variant.  Detached-HEAD checkouts intentionally -- they're
  pins, not branches the user pulls into.

  EAP=Continue wrap around the new pin-step git invocations.  `git
  fetch origin <commit>` writes the routine 'From <url>' info line to
  stderr; under the script's global EAP=Stop that terminates the
  script even though fetch+checkout succeed.  Matches the established
  pattern in Install-Uv, Test-Python, _Run-NpmInstall.

Backend fix (hermes_cli/web_server.py)
--------------------------------------

  CORS allow_origin_regex now accepts Origin: 'null'.  Packaged
  Electron loads index.html via file://; Chromium sets the WebSocket
  upgrade Origin header to the opaque origin 'null', which the old
  regex rejected with HTTP 403 before gateway_ws() ever ran.  This
  failure mode was masked in the older FACTORY_HERMES_ROOT
  architecture because the resolver often found an existing hermes
  on PATH with different binding behavior.

  Security maintained: localhost-only bind keeps cross-machine pages
  out; per-process session token still gates every authenticated
  /api/ endpoint regardless of Origin.

Desktop QoL
-----------

  DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I).
  Field-debugging trade-off: tiny attack surface increase versus
  a much better support story when CSP / WS / theme issues surface.

  NSIS prereq-check page deleted (-767 lines).  The standard
  Welcome -> License -> Directory -> InstallFiles -> Finish wizard
  now installs without custom Python/Git/ripgrep detection -- those
  prereqs are install.ps1's job at first launch.

Test infrastructure (Phase 1G)
------------------------------

  apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform
  bundle validator (was darwin-only and asserted on dead factory-
  payload paths):
    NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard)
    POSITIVE: install-stamp.json carries a real commit + branch
    POSITIVE: node-pty native deps shipped under resources/native-deps
    POSITIVE: renderer dist/index.html reachable (asar or unpacked)
  New nsis mode and npm run test:desktop:nsis script.

Validated end-to-end on clean Win10 VM
--------------------------------------

  Confirmed: NSIS installer drops Electron shell, app launches,
  install overlay shows progress, install.ps1 clones the pinned
  commit, 14 stages run to completion, marker written, backend
  spawns, WebSocket connects, onboarding overlay asks for API key,
  main UI loads, integrated terminal works.

  Failures handled: bootstrap stays failed (no hot-loop retry),
  "Copy output" gives actionable transcript, "Reload and retry"
  explicitly re-runs install.ps1.

What's deferred
---------------

  - MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest
    with runFullTrust, signed and submitted to Microsoft Store.
  - install.sh stage protocol parity (Phase 2): once shipped, the
    unsupported-platform overlay becomes drive-it-yourself and
    macOS/Linux packaged installers gain feature parity with Windows.
This commit is contained in:
emozilla 2026-05-18 02:26:46 -04:00
parent 046f0c01cb
commit 705eaa054a
17 changed files with 2072 additions and 1212 deletions

View file

@ -157,9 +157,6 @@ jobs:
- name: Build desktop renderer
run: npm --prefix apps/desktop run build
- name: Stage Hermes payload
run: npm --prefix apps/desktop run stage:hermes
- name: Map macOS signing credentials
if: matrix.platform == 'mac'
shell: bash

View file

@ -97,7 +97,7 @@ npm run dist:mac:zip # zip only
npm run dist:win # NSIS + MSI
```
Before packaging, `stage:hermes` copies the Python Hermes payload into `build/hermes-agent`. Electron Builder then ships it as `Contents/Resources/hermes-agent`.
Before packaging, the desktop app no longer bundles a copy of the Hermes Agent Python source. Instead, the packaged Electron app will fetch and install Hermes Agent at first launch via `scripts/install.ps1`'s stage protocol (Windows) — see the bootstrap flow documented in `electron/main.cjs`. macOS and Linux packaged builds are temporarily non-functional until `install.sh` gains the same stage protocol; dev workflows on all three platforms continue to work since they resolve a sibling source checkout.
## Automated Releases
@ -187,12 +187,14 @@ Hermes Desktop shares its install layout with the CLI installers (`scripts/insta
```text
HERMES_HOME/ # %LOCALAPPDATA%\hermes (Windows)
# ~/.hermes (macOS / Linux)
├── hermes-agent/ # ACTIVE_HERMES_ROOT — the canonical install
├── hermes-agent/ # ACTIVE_HERMES_ROOT — git checkout
│ ├── .git/ # canonical install is always a git checkout
│ ├── hermes_cli/, agent/, ... # Python source
│ ├── pyproject.toml # source of truth for deps
│ ├── venv/ # virtualenv (Scripts\python.exe on Windows,
│ │ # bin/python elsewhere)
│ └── .hermes-desktop-runtime.json # marker: schema version + pyproject hash
│ └── .hermes-bootstrap-complete # marker: first-launch install.ps1 succeeded
├── git/ # PortableGit (Windows; installed by install.ps1)
├── config.yaml # user config
├── .env # API keys
└── logs/
@ -202,33 +204,33 @@ HERMES_HOME/ # %LOCALAPPDATA%\hermes (Windows)
└── gateway.log
```
The factory image (`Contents/Resources/hermes-agent` on macOS, `resources\hermes-agent` on Windows) ships inside the `.app` / `.exe` and seeds `HERMES_HOME/hermes-agent` on first launch.
The packaged installer ships only the Electron app — Hermes Agent itself is fetched and installed at first launch by running `scripts/install.ps1` (Windows) against the git ref baked into the .exe at build time (see `apps/desktop/scripts/write-build-stamp.cjs`).
### Resolution order
The desktop resolves a Hermes backend in this order:
1. `HERMES_DESKTOP_HERMES_ROOT` — explicit dev override.
2. Existing `hermes` CLI on PATH (skipped when `HERMES_DESKTOP_IGNORE_EXISTING=1`).
3. Repo source root — only when running `npm run dev` from a checkout. Takes precedence over `HERMES_HOME/hermes-agent` so devs always run their local edits.
4. `HERMES_HOME/hermes-agent` if it already exists (CLI installer or prior desktop launch).
5. Packaged + factory image present → sync factory → `HERMES_HOME/hermes-agent`, then use it.
6. Pip-installed `hermes_cli` module via system Python.
2. Repo source root — only when running `npm run dev` from a checkout. Takes precedence over `HERMES_HOME/hermes-agent` so devs always run their local edits.
3. `HERMES_HOME/hermes-agent` if the `.hermes-bootstrap-complete` marker is present. The marker attests that install.ps1 succeeded and the user finished initial configuration; we trust the install and skip the bootstrap flow on every launch after the first.
4. Existing `hermes` CLI on PATH (skipped when `HERMES_DESKTOP_IGNORE_EXISTING=1`).
5. Pip-installed `hermes_cli` module via system Python.
6. None of the above → bootstrap-needed sentinel. The desktop's first-launch wizard runs `scripts/install.ps1` stages, then writes the marker on success.
### First-launch flow on a packaged install
1. Sync factory image → `HERMES_HOME/hermes-agent`. Skipped if a `.git` directory exists at the destination (developer install) — never overwrites a user's local repo.
2. Create venv at `HERMES_HOME/hermes-agent/venv` using system Python (errors out with a Python-install hint if no Python 3.11+ is found).
3. `pip install -e HERMES_HOME/hermes-agent``pyproject.toml` is the single source of truth for dependencies.
4. Stamp `.hermes-desktop-runtime.json` with the schema version + pyproject hash + factory version.
1. `resolveHermesBackend()` returns `kind: 'bootstrap-needed'`.
2. The renderer shows the install overlay; main fetches `scripts/install.ps1` from GitHub at the pinned commit (from `install-stamp.json`).
3. Main drives `install.ps1 -Manifest` to get the stage list, then iterates `install.ps1 -Stage <name> -NonInteractive -Json` with live progress events to the renderer.
4. On all stages succeeding, main writes `.hermes-bootstrap-complete` with `{ schemaVersion, pinnedCommit, pinnedBranch, completedAt, desktopVersion }`.
5. Renderer hands off to the existing onboarding overlay (API key / model / persona).
6. Subsequent launches see the marker and skip everything in steps 1-5.
Subsequent launches compare the marker against the active `pyproject.toml` and skip steps 2-4 when nothing has changed.
### Updates
### Upgrades
Once bootstrapped, the install is a real git checkout. Updates flow through the in-app update path (`applyUpdates()``git fetch && git pull --ff-only` against the configured branch) or `hermes update` from the CLI. Both check `pyproject.toml` drift and re-run `pip install -e .` only when needed.
A new installer drops a new factory image. On next launch the marker mismatches → factory contents are copied over `HERMES_HOME/hermes-agent` (excluding `venv/`, `.git`, `__pycache__`, etc.), `pip install -e` re-runs to pick up new deps, the marker is re-stamped. The venv is preserved across upgrades to keep the upgrade fast when deps haven't moved.
A user who installed via `scripts/install.ps1` / `scripts/install.sh` (so `HERMES_HOME/hermes-agent/.git` exists) is detected as a developer install and the desktop never overwrites their checkout — they keep using `hermes update` / `git pull` to update.
A user who installed via `scripts/install.ps1` directly (so `HERMES_HOME/hermes-agent/.git` exists but no `.hermes-bootstrap-complete` marker) is detected via resolver step 4 (their `hermes` CLI on PATH) and the desktop reuses their install without re-running the bootstrap.
## Debugging
@ -241,14 +243,14 @@ HERMES_HOME/logs/desktop.log # %LOCALAPPDATA%\hermes\logs\desktop.log on Win
If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context.
To reset desktop runtime state (forces re-sync from the factory image and re-`pip install -e .` on next launch):
To force a fresh first-launch bootstrap (rare — useful for development / dogfooding the install flow):
```bash
# macOS / Linux
rm "$HOME/.hermes/hermes-agent/.hermes-desktop-runtime.json"
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
# Windows (PowerShell)
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-desktop-runtime.json"
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
```
For a full reset of just the Python venv (rare — usually only needed if the venv is broken):

View file

@ -0,0 +1,466 @@
'use strict'
/**
* bootstrap-runner.cjs
*
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
* scripts/install.ps1 stage-by-stage and streaming progress events back to
* the renderer.
*
* Wired from electron/main.cjs:
* const { runBootstrap } = require('./bootstrap-runner.cjs')
* const result = await runBootstrap({
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
* activeRoot, // ACTIVE_HERMES_ROOT
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
* hermesHome, // HERMES_HOME
* logRoot, // HERMES_HOME/logs
* emit: ev => {...} // event sink (sender.send or similar)
* })
*
* Emits events with shape:
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
* json?, durationMs?, error? }
* { type: 'log', stage?, line } // raw line from install.ps1
* { type: 'complete', marker: <written marker payload> }
* { type: 'failed', stage?, error } // bootstrap aborted
*
* Resolves with the same shape as the final 'complete' or 'failed' event so
* callers can await either way.
*
* NOT implemented yet (deferred to Phase 1E / 1F):
* - User-facing retry / cancel from the renderer (event channels exist;
* no UI consumes them yet)
* - macOS / Linux install.sh equivalent
*/
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
// runner (passed -NonInteractive to install.ps1, which the install script
// itself handles by emitting skipped=true frames). The renderer / 1E onboarding
// overlay takes over for those concerns (API keys, model, persona, gateway).
// We let install.ps1's own -NonInteractive logic drive this rather than
// filtering client-side -- single source of truth.
// ---------------------------------------------------------------------------
// install.ps1 source resolution
// ---------------------------------------------------------------------------
function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) return null
const candidate = path.join(sourceRepoRoot, 'scripts', 'install.ps1')
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
}
}
function bootstrapCacheDir(hermesHome) {
return path.join(hermesHome, 'bootstrap-cache')
}
function cachedScriptPath(hermesHome, commit) {
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.ps1`)
}
function downloadInstallScript(commit, destPath) {
// Fetch from GitHub raw at the pinned commit. The raw URL with a SHA
// is immutable (unlike a branch ref), so we don't need integrity
// verification beyond "did the file we wrote pass a syntax probe."
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/install.ps1`
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const tmpPath = destPath + '.tmp'
const out = fs.createWriteStream(tmpPath)
https
.get(url, res => {
if (res.statusCode === 301 || res.statusCode === 302) {
// GitHub raw shouldn't redirect for a SHA URL, but follow once
// defensively.
out.close()
fs.unlinkSync(tmpPath)
https
.get(res.headers.location, res2 => {
if (res2.statusCode !== 200) {
reject(new Error(`Failed to download install.ps1: HTTP ${res2.statusCode} from redirect ${res.headers.location}`))
return
}
const out2 = fs.createWriteStream(tmpPath)
res2.pipe(out2)
out2.on('finish', () => {
out2.close()
fs.renameSync(tmpPath, destPath)
resolve(destPath)
})
out2.on('error', reject)
})
.on('error', reject)
return
}
if (res.statusCode !== 200) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {}
reject(new Error(`Failed to download install.ps1: HTTP ${res.statusCode} from ${url}`))
return
}
res.pipe(out)
out.on('finish', () => {
out.close()
fs.renameSync(tmpPath, destPath)
resolve(destPath)
})
out.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {}
reject(err)
})
})
.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {}
reject(err)
})
})
}
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
// 1. Dev shortcut: prefer a local checkout's install.ps1 so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
const localScript = resolveLocalInstallScript(sourceRepoRoot)
if (localScript) {
emit({ type: 'log', line: `[bootstrap] using local install.ps1 at ${localScript}` })
return { path: localScript, source: 'local' }
}
// 2. Packaged path: download from GitHub at the pinned commit (1B's stamp).
if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) {
throw new Error(
'Cannot resolve install.ps1: no SOURCE_REPO_ROOT and no install stamp. ' +
'This packaged build was produced without a valid build-time stamp.'
)
}
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({ type: 'log', line: `[bootstrap] using cached install.ps1 for ${installStamp.commit.slice(0, 12)}` })
return { path: cached, source: 'cache', commit: installStamp.commit }
} catch {
// not cached; download
}
emit({ type: 'log', line: `[bootstrap] fetching install.ps1 for ${installStamp.commit.slice(0, 12)} from GitHub` })
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit }
}
// ---------------------------------------------------------------------------
// powershell wrapper
// ---------------------------------------------------------------------------
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
const ps = process.platform === 'win32' ? 'powershell.exe' : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
let stdout = ''
let stderr = ''
let killed = false
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
} else {
abortSignal.addEventListener('abort', onAbort, { once: true })
}
}
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
// Stream stdout line-by-line so the renderer sees progress in real time.
let stdoutBuf = ''
child.stdout.on('data', chunk => {
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line })
}
})
let stderrBuf = ''
child.stderr.on('data', chunk => {
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
}
})
child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
// Flush any trailing bytes
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
resolve({ stdout, stderr, code, signal, killed })
})
})
}
// ---------------------------------------------------------------------------
// Manifest + stage dispatch
// ---------------------------------------------------------------------------
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
// so the repository stage clones the exact SHA the .exe was tested with
// instead of falling back to install.ps1's default ($Branch = "main").
function buildPinArgs(installStamp) {
const args = []
if (installStamp && installStamp.commit) {
args.push('-Commit', installStamp.commit)
}
if (installStamp && installStamp.branch) {
args.push('-Branch', installStamp.branch)
}
return args
}
async function fetchManifest({ scriptPath, emit, hermesHome, installStamp }) {
const pinArgs = buildPinArgs(installStamp)
const result = await spawnPowerShell(scriptPath, ['-Manifest', ...pinArgs], {
emit,
stageName: '__manifest__',
hermesHome
})
if (result.code !== 0) {
throw new Error(`install.ps1 -Manifest failed: exit ${result.code}\n${result.stderr || result.stdout}`)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
// Find the last line that parses as JSON with a `stages` field.
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
} catch {}
}
throw new Error(`install.ps1 -Manifest produced no parseable JSON payload\n${result.stdout}`)
}
// Parse the JSON result frame from a stage run. The protocol guarantees
// exactly one JSON line per stage in -Json or -Stage mode (post #27224 fix
// for the double-emit bug we addressed in the install.ps1 PR).
function parseStageResult(stdout) {
const lines = stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
} catch {}
}
return null
}
async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, installStamp }) {
const startedAt = Date.now()
emit({ type: 'stage', name: stage.name, state: 'running' })
const pinArgs = buildPinArgs(installStamp)
const result = await spawnPowerShell(
scriptPath,
['-Stage', stage.name, '-NonInteractive', '-Json', ...pinArgs],
{ emit, stageName: stage.name, abortSignal, hermesHome }
)
const durationMs = Date.now() - startedAt
if (result.killed) {
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
emit(ev)
return ev
}
const json = parseStageResult(result.stdout)
if (!json) {
const ev = {
type: 'stage',
name: stage.name,
state: 'failed',
durationMs,
error: `install.ps1 -Stage ${stage.name} produced no JSON result frame (exit=${result.code})`,
json: null
}
emit(ev)
return ev
}
if (json.ok && json.skipped) {
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
emit(ev)
return ev
}
if (json.ok) {
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
emit(ev)
return ev
}
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
emit(ev)
return ev
}
// ---------------------------------------------------------------------------
// Per-run log file
// ---------------------------------------------------------------------------
function openRunLog(logRoot) {
fs.mkdirSync(logRoot, { recursive: true })
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
const stream = fs.createWriteStream(logPath, { flags: 'a' })
return { path: logPath, stream }
}
// ---------------------------------------------------------------------------
// Public entrypoint
// ---------------------------------------------------------------------------
async function runBootstrap(opts) {
const {
installStamp,
activeRoot,
sourceRepoRoot,
hermesHome,
logRoot,
onEvent,
abortSignal,
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
} = opts
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
// Tee every event to the runLog AND the caller's onEvent. This gives us a
// forensic trail per bootstrap run AND lets the renderer subscribe live.
const emit = ev => {
try {
runLog.stream.write(JSON.stringify(ev) + '\n')
} catch {}
try {
if (typeof onEvent === 'function') onEvent(ev)
} catch (err) {
// Don't let a subscriber bug crash the bootstrap
runLog.stream.write(`emit error: ${err && err.message}\n`)
}
}
emit({
type: 'log',
line:
`[bootstrap] starting at ${new Date().toISOString()}; ` +
`activeRoot=${activeRoot}; ` +
`stamp=${installStamp ? installStamp.commit.slice(0, 12) : '<none>'}; ` +
`runLog=${runLog.path}`
})
try {
// 1. Resolve install.ps1
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
// 2. Fetch manifest
const manifest = await fetchManifest({ scriptPath: scriptInfo.path, emit, hermesHome, installStamp })
emit({
type: 'manifest',
stages: manifest.stages,
protocolVersion: manifest.protocol_version || manifest.protocolVersion || null
})
// 3. Iterate stages in order. Stages flagged needs_user_input are still
// invoked -- install.ps1's own -NonInteractive handler in those stages
// emits skipped=true. We trust the protocol rather than filtering
// client-side.
for (const stage of manifest.stages) {
if (abortSignal && abortSignal.aborted) {
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
return { ok: false, cancelled: true }
}
const ev = await runStage({ scriptPath: scriptInfo.path, stage, emit, hermesHome, abortSignal, installStamp })
if (ev.state === 'failed') {
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: ev.error }
}
}
// 4. Write the bootstrap-complete marker.
const markerPayload = {
pinnedCommit: installStamp ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null
}
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
emit({ type: 'complete', marker })
return { ok: true, marker }
} catch (err) {
emit({ type: 'failed', error: err.message || String(err) })
return { ok: false, error: err.message || String(err) }
} finally {
try {
runLog.stream.end()
} catch {}
}
}
module.exports = {
runBootstrap,
// Exposed for testability
parseStageResult,
resolveLocalInstallScript,
cachedScriptPath
}

View file

@ -21,7 +21,8 @@ const net = require('node:net')
const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
@ -36,7 +37,24 @@ let nodePty = null
try {
nodePty = require('@homebridge/node-pty-prebuilt-multiarch')
} catch {
nodePty = null
// Packaged builds set `files:` in package.json, which excludes node_modules
// from the asar. Workspace dedup also hoists this native dep to the repo
// root's node_modules, out of reach of electron-builder's collector. We
// ship a minimal copy under resources/native-deps/ via extraResources +
// scripts/stage-native-deps.cjs; resolve from there when the normal
// require() fails. Dev mode never reaches this branch -- the hoisted
// resolve succeeds via Node's normal module lookup.
try {
const path = require('node:path')
const resourcesPath = process.resourcesPath
if (resourcesPath) {
nodePty = require(
path.join(resourcesPath, 'native-deps', '@homebridge', 'node-pty-prebuilt-multiarch')
)
}
} catch {
nodePty = null
}
}
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
@ -56,6 +74,63 @@ const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// Build-time install stamp -- the git ref this .exe was built against.
//
// Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build`
// and bundled into packaged apps via electron-builder's extraResources entry,
// so the runtime stamp ends up at process.resourcesPath/install-stamp.json
// after install. The bootstrap runner (Phase 1D) reads it to know which
// commit to clone when running install.ps1 stages at first launch.
//
// Returns null when the file is missing (dev runs from a checkout where
// build hasn't been invoked, or schema mismatch). Callers must handle null.
//
// Schema:
// { schemaVersion: 1, commit, branch, builtAt, dirty, source }
const INSTALL_STAMP_SCHEMA_VERSION = 1
function loadInstallStamp() {
// Try packaged location first (resources/install-stamp.json), then the
// dev/local build output (apps/desktop/build/install-stamp.json) so
// someone running `npm run start` after a local `npm run build` also
// sees a stamp without needing a packaged build.
const candidates = [
process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null,
path.join(APP_ROOT, 'build', 'install-stamp.json')
].filter(Boolean)
for (const p of candidates) {
try {
const raw = fs.readFileSync(p, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) {
if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) {
console.warn(`[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring`)
continue
}
return Object.freeze({
schemaVersion: parsed.schemaVersion,
commit: parsed.commit,
branch: parsed.branch || null,
builtAt: parsed.builtAt || null,
dirty: Boolean(parsed.dirty),
source: parsed.source || null,
path: p
})
}
} catch {
// Either ENOENT or malformed JSON; try the next candidate
}
}
return null
}
const INSTALL_STAMP = loadInstallStamp()
if (INSTALL_STAMP) {
console.log(`[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}`)
} else if (IS_PACKAGED) {
// Dev builds without a stamp are normal; packaged builds without one
// mean the bootstrap won't know what to clone. Surface clearly.
console.error('[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.')
}
// HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors
// scripts/install.ps1's $HermesHome and scripts/install.sh's $HERMES_HOME.
//
@ -92,12 +167,19 @@ const HERMES_HOME = resolveHermesHome()
const ACTIVE_HERMES_ROOT = path.join(HERMES_HOME, 'hermes-agent')
// VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it.
const VENV_ROOT = path.join(ACTIVE_HERMES_ROOT, 'venv')
const RUNTIME_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-desktop-runtime.json')
// FACTORY_HERMES_ROOT — read-only payload that ships inside the .app/.exe.
// On first run (or after an installer-driven upgrade) we sync it into
// ACTIVE_HERMES_ROOT, unless ACTIVE is a git checkout (developer install via
// install.ps1) in which case we leave it alone.
const FACTORY_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
// BOOTSTRAP_COMPLETE_MARKER — written by the first-launch bootstrap runner
// (Phase 1D) after install.ps1 has completed all stages and the user has
// finished initial configuration. Presence of this marker means the install
// is in a known-good state and we can skip the bootstrap flow on subsequent
// boots, going straight to `resolveHermesBackend()`. Missing or stale marker
// means we re-run the bootstrap; install.ps1's stages are idempotent so a
// re-run on an already-good install just discovers everything in place.
//
// We deliberately put the marker INSIDE ACTIVE_HERMES_ROOT (not alongside)
// so that deleting the checkout to start fresh also deletes the marker --
// avoids the confusing "marker exists but checkout is gone" state.
const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
@ -117,8 +199,6 @@ const BOOT_FAKE_STEP_MS = (() => {
if (!Number.isFinite(raw) || raw <= 0) return 650
return Math.max(120, raw)
})()
const RUNTIME_SCHEMA_VERSION = 4
const RUNTIME_IMPORT_CHECK = bundledRuntimeImportCheck()
const APP_NAME = 'Hermes'
const TITLEBAR_HEIGHT = 34
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
@ -282,6 +362,12 @@ app.setAboutPanelOptions({
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
// Latched bootstrap failure: when the first-launch install fails, we hold
// onto the error so subsequent startHermes() calls (e.g. the renderer's
// ensureGatewayOpen retrying after the WS won't open) return the same error
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
@ -416,6 +502,86 @@ function broadcastBootProgress() {
webContents.send('hermes:boot-progress', bootProgressState)
}
// Bootstrap-event broadcast channel + state. The bootstrap runner emits a
// stream of events (manifest, stage, log, complete, failed) that the renderer
// install overlay subscribes to. We also keep a running snapshot:
// - manifest: the stage list (rendered as a checklist in the overlay)
// - stages: per-stage state ('pending' | 'running' | 'succeeded' |
// 'skipped' | 'failed') keyed by stage name
// - active: true while a bootstrap is in flight; false otherwise
// - error: last 'failed' event's error message
// - log: bounded ring buffer of the last 200 log lines for the
// "Show details" affordance in the overlay
//
// The snapshot is queryable via the hermes:bootstrap:get IPC handler so a
// reloaded renderer (e.g. devtools reload during dev) recovers state.
// Bootstrap log ring: bounded buffer so a long install (npm + playwright
// downloads can emit thousands of lines) doesn't grow unbounded in memory
// AND so the renderer's getBootstrapState() reply stays a reasonable size.
// We keep enough to cover an entire failed stage's transcript so the
// 'Copy output' button gives the user actually-actionable context, not
// just the last few lines.
const BOOTSTRAP_LOG_RING_MAX = 500
let bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
function broadcastBootstrapEvent(ev) {
if (ev.type === 'manifest') {
bootstrapState.manifest = ev
bootstrapState.active = true
bootstrapState.startedAt = bootstrapState.startedAt || Date.now()
bootstrapState.stages = {}
for (const stage of ev.stages || []) {
bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null }
}
} else if (ev.type === 'stage') {
bootstrapState.stages[ev.name] = {
state: ev.state,
durationMs: ev.durationMs ?? null,
json: ev.json ?? null,
error: ev.error ?? null
}
} else if (ev.type === 'log') {
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line })
if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) {
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
}
} else if (ev.type === 'complete') {
bootstrapState.active = false
bootstrapState.completedAt = Date.now()
bootstrapState.error = null
bootstrapState.unsupportedPlatform = null
} else if (ev.type === 'failed') {
bootstrapState.active = false
bootstrapState.error = ev.error || 'unknown error'
} else if (ev.type === 'unsupported-platform') {
bootstrapState.active = false
bootstrapState.unsupportedPlatform = {
platform: ev.platform,
activeRoot: ev.activeRoot,
installCommand: ev.installCommand,
docsUrl: ev.docsUrl
}
}
if (!mainWindow || mainWindow.isDestroyed()) return
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) return
webContents.send('hermes:bootstrap:event', ev)
}
function getBootstrapState() {
return bootstrapState
}
function updateBootProgress(update, options = {}) {
const nextProgressRaw =
typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress
@ -960,6 +1126,61 @@ function readJson(filePath) {
}
}
// Used by applyUpdates() to detect pyproject.toml drift after `git pull` so
// we know whether to re-run `pip install -e .` against the venv. Returns
// null on read failure.
function sha256OfFile(filePath) {
try {
const buf = fs.readFileSync(filePath)
return crypto.createHash('sha256').update(buf).digest('hex')
} catch {
return null
}
}
// Bootstrap-complete marker helpers. The marker is written ONCE by the
// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed
// AND the user has finished initial configuration. On every subsequent boot
// we check `isBootstrapComplete()` and skip the bootstrap flow entirely if
// the marker is present and current-schema.
//
// Marker schema (version 1):
// {
// schemaVersion: 1,
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
// pinnedBranch: "<branch name>" | null,
// completedAt: "<ISO 8601>",
// desktopVersion: "<app.getVersion()>" // for forensics
// }
function readBootstrapMarker() {
return readJson(BOOTSTRAP_COMPLETE_MARKER)
}
function isBootstrapComplete() {
const marker = readBootstrapMarker()
if (!marker || typeof marker !== 'object') return false
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
// We DELIBERATELY do NOT verify that the checkout is currently at the
// pinned commit -- users update via the in-app update path or `hermes
// update`, which moves HEAD legitimately. The marker just attests "we
// ran the bootstrap successfully at least once."
return isHermesSourceRoot(ACTIVE_HERMES_ROOT)
}
function writeBootstrapMarker(payload) {
fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true })
const merged = {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null,
pinnedBranch: payload.pinnedBranch || null,
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
return merged
}
function resolveWebDist() {
const override = process.env.HERMES_DESKTOP_WEB_DIST
if (override && directoryExists(path.resolve(override))) return path.resolve(override)
@ -1033,7 +1254,7 @@ function createActiveBackend(dashboardArgs) {
}
function resolveHermesBackend(dashboardArgs) {
// 1. Explicit override HERMES_DESKTOP_HERMES_ROOT points at a developer
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
// checkout. Honour it as-is (no bootstrap; the user is driving).
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
if (overrideRoot && isHermesSourceRoot(overrideRoot)) {
@ -1041,7 +1262,7 @@ function resolveHermesBackend(dashboardArgs) {
if (backend) return backend
}
// 2. Development source when running `npm run dev` from a checkout, the
// 2. Development source -- when running `npm run dev` from a checkout, the
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
// installed `hermes` on PATH so local Python edits are actually exercised.
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
@ -1050,9 +1271,21 @@ function resolveHermesBackend(dashboardArgs) {
if (backend) return backend
}
// 3. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
// pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1
// (used by test:desktop:fresh to force the factory-image bootstrap path).
// 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
// The bootstrap marker means install.ps1 stages finished and the user
// completed initial configuration; we trust the install and go straight
// to spawning hermes. Updates flow through the in-app update path
// (applyUpdates -> git pull) or `hermes update` from the CLI.
if (isBootstrapComplete()) {
return createActiveBackend(dashboardArgs)
}
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
// a previous tool-only setup, or pip-installed system-wide. Use it but
// do NOT write a bootstrap marker; the user did this themselves and we
// don't want to take ownership of an install we didn't perform.
// HERMES_DESKTOP_IGNORE_EXISTING=1 forces the bootstrap path for testing.
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
let hermesCommand = null
const hermesOverride = process.env.HERMES_DESKTOP_HERMES
@ -1083,22 +1316,9 @@ function resolveHermesBackend(dashboardArgs) {
}
}
// 4. ACTIVE_HERMES_ROOT — the canonical mutable install at
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
// On packaged installs this is populated from FACTORY_HERMES_ROOT during
// ensureRuntime(). On install.ps1 / install.sh setups it's already there.
if (isHermesSourceRoot(ACTIVE_HERMES_ROOT)) {
return createActiveBackend(dashboardArgs)
}
// 5. Packaged: FACTORY_HERMES_ROOT exists but ACTIVE doesn't yet. Return a
// bootstrap-flagged backend; ensureRuntime() will sync factory → active
// and provision the venv before launch.
if (IS_PACKAGED && isHermesSourceRoot(FACTORY_HERMES_ROOT)) {
return createActiveBackend(dashboardArgs)
}
// 6. Last-ditch: pip-installed hermes_cli module via system Python.
// 5. Last-ditch: pip-installed hermes_cli module via system Python.
// Same rationale as #4 -- the user installed this; we use it but don't
// take ownership.
const python = findSystemPython()
if (python) {
return {
@ -1112,21 +1332,30 @@ function resolveHermesBackend(dashboardArgs) {
}
}
// Nothing worked. Distinguish the "no payload" and "no Python" cases so the
// user gets actionable guidance instead of "install the Hermes CLI".
const factoryPresent = isHermesSourceRoot(FACTORY_HERMES_ROOT)
const activePresent = isHermesSourceRoot(ACTIVE_HERMES_ROOT)
if (factoryPresent || activePresent) {
throw new Error(
'Hermes payload is present but no Python 3.11+ interpreter could be found. ' +
'Install Python 3.11+ from https://www.python.org/downloads/ or the Microsoft Store, ' +
'then relaunch Hermes.'
)
// 6. Nothing usable yet -- signal the bootstrap runner that we need to
// clone+install. Phase 1D's bootstrap-runner consumes this sentinel
// and drives install.ps1 stages with a progress UI. Until 1D lands,
// callers see the sentinel and surface it as a user-facing error
// explaining what's missing.
//
// We deliberately do NOT throw here -- throwing inside
// resolveHermesBackend was the old "no payload" path and forced the
// user into a dead end. With the bootstrap protocol, "no install yet"
// is a recoverable state the GUI can drive through.
return {
kind: 'bootstrap-needed',
label: 'Hermes Agent not installed yet; bootstrap required',
command: null,
args: dashboardArgs,
bootstrap: true,
env: {},
shell: false,
// Hints for the bootstrap runner / UI layer:
activeRoot: ACTIVE_HERMES_ROOT,
installStamp: INSTALL_STAMP, // may be null in dev
isPackaged: IS_PACKAGED,
platform: process.platform
}
throw new Error(
'Could not find Hermes. Install the Hermes CLI ' +
'(https://github.com/NousResearch/hermes-agent#install) or set HERMES_DESKTOP_HERMES_ROOT.'
)
}
async function ensureRuntime(backend) {
@ -1135,39 +1364,108 @@ async function ensureRuntime(backend) {
return backend
}
// Step 1: Ensure ACTIVE_HERMES_ROOT is populated. On packaged installs we
// sync from FACTORY_HERMES_ROOT (the read-only payload bundled into the
// .app/.exe). We DON'T overwrite a developer install: presence of a .git
// dir or a Hermes-managed venv at the same place means the user set this
// up via install.ps1 / install.sh / git clone, and that install owns the
// updates (via `hermes update`).
const isGitCheckout = directoryExists(path.join(ACTIVE_HERMES_ROOT, '.git'))
const factoryAvailable = IS_PACKAGED && isHermesSourceRoot(FACTORY_HERMES_ROOT)
if (factoryAvailable && !isGitCheckout) {
const factoryVersion =
readPyprojectVersion(FACTORY_HERMES_ROOT) ??
readJson(path.join(FACTORY_HERMES_ROOT, 'package.json'))?.version ??
app.getVersion()
const marker = readJson(RUNTIME_MARKER)
const pyprojectHash = sha256OfFile(path.join(FACTORY_HERMES_ROOT, 'pyproject.toml'))
const activeFresh =
isHermesSourceRoot(ACTIVE_HERMES_ROOT) &&
marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION &&
marker?.factoryVersion === factoryVersion &&
marker?.pyprojectHash === pyprojectHash
if (!activeFresh) {
await advanceBootProgress('runtime.sync', 'Installing Hermes', 30)
rememberLog(`Syncing Hermes payload ${FACTORY_HERMES_ROOT}${ACTIVE_HERMES_ROOT}`)
fs.mkdirSync(ACTIVE_HERMES_ROOT, { recursive: true })
// Copy in factory contents. We do NOT delete venv/ — preserving it
// across upgrades skips re-install when deps haven't moved.
await syncTreeExcludingVenv(FACTORY_HERMES_ROOT, ACTIVE_HERMES_ROOT)
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
// find anything to spawn. Hand off to the bootstrap runner which drives
// install.ps1's stage protocol, writes the bootstrap-complete marker on
// success, then we re-resolve to get the now-installed backend.
//
// Phase 1D status: bootstrap runs but events go to desktop.log only
// (renderer window isn't created until later in startBackend). Phase 1E
// will rewire startup to spawn the window first and route bootstrap events
// to a renderer-side install overlay.
if (backend.kind === 'bootstrap-needed') {
if (process.platform !== 'win32') {
// macOS/Linux: install.sh doesn't yet support the stage protocol that
// install.ps1 does, so we can't drive a first-launch bootstrap. Emit
// a platform-unsupported event so the renderer's install overlay can
// render a 'run install.sh manually' guide instead of a generic
// 'desktop boot failed' toast. Mark the bootstrap state as inactive
// with an explanatory error so the overlay's failure branch picks
// it up immediately. THEN throw -- so the existing 'desktop boot
// failed' path still trips and prevents the rest of startHermes
// from running against a missing install.
const guidanceUrl = 'https://github.com/NousResearch/hermes-agent#install'
const installShUrl = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh'
try {
broadcastBootstrapEvent({
type: 'unsupported-platform',
platform: process.platform,
activeRoot: backend.activeRoot,
installCommand: `bash <(curl -fsSL ${installShUrl})`,
docsUrl: guidanceUrl
})
} catch {}
throw new Error(
`Hermes Agent is not installed at ${backend.activeRoot}. On macOS/Linux ` +
'first-launch install is not yet automated -- run scripts/install.sh ' +
'from the Hermes repo manually, then relaunch this app.'
)
}
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
// Eagerly flip the bootstrap UI state to 'active' so the renderer
// shows the install overlay BEFORE the runner finishes fetching the
// manifest (which on slow networks can take tens of seconds and would
// otherwise leave the user staring at the generic 'Preparing' splash).
// We emit a synthetic manifest with an empty stages list -- the real
// manifest event will overwrite it once install.ps1 -Manifest returns.
try {
broadcastBootstrapEvent({
type: 'manifest',
stages: [],
protocolVersion: null
})
} catch {}
const bootstrapResult = await runBootstrap({
installStamp: backend.installStamp,
activeRoot: backend.activeRoot,
sourceRepoRoot: SOURCE_REPO_ROOT,
hermesHome: HERMES_HOME,
logRoot: path.join(HERMES_HOME, 'logs'),
onEvent: ev => {
// Tee every bootstrap event to (a) the desktop log for forensics
// and (b) the renderer for live progress UI. Either may be absent;
// tolerate both gracefully so a renderer crash doesn't stall the
// bootstrap and a log-write failure doesn't suppress the UI signal.
try {
rememberLog(`[bootstrap] ${JSON.stringify(ev)}`)
} catch {}
try {
broadcastBootstrapEvent(ev)
} catch {}
},
writeMarker: writeBootstrapMarker
})
if (!bootstrapResult.ok) {
const bootstrapError = new Error(
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
`${bootstrapResult.error || 'unknown error'}. ` +
`Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.`
)
bootstrapError.isBootstrapFailure = true
bootstrapError.failedStage = bootstrapResult.failedStage || null
// Latch the failure so subsequent startHermes() calls return this
// same error without re-running install.ps1. Cleared by the
// hermes:bootstrap:reset IPC (renderer's "Reload and retry").
bootstrapFailure = bootstrapError
throw bootstrapError
}
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
// Re-resolve now that the install exists. The new resolution lands in
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
return ensureRuntime(resolveHermesBackend(backend.args))
}
// bootstrap=true with a real backend (createActiveBackend path) means we
// have a checkout and need to ensure the venv-derived Python command is
// wired into the backend before launch. Same code path the old factory
// sync flow exited through, minus all the factory/pip/marker machinery
// (install.ps1 owns those concerns now and the bootstrap-complete marker
// attests they ran successfully).
if (!isHermesSourceRoot(ACTIVE_HERMES_ROOT)) {
throw new Error(
`Hermes install at ${ACTIVE_HERMES_ROOT} is missing or incomplete. ` +
@ -1175,29 +1473,12 @@ async function ensureRuntime(backend) {
)
}
// Step 2: Ensure venv exists at <ACTIVE_HERMES_ROOT>/venv — same place
// install.ps1 / install.sh put it. A user who installed via the CLI script
// already has this; we reuse it as-is.
const venvPython = getVenvPython(VENV_ROOT)
if (!fileExists(venvPython)) {
const systemPython = findSystemPython()
if (!systemPython) {
throw new Error(
'Python 3.11+ is required to bootstrap Hermes. Install Python from ' +
'https://www.python.org/downloads/ (or the Microsoft Store on Windows), then relaunch Hermes.'
)
}
await advanceBootProgress('runtime.venv', 'Creating Hermes virtual environment', 50)
await runProcess(systemPython, ['-m', 'venv', VENV_ROOT])
}
// Step 2b: On Windows, preflight Git Bash. Hermes' terminal tool calls
// bash.exe directly (tools/environments/local.py); without it the agent
// can't run a terminal command. We surface this here as a clear, actionable
// error rather than letting the user discover it on their first chat
// ("hey, run `ls`" → opaque tool failure). The NSIS prereq page handles
// this for installer users; this check catches everyone else (.msi users,
// npm run dev with a fresh checkout, manual installs, etc.).
// On Windows, preflight Git Bash. Hermes' terminal tool calls bash.exe
// directly (tools/environments/local.py); without it the agent can't run
// terminal commands. install.ps1's Stage-Git puts PortableGit at
// %LOCALAPPDATA%\hermes\git\, which findGitBash() picks up, so for any
// user who completed the bootstrap this is a no-op. For users who got
// here via an external `hermes` on PATH, this check still helps.
if (IS_WINDOWS && !findGitBash()) {
throw new Error(
'Git for Windows is required for Hermes on Windows (provides Git Bash, ' +
@ -1207,42 +1488,19 @@ async function ensureRuntime(backend) {
)
}
// Step 3: Ensure deps are installed. We compare a marker against the
// active pyproject.toml's hash and only run pip when something changed —
// keeps `npm run dev` boots fast on a stable repo.
const expectedMarker = {
runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION,
pyprojectHash: sha256OfFile(path.join(ACTIVE_HERMES_ROOT, 'pyproject.toml')),
factoryVersion: factoryAvailable ? (readPyprojectVersion(FACTORY_HERMES_ROOT) ?? app.getVersion()) : null
}
const currentMarker = readJson(RUNTIME_MARKER)
const depsFresh =
currentMarker?.runtimeSchemaVersion === expectedMarker.runtimeSchemaVersion &&
currentMarker?.pyprojectHash === expectedMarker.pyprojectHash &&
(await hasRuntimeImports(venvPython))
if (!depsFresh) {
await advanceBootProgress('runtime.dependencies', 'Installing Hermes dependencies', 66)
await runProcess(venvPython, [
'-m',
'pip',
'install',
'--disable-pip-version-check',
'--no-warn-script-location',
'--upgrade',
'-e',
ACTIVE_HERMES_ROOT
])
await advanceBootProgress('runtime.verify', 'Validating Hermes dependencies', 78)
await runProcess(venvPython, ['-c', RUNTIME_IMPORT_CHECK])
fs.writeFileSync(
RUNTIME_MARKER,
JSON.stringify({ ...expectedMarker, installedAt: new Date().toISOString() }, null, 2)
const venvPython = getVenvPython(VENV_ROOT)
if (!fileExists(venvPython)) {
// No venv at the expected location AND no bootstrap-needed sentinel
// means we have a half-installed checkout: .git exists, source files
// exist, but venv is missing or broken. This shouldn't happen in
// normal flow because isBootstrapComplete() requires
// isHermesSourceRoot() and the bootstrap writes the marker only after
// install.ps1 succeeds. If we hit this, the user (or a deleted venv)
// broke the invariant; tell them to re-run the install.
throw new Error(
`Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` +
'`scripts/install.ps1` to rebuild it.'
)
} else {
await advanceBootProgress('runtime.ready', 'Reusing existing Hermes runtime', 78)
}
backend.command = venvPython
@ -1257,79 +1515,6 @@ async function ensureRuntime(backend) {
return backend
}
async function hasRuntimeImports(python) {
try {
await runProcess(python, ['-c', RUNTIME_IMPORT_CHECK])
return true
} catch {
rememberLog('Hermes runtime is missing required imports; reinstalling.')
return false
}
}
// Read pyproject.toml's [project].version with a regex — avoids pulling in a
// TOML parser for one field. Returns null if the file is missing or the
// version line can't be matched.
function readPyprojectVersion(root) {
try {
const text = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8')
const match = text.match(/^version\s*=\s*"([^"]+)"/m)
return match ? match[1] : null
} catch {
return null
}
}
function sha256OfFile(filePath) {
try {
const buf = fs.readFileSync(filePath)
return crypto.createHash('sha256').update(buf).digest('hex')
} catch {
return null
}
}
// Copy from src → dst, preserving any existing venv/ at dst.
//
// In practice src (FACTORY_HERMES_ROOT) never contains a venv —
// stage-hermes-payload.mjs explicitly excludes venvs from the bundled
// payload. The venv-preservation filter below is defensive: if a future
// payload ever does include a venv directory, we still won't clobber the
// user's existing one at ACTIVE_HERMES_ROOT/venv.
//
// Excludes .git, __pycache__, .pyc/.pyo, etc. — same set
// stage-hermes-payload.mjs uses on the build side.
async function syncTreeExcludingVenv(src, dst) {
const EXCLUDED = new Set([
'.git',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'__pycache__',
'node_modules',
'.DS_Store'
])
const srcVenv = path.join(src, 'venv')
const venvPreserved = directoryExists(path.join(dst, 'venv'))
await fs.promises.cp(src, dst, {
recursive: true,
force: true,
filter: source => {
const name = path.basename(source)
if (EXCLUDED.has(name)) return false
if (name.endsWith('.pyc') || name.endsWith('.pyo')) return false
// Defensive: skip any venv/ inside src so we never clobber dst's venv.
// (The source path the filter receives is rooted at src; that's why we
// check srcVenv here, not dstVenv.)
if (venvPreserved && (source === srcVenv || source.startsWith(srcVenv + path.sep))) {
return false
}
return true
}
})
}
function isPortAvailable(port) {
return new Promise(resolve => {
const server = net.createServer()
@ -2064,7 +2249,10 @@ function buildApplicationMenu() {
}
function toggleDevTools(window) {
if (!DEV_SERVER) return
// DevTools is enabled in packaged builds so users can diagnose renderer
// issues without needing a dev build. Trade-off: tiny attack surface
// increase versus a much better support story when WS connection or
// CSP issues surface in the field.
const { webContents } = window
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools()
@ -2074,7 +2262,7 @@ function toggleDevTools(window) {
}
function installDevToolsShortcut(window) {
if (!DEV_SERVER) return
// F12 / Cmd+Opt+I works in both dev and packaged builds.
window.webContents.on('before-input-event', (event, input) => {
const key = input.key.toLowerCase()
const isInspectShortcut =
@ -2419,6 +2607,15 @@ function resetHermesConnection() {
}
async function startHermes() {
// Latched-failure short-circuit: once bootstrap has failed in this
// process, every subsequent startHermes() call re-throws the same error
// without re-running install.ps1. This prevents the renderer's
// ensureGatewayOpen retries (and any other getConnection callers) from
// restarting a 5-10 minute install loop while the user is still reading
// the failure overlay.
if (bootstrapFailure) {
throw bootstrapFailure
}
if (connectionPromise) return connectionPromise
connectionPromise = (async () => {
@ -2591,7 +2788,7 @@ function createWindow() {
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: Boolean(DEV_SERVER)
devTools: true
}
})
@ -2644,7 +2841,27 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
bootstrapFailure = null
connectionPromise = null
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
return { ok: true }
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {

View file

@ -81,6 +81,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
},
// First-launch bootstrap progress -- emitted by the install.ps1 stage
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs).
// Renderer's install overlay subscribes to live events and queries the
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
updates: {
check: () => ipcRenderer.invoke('hermes:updates:check'),

View file

@ -1,767 +0,0 @@
; ============================================================================
; Hermes Desktop installer prerequisite detection page
; ============================================================================
;
; A native NSIS Wizard page (using nsDialogs) inserted between the directory
; selection page and the install-files page. Detects Python 3.11+, Git for
; Windows, and ripgrep; offers to install missing items via winget.
;
; Page sequence:
; Welcome Directory [PrereqPage] InstFiles Finish
;
; Hooks used:
; customInit open $TEMP\Hermes-Installer.log for diagnostics
; customPageAfterChangeDir page declaration (electron-builder's hook for
; inserting a page between Directory and InstFiles)
; customInstall execute winget for any prereqs the user
; checked on the page; close the log file
;
; Diagnostics:
; $TEMP\Hermes-Installer.log captures every detection probe (command,
; exit code, captured output), the user's checkbox choices, and full
; winget stdout/stderr for Python and ripgrep installs. Git install
; goes via ExecShellWait (for UAC focus reasons) which cannot capture
; output, so for Git we log start/end + the post-install filesystem
; probe result only. Users hitting bugs should attach this file.
;
; The Function declarations live at top-level in this file so they're parsed
; at include time; the customPageAfterChangeDir macro references them via
; the Page directive so the optimizer doesn't strip them.
;
; UAC behavior:
; Python: --scope user, no UAC.
; ripgrep: --scope user, no UAC.
; Git for Windows: always per-machine, triggers UAC prompt.
; Footer warns the user about Git's UAC; ExecShellWait preserves the
; foreground focus chain so the prompt comes to front.
;
; Detection:
; Python: try `py -3.11`/`-3.12`/`-3.13`/`-3.14`. The Python launcher
; returns exit 0 only when that specific version is installed. The
; Microsoft Store "Python stub" doesn't install py.exe, so users with
; only the stub get correctly classified as not-installed.
; Git: `where git` returns exit 0 if git is on PATH.
; ripgrep: `where rg` returns exit 0 if rg is on PATH.
; winget: `where winget` returns exit 0 on Win11 / Win10 1809+ with App
; Installer. If unavailable, the page shows manual download URLs.
;
; Required vs. recommended:
; Python and Git are REQUIRED without them the agent's runtime + terminal
; tool fail. The page emphasizes "required" wording and the bootstrapper
; throws if either is missing at first launch.
; ripgrep is RECOMMENDED Hermes' search_files tool uses it for fast
; .gitignore-aware search, and falls back to grep/find from Git Bash when
; missing (works but slower, less filtering). Page wording is softer for
; ripgrep so users understand they CAN skip it.
;
; Skip behaviors:
; - All three already detected page is auto-skipped via Abort
; - Silent install (/S) customInstall winget block skips
; - User unchecks all checkboxes page advances without running winget
; ============================================================================
!include "LogicLib.nsh"
!include "nsDialogs.nsh"
!include "WinMessages.nsh"
!include "FileFunc.nsh"
Var HermesDialog
Var HermesPyStatusLabel
Var HermesPyCheckbox
Var HermesGitStatusLabel
Var HermesGitCheckbox
Var HermesRgStatusLabel
Var HermesRgCheckbox
Var HermesFooterLabel
Var HermesHasWinget
Var HermesHasPython
Var HermesHasGit
Var HermesHasRipgrep
Var HermesInstallPython
Var HermesInstallGit
Var HermesInstallRipgrep
Var HermesLogHandle
Var HermesLogPath
; ----------------------------------------------------------------------------
; Installer logging
; ----------------------------------------------------------------------------
; We write a structured log to $TEMP\Hermes-Installer.log so users can attach
; it to bug reports when prereq detection or winget installs misbehave.
;
; Why this design:
; - The wizard's built-in Details panel only exists at runtime; once the
; user clicks Finish (or Cancel) it's gone. The file persists.
; - NSIS's built-in `LogSet on` / `LogText` requires the "advanced logging"
; build of makensis (NSIS_CONFIG_LOG=1), which electron-builder's bundled
; binary doesn't include. So we roll our own with FileWrite.
; - Every winget invocation streams its full stdout/stderr into the log via
; nsExec::ExecToStack the same data the Details panel shows, but
; captured for post-mortem.
; - Detection probes also log exit codes + captured output, so when a user
; reports "the page said Python isn't installed but I have it", we can
; see exactly which probes ran and what they returned.
; - File is opened (truncate mode) in customInit and explicitly closed at
; the end of customInstall. If the installer crashes or the user
; cancels before customInstall completes, the file remains on disk
; whatever we wrote up to that point survives. FileWrite per-line is a
; normal Windows I/O call that hits the kernel buffer cache; the OS
; flushes that buffer when the process exits, so even on hard cancel
; the user can attach a partial log.
;
; Macros:
; ${HermesLog} "free-form text" emit a timestamped line
; ${HermesLogKV} "key" "value" emit a "key = value" line
; ${HermesLogBlock} "label" "varname" emit a delimited block (no `$`)
;
; The macros are no-ops when $HermesLogHandle is empty (e.g. if FileOpen
; failed because $TEMP was unwritable rare but defensive).
; ----------------------------------------------------------------------------
!macro _HermesLogRaw Line
${If} $HermesLogHandle != ""
FileWrite $HermesLogHandle "${Line}$\r$\n"
${EndIf}
!macroend
!macro _HermesLogTimestamped Msg
; ${__TIMESTAMP__} is the BUILD-time stamp, not runtime. We want runtime,
; so use ${GetTime} from FileFunc.nsh. $R0..$R6 = day, month, year, dow,
; hour, minute, second. Stash callers' $R0$R6 first.
Push $R0
Push $R1
Push $R2
Push $R3
Push $R4
Push $R5
Push $R6
${GetTime} "" "L" $R0 $R1 $R2 $R3 $R4 $R5 $R6
${If} $HermesLogHandle != ""
FileWrite $HermesLogHandle "[$R2-$R1-$R0 $R4:$R5:$R6] ${Msg}$\r$\n"
${EndIf}
Pop $R6
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $R0
!macroend
!define HermesLog "!insertmacro _HermesLogTimestamped"
!macro _HermesLogKV Key Value
${HermesLog} "${Key} = ${Value}"
!macroend
!define HermesLogKV "!insertmacro _HermesLogKV"
; HermesLogBlock write a multi-line block (typically captured command
; output) with a "--- begin/end ---" frame so it's clear in the log where
; the captured payload starts and stops. The `Payload` parameter is the
; NSIS variable name (without `$`) holding the captured string.
!macro _HermesLogBlock Label PayloadVar
${HermesLog} "--- begin ${Label} ---"
!insertmacro _HermesLogRaw "$${PayloadVar}"
${HermesLog} "--- end ${Label} ---"
!macroend
!define HermesLogBlock "!insertmacro _HermesLogBlock"
; ----------------------------------------------------------------------------
; HermesDetectPythonViaRegistry sets $HermesHasPython="1" if a PEP 514
; entry exists for any of the supported Python versions. Reads HKLM
; (system-wide installs) then HKCU (per-user installs). Vendor "PythonCore"
; covers official python.org distributions; "ContinuumAnalytics" covers
; Anaconda/Miniconda. We don't enumerate other vendors because they're
; rare in our user base and we'd rather miss them and let winget add a
; second Python than misclassify something else as a working Python.
; ----------------------------------------------------------------------------
Function HermesDetectPythonViaRegistry
Push $1
Push $2
${HermesLog} "registry: scanning HKLM/HKCU SOFTWARE\Python\PythonCore for 3.11/3.12/3.13"
; Set view to 64-bit on x64 systems so we read the right hive the
; default 32-bit view would miss a 64-bit Python install on 64-bit
; Windows. SetRegView 32 restored at function exit.
SetRegView 64
; Iterate the supported versions. Each is its own ReadRegStr NSIS
; doesn't have loops over arrays inside functions easily, and four
; copies is clearer than gymnastics with $R0-$R9.
ReadRegStr $1 HKLM "SOFTWARE\Python\PythonCore\3.11\InstallPath" ""
${If} $1 != ""
${HermesLog} " hit: HKLM\Python\PythonCore\3.11\InstallPath = $1"
StrCpy $HermesHasPython "1"
Goto hermes_py_reg_done
${EndIf}
ReadRegStr $1 HKLM "SOFTWARE\Python\PythonCore\3.12\InstallPath" ""
${If} $1 != ""
${HermesLog} " hit: HKLM\Python\PythonCore\3.12\InstallPath = $1"
StrCpy $HermesHasPython "1"
Goto hermes_py_reg_done
${EndIf}
ReadRegStr $1 HKLM "SOFTWARE\Python\PythonCore\3.13\InstallPath" ""
${If} $1 != ""
${HermesLog} " hit: HKLM\Python\PythonCore\3.13\InstallPath = $1"
StrCpy $HermesHasPython "1"
Goto hermes_py_reg_done
${EndIf}
ReadRegStr $1 HKCU "SOFTWARE\Python\PythonCore\3.11\InstallPath" ""
${If} $1 != ""
${HermesLog} " hit: HKCU\Python\PythonCore\3.11\InstallPath = $1"
StrCpy $HermesHasPython "1"
Goto hermes_py_reg_done
${EndIf}
ReadRegStr $1 HKCU "SOFTWARE\Python\PythonCore\3.12\InstallPath" ""
${If} $1 != ""
${HermesLog} " hit: HKCU\Python\PythonCore\3.12\InstallPath = $1"
StrCpy $HermesHasPython "1"
Goto hermes_py_reg_done
${EndIf}
ReadRegStr $1 HKCU "SOFTWARE\Python\PythonCore\3.13\InstallPath" ""
${If} $1 != ""
${HermesLog} " hit: HKCU\Python\PythonCore\3.13\InstallPath = $1"
StrCpy $HermesHasPython "1"
Goto hermes_py_reg_done
${EndIf}
${HermesLog} " no registry keys matched"
hermes_py_reg_done:
SetRegView 32
Pop $2
Pop $1
FunctionEnd
; ----------------------------------------------------------------------------
; HermesDetectPythonViaFilesystem sets $HermesHasPython="1" if a Python
; install exists at one of the standard locations. FileExists never runs
; the binary so this is safe even if the user has the MS Store stub on
; their PATH. We probe both system-wide (Program Files) and per-user
; (LocalAppData\Programs) install locations for versions 3.113.14.
; ----------------------------------------------------------------------------
Function HermesDetectPythonViaFilesystem
${HermesLog} "filesystem: probing standard Python install paths"
; System-wide installs (default location for python.org with admin)
${If} ${FileExists} "$PROGRAMFILES64\Python311\python.exe"
${HermesLog} " hit: $PROGRAMFILES64\Python311\python.exe"
StrCpy $HermesHasPython "1"
Return
${EndIf}
${If} ${FileExists} "$PROGRAMFILES64\Python312\python.exe"
${HermesLog} " hit: $PROGRAMFILES64\Python312\python.exe"
StrCpy $HermesHasPython "1"
Return
${EndIf}
${If} ${FileExists} "$PROGRAMFILES64\Python313\python.exe"
${HermesLog} " hit: $PROGRAMFILES64\Python313\python.exe"
StrCpy $HermesHasPython "1"
Return
${EndIf}
; Per-user installs (default location for python.org without admin
; or with "Install for me only"). Covers the user-reported case.
${If} ${FileExists} "$LOCALAPPDATA\Programs\Python\Python311\python.exe"
${HermesLog} " hit: $LOCALAPPDATA\Programs\Python\Python311\python.exe"
StrCpy $HermesHasPython "1"
Return
${EndIf}
${If} ${FileExists} "$LOCALAPPDATA\Programs\Python\Python312\python.exe"
${HermesLog} " hit: $LOCALAPPDATA\Programs\Python\Python312\python.exe"
StrCpy $HermesHasPython "1"
Return
${EndIf}
${If} ${FileExists} "$LOCALAPPDATA\Programs\Python\Python313\python.exe"
${HermesLog} " hit: $LOCALAPPDATA\Programs\Python\Python313\python.exe"
StrCpy $HermesHasPython "1"
Return
${EndIf}
${HermesLog} " no filesystem paths matched"
FunctionEnd
; ----------------------------------------------------------------------------
; HermesProbe small wrapper around nsExec::ExecToStack that captures both
; exit code and stdout/stderr into a single log entry. Used instead of bare
; nsExec::Exec so we have evidence for "the page said X isn't installed but
; I have it" bug reports.
;
; Caller pushes command string onto the stack before Call.
; On return:
; $0 = exit code (0 = success on Windows)
; $1 = captured stdout+stderr (truncated by NSIS to ~64KB)
; Caller's $0/$1 are clobbered; $9 is preserved.
; ----------------------------------------------------------------------------
Function HermesProbe
; Stack on entry (top bottom): <command>, <caller-return-addr>, ...
; Save $9 so we can use it as a local. Standard NSIS stack-arg idiom:
; Exch $9 ; $9 = arg, old $9 pushed onto stack
; ... work ...
; Pop $9 ; restore old $9 (discards arg)
Exch $9
nsExec::ExecToStack '$9'
Pop $0 ; exit code
Pop $1 ; captured output
${HermesLog} "probe: $9"
${HermesLog} " exit = $0"
${If} $1 != ""
${HermesLogBlock} "probe output" "1"
${EndIf}
Pop $9 ; restore caller's $9 (discards the command arg)
FunctionEnd
; ----------------------------------------------------------------------------
; HermesDetectPrereqs populates $HermesHasWinget / $HermesHasPython /
; $HermesHasGit / $HermesHasRipgrep with "0" or "1". Called from the
; page-create function. Every probe is logged via HermesProbe.
; ----------------------------------------------------------------------------
Function HermesDetectPrereqs
${HermesLog} "=== HermesDetectPrereqs: begin ==="
; --- winget ---
Push 'cmd.exe /c where winget'
Call HermesProbe
${If} $0 == 0
StrCpy $HermesHasWinget "1"
${Else}
StrCpy $HermesHasWinget "0"
${EndIf}
${HermesLogKV} "HermesHasWinget" "$HermesHasWinget"
; --- Python 3.11 / 3.12 / 3.13 ---
; We deliberately accept 3.113.13 only and NOT 3.14, because some of
; Hermes' transitive deps (notably pywinpty, which carries Rust crates
; like windows_x86_64_msvc) don't yet publish 3.14 wheels. Without
; wheels, `pip install -e .` falls back to building from sdist, which
; needs a Rust toolchain. Users without one see a confusing "could
; not compile windows_x86_64_msvc build script" error. install.ps1
; sidesteps this by pinning to 3.11 via uv; the desktop installer
; can't easily install uv in the same flow yet, so we just refuse to
; accept 3.14 as "good" and offer 3.11 via winget instead. Revisit
; when 3.14 wheels are widely available across our dep tree.
;
; Detection strategy, in order from most-precise to least-precise.
; Each step uses ONLY operations that don't execute `python.exe`
; directly off PATH running `python` on Windows can open the
; Microsoft Store if only the "Python stub" is installed, which is
; terrible UX during an installer. We avoid that by:
; (a) launcher checks (py.exe runs no python until -V),
; (b) registry reads (PEP 514, no execution at all),
; (c) filesystem probes via FileExists.
StrCpy $HermesHasPython "0"
; (1) The py launcher. Ships with python.org installer when
; "Install launcher for all users" is checked (default for some
; paths, not for per-user installs without elevation). When
; present, py -3.X --version returns 0 iff that version exists.
Push 'cmd.exe /c py -3.11 --version'
Call HermesProbe
${If} $0 == 0
StrCpy $HermesHasPython "1"
${Else}
Push 'cmd.exe /c py -3.12 --version'
Call HermesProbe
${If} $0 == 0
StrCpy $HermesHasPython "1"
${Else}
Push 'cmd.exe /c py -3.13 --version'
Call HermesProbe
${If} $0 == 0
StrCpy $HermesHasPython "1"
${EndIf}
${EndIf}
${EndIf}
${HermesLogKV} "after py-launcher probes, HermesHasPython" "$HermesHasPython"
; (2) PEP 514 registry probe. Every standards-compliant Python
; installer registers itself under HKLM or HKCU at
; SOFTWARE\Python\PythonCore\<version>\InstallPath. The MS Store
; stub does NOT register here so we get a clean signal for
; "real Python is installed" without ever risking the Store
; popup. Covers the case the user reported: per-user Python.org
; install without launcher checkbox, plus Anaconda which writes
; similar keys under a different vendor name.
${If} $HermesHasPython == "0"
Call HermesDetectPythonViaRegistry
${HermesLogKV} "after registry probe, HermesHasPython" "$HermesHasPython"
${EndIf}
; (3) Filesystem probe of common install locations. Catches edge
; cases where the installer didn't update the registry (rare
; but possible with hand-extracted Python or some third-party
; installers). We only check standard paths running anything
; would risk spawning the Store stub.
${If} $HermesHasPython == "0"
Call HermesDetectPythonViaFilesystem
${HermesLogKV} "after filesystem probe, HermesHasPython" "$HermesHasPython"
${EndIf}
; --- Git ---
Push 'cmd.exe /c where git'
Call HermesProbe
${If} $0 == 0
StrCpy $HermesHasGit "1"
${Else}
StrCpy $HermesHasGit "0"
${EndIf}
${HermesLogKV} "HermesHasGit" "$HermesHasGit"
; --- ripgrep ---
Push 'cmd.exe /c where rg'
Call HermesProbe
${If} $0 == 0
StrCpy $HermesHasRipgrep "1"
${Else}
StrCpy $HermesHasRipgrep "0"
${EndIf}
${HermesLogKV} "HermesHasRipgrep" "$HermesHasRipgrep"
${HermesLog} "=== HermesDetectPrereqs: end ==="
FunctionEnd
; ----------------------------------------------------------------------------
; HermesRunWinget invoke `winget install ...` and capture both exit code
; and full stdout/stderr to the log. Also replays the captured output to the
; install Details panel via DetailPrint so the user sees progress (batched
; at end rather than live acceptable trade-off; winget installs take 30-90
; seconds and emit ~10-30 lines).
;
; Caller pushes: <args-after-winget> (e.g. 'install -e --id Python...')
; <human-name> (e.g. 'Python 3.11')
; On return: $0 = winget exit code, $1 = full captured output
; ----------------------------------------------------------------------------
Function HermesRunWinget
Exch $9 ; $9 = human-name
Exch
Exch $8 ; $8 = args
Push $2 ; preserve for caller
${HermesLog} "winget: invoking for $9"
${HermesLog} " command: winget $8"
DetailPrint "Running: winget $8"
; ExecToStack captures up to ~64KB of combined stdout+stderr.
; The 'cmd.exe /c' wrapper ensures we use the user's PATH-resolved winget
; and that I/O redirection works portably.
nsExec::ExecToStack 'cmd.exe /c winget $8'
Pop $0 ; exit code
Pop $1 ; captured output
${HermesLog} " exit code = $0"
${If} $1 != ""
${HermesLogBlock} "winget output ($9)" "1"
; Echo captured output to Details panel so user sees what winget did.
; DetailPrint takes one line; the captured blob may contain $\r$\n. We
; pass it whole DetailPrint handles embedded newlines reasonably.
DetailPrint "$1"
${EndIf}
Pop $2
Pop $8
Pop $9
FunctionEnd
; ----------------------------------------------------------------------------
; HermesPrereqPageCreate builds the prereq page UI. If all three items are
; already installed we Abort, which causes NSIS to skip directly to the next
; page in the sequence (InstFiles).
; ----------------------------------------------------------------------------
Function HermesPrereqPageCreate
Call HermesDetectPrereqs
${If} $HermesHasPython == "1"
${AndIf} $HermesHasGit == "1"
${AndIf} $HermesHasRipgrep == "1"
${HermesLog} "page: all prereqs detected, auto-skipping prereq page"
Abort
${EndIf}
${HermesLog} "page: rendering prereq page (winget=$HermesHasWinget python=$HermesHasPython git=$HermesHasGit rg=$HermesHasRipgrep)"
; Set the wizard's standard header (top blue/gradient bar). 1037 is the
; title control, 1038 is the subtitle. Without this, the header still
; reads "Choose Install Location" left over from the Directory page.
GetDlgItem $0 $HWNDPARENT 1037
SendMessage $0 ${WM_SETTEXT} 0 "STR:System Requirements"
GetDlgItem $0 $HWNDPARENT 1038
SendMessage $0 ${WM_SETTEXT} 0 "STR:Hermes needs Python 3.11+ and Git for Windows. ripgrep is recommended."
nsDialogs::Create 1018
Pop $HermesDialog
${If} $HermesDialog == error
Abort
${EndIf}
StrCpy $HermesInstallPython "0"
StrCpy $HermesInstallGit "0"
StrCpy $HermesInstallRipgrep "0"
; Page body intro. The wizard's header (set above) shows the title
; "System Requirements" and subtitle, so we don't repeat them here
; just one short explanatory line.
${NSD_CreateLabel} 0u 0u 100% 16u "Items already installed are listed as detected. Missing items can be installed automatically via winget."
Pop $0
; --- Python panel (REQUIRED) ---
${NSD_CreateGroupBox} 0u 18u 100% 30u "Python 3.11+ (required)"
Pop $0
${If} $HermesHasPython == "1"
${NSD_CreateLabel} 8u 28u 95% 10u "Detected on your system."
Pop $HermesPyStatusLabel
${Else}
${If} $HermesHasWinget == "1"
${NSD_CreateLabel} 8u 27u 95% 9u "Not detected."
Pop $HermesPyStatusLabel
${NSD_CreateCheckbox} 8u 37u 95% 9u "Install Python 3.11"
Pop $HermesPyCheckbox
${NSD_Check} $HermesPyCheckbox
${Else}
${NSD_CreateLabel} 8u 27u 95% 14u "Not detected. Install manually from https://www.python.org/downloads/ and re-run this installer."
Pop $HermesPyStatusLabel
${EndIf}
${EndIf}
; --- Git panel (REQUIRED) ---
${NSD_CreateGroupBox} 0u 50u 100% 30u "Git for Windows (required, provides Git Bash)"
Pop $0
${If} $HermesHasGit == "1"
${NSD_CreateLabel} 8u 60u 95% 10u "Detected on your system."
Pop $HermesGitStatusLabel
${Else}
${If} $HermesHasWinget == "1"
${NSD_CreateLabel} 8u 59u 95% 9u "Not detected. Required by Hermes' terminal tool."
Pop $HermesGitStatusLabel
${NSD_CreateCheckbox} 8u 69u 95% 9u "Install Git for Windows"
Pop $HermesGitCheckbox
${NSD_Check} $HermesGitCheckbox
${Else}
${NSD_CreateLabel} 8u 59u 95% 14u "Not detected. Install manually from https://git-scm.com/download/win and re-run this installer."
Pop $HermesGitStatusLabel
${EndIf}
${EndIf}
; --- ripgrep panel (RECOMMENDED) ---
${NSD_CreateGroupBox} 0u 82u 100% 30u "ripgrep (recommended for fast file search)"
Pop $0
${If} $HermesHasRipgrep == "1"
${NSD_CreateLabel} 8u 92u 95% 10u "Detected on your system."
Pop $HermesRgStatusLabel
${Else}
${If} $HermesHasWinget == "1"
${NSD_CreateLabel} 8u 91u 95% 9u "Not detected. Hermes will fall back to slower grep/find."
Pop $HermesRgStatusLabel
${NSD_CreateCheckbox} 8u 101u 95% 9u "Install ripgrep"
Pop $HermesRgCheckbox
${NSD_Check} $HermesRgCheckbox
${Else}
${NSD_CreateLabel} 8u 91u 95% 14u "Not detected. Install manually from https://github.com/BurntSushi/ripgrep#installation if you want fast .gitignore-aware search."
Pop $HermesRgStatusLabel
${EndIf}
${EndIf}
; --- Footer (UAC notice when Git install will run) ---
${If} $HermesHasGit == "0"
${AndIf} $HermesHasWinget == "1"
${NSD_CreateLabel} 0u 116u 100% 18u "Note: Git for Windows requires administrator approval. The UAC prompt may appear behind this window — check your taskbar."
Pop $HermesFooterLabel
${EndIf}
nsDialogs::Show
FunctionEnd
; ----------------------------------------------------------------------------
; HermesPrereqPageLeave read checkbox states when the user clicks Next.
; Variables stay at "0" if a checkbox doesn't exist (because the
; corresponding prereq is already installed or winget isn't available).
; ----------------------------------------------------------------------------
Function HermesPrereqPageLeave
${If} $HermesHasPython == "0"
${AndIf} $HermesHasWinget == "1"
${NSD_GetState} $HermesPyCheckbox $HermesInstallPython
${EndIf}
${If} $HermesHasGit == "0"
${AndIf} $HermesHasWinget == "1"
${NSD_GetState} $HermesGitCheckbox $HermesInstallGit
${EndIf}
${If} $HermesHasRipgrep == "0"
${AndIf} $HermesHasWinget == "1"
${NSD_GetState} $HermesRgCheckbox $HermesInstallRipgrep
${EndIf}
${HermesLog} "page: user choices — install_python=$HermesInstallPython install_git=$HermesInstallGit install_ripgrep=$HermesInstallRipgrep"
FunctionEnd
; ----------------------------------------------------------------------------
; Page declaration inserted between the Directory page and InstFiles via
; the customPageAfterChangeDir hook (defined in
; node_modules/app-builder-lib/templates/nsis/assistedInstaller.nsh, included
; whenever build.nsis.oneClick=false).
;
; Note: NSIS's optimizer emits "warning 6010: install function ... not
; referenced" for these functions because Page custom directives don't count
; as references in the optimizer's reference-tracking pass. We set
; build.nsis.warningsAsErrors=false in package.json so this warning doesn't
; fail the build. The functions ARE actually called by NSIS at page-display
; time the optimizer just can't see it statically.
; ----------------------------------------------------------------------------
!macro customPageAfterChangeDir
Page custom HermesPrereqPageCreate HermesPrereqPageLeave
!macroend
; ----------------------------------------------------------------------------
; customInit runs at installer startup, before any page. We use it to open
; the installer log file. The log path is $TEMP\Hermes-Installer.log; we
; truncate (mode "w") on each install so users don't get an ever-growing
; file. Users hitting bugs are asked to attach this file.
;
; If FileOpen fails (e.g. $TEMP unwritable, AV blocking) we just leave
; $HermesLogHandle empty every log macro is a no-op when the handle is
; empty, so the installer still works, we just lose the diagnostic.
; ----------------------------------------------------------------------------
!macro customInit
StrCpy $HermesLogPath "$TEMP\Hermes-Installer.log"
ClearErrors
FileOpen $HermesLogHandle "$HermesLogPath" w
${If} ${Errors}
StrCpy $HermesLogHandle ""
; Don't MessageBox — installers shouldn't bother the user about logging
; failures. We still install successfully; we just won't have a log.
${Else}
; UTF-8 BOM so Notepad / editors don't garble any non-ASCII in winget
; output (which uses characters and other glyphs in some locales).
FileWriteByte $HermesLogHandle "239"
FileWriteByte $HermesLogHandle "187"
FileWriteByte $HermesLogHandle "191"
${HermesLog} "================================================================"
${HermesLog} "Hermes Desktop installer log"
${HermesLog} "================================================================"
${HermesLogKV} "log path" "$HermesLogPath"
${HermesLogKV} "installer name" "$EXEFILE"
${HermesLogKV} "installer dir" "$EXEDIR"
${HermesLogKV} "install target dir" "$INSTDIR"
${HermesLogKV} "TEMP" "$TEMP"
${HermesLogKV} "WINDIR" "$WINDIR"
${HermesLogKV} "PROGRAMFILES64" "$PROGRAMFILES64"
${HermesLogKV} "LOCALAPPDATA" "$LOCALAPPDATA"
${HermesLog} "================================================================"
${EndIf}
!macroend
; ----------------------------------------------------------------------------
; customInstall runs the actual winget commands for whatever prereqs the
; user checked on the page. Output streams to the install progress log AND
; to $HermesLogPath via HermesRunWinget.
; ----------------------------------------------------------------------------
!macro customInstall
${HermesLog} "=== customInstall: begin ==="
; Tell the user where the log lives so they can attach it if anything
; goes wrong. Shown in the install Details panel.
${If} $HermesLogHandle != ""
DetailPrint "Installer log: $HermesLogPath"
${EndIf}
; Skip on silent installs (managed deploys handle prereqs out-of-band).
IfSilent 0 hermes_prereq_not_silent
${HermesLog} "silent install (/S) — skipping prereq winget block"
Goto hermes_prereq_install_done
hermes_prereq_not_silent:
${If} $HermesInstallPython == "1"
; Python with --scope user installs to %LOCALAPPDATA%\Programs\Python\
; no UAC, no foreground chain to preserve. HermesRunWinget captures
; both the Details-panel output AND a copy to the installer log.
DetailPrint "Installing Python 3.11+ via winget (silent per-user install, no admin prompt)..."
Push 'install -e --id Python.Python.3.11 --scope user --silent --disable-interactivity --accept-package-agreements --accept-source-agreements'
Push 'Python 3.11'
Call HermesRunWinget
${If} $0 != 0
DetailPrint "Python install via winget exited with code $0."
${HermesLog} "Python install FAILED (exit $0). User notified via MessageBox."
MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "Python install via winget did not complete successfully (exit code $0).$\r$\n$\r$\nSee log: $HermesLogPath$\r$\n$\r$\nYou can install Python 3.11+ manually from https://www.python.org/downloads/ after Hermes setup finishes. Hermes will not run until Python is installed."
${Else}
DetailPrint "Python 3.11+ installed successfully."
${HermesLog} "Python install succeeded"
${EndIf}
${EndIf}
${If} $HermesInstallRipgrep == "1"
; ripgrep with --scope user ~5MB, no UAC needed. Failure is non-fatal:
; Hermes' search_files tool falls back to grep/find from Git Bash.
DetailPrint "Installing ripgrep via winget (silent per-user install, no admin prompt)..."
Push 'install -e --id BurntSushi.ripgrep.MSVC --scope user --silent --disable-interactivity --accept-package-agreements --accept-source-agreements'
Push 'ripgrep'
Call HermesRunWinget
${If} $0 != 0
DetailPrint "ripgrep install via winget exited with code $0 (non-fatal — Hermes will fall back to grep/find)."
${HermesLog} "ripgrep install failed (exit $0) — non-fatal"
${Else}
DetailPrint "ripgrep installed successfully."
${HermesLog} "ripgrep install succeeded"
${EndIf}
${EndIf}
${If} $HermesInstallGit == "1"
; Git for Windows always installs per-machine and triggers UAC. We use
; ExecShellWait (NSIS's wrapper around Windows ShellExecute) instead of
; nsExec because ShellExecute preserves the foreground focus chain
; across non-elevated elevated process spawns. With nsExec the
; intermediate hidden winget.exe breaks that chain and UAC ends up
; behind the installer window.
;
; Trade-off: ExecShellWait doesn't capture output, so winget runs in
; its own console window. The console flashes briefly while winget
; downloads, then UAC fires for the elevated Git installer with
; correct foreground promotion. We CANNOT log winget's stdout/stderr
; for this case; we only log start time, end time, and the post-
; install filesystem probe result.
DetailPrint "Installing Git for Windows via winget (UAC prompt will appear)..."
${HermesLog} "Git: starting ExecShellWait — UAC will fire; no stdout capture possible"
${HermesLog} " command: winget install -e --id Git.Git --silent --disable-interactivity --accept-package-agreements --accept-source-agreements"
ExecShellWait "open" "winget" "install -e --id Git.Git --silent --disable-interactivity --accept-package-agreements --accept-source-agreements" SW_SHOWNORMAL
${HermesLog} "Git: ExecShellWait returned (no exit code available from ShellExecute)"
; ExecShellWait returns no exit code, so verify by checking the file
; system directly. Don't use `where git` — that reads OUR process's
; PATH, which was captured at NSIS startup before Git's installer ran
; and modified the system PATH. Until we restart, the new PATH isn't
; visible to us. Probe Git's standard install locations instead.
StrCpy $0 "0" ; "git found" flag
${If} ${FileExists} "$PROGRAMFILES64\Git\bin\bash.exe"
${HermesLog} "Git: found bash.exe at $PROGRAMFILES64\Git\bin\bash.exe"
StrCpy $0 "1"
${ElseIf} ${FileExists} "$PROGRAMFILES\Git\bin\bash.exe"
${HermesLog} "Git: found bash.exe at $PROGRAMFILES\Git\bin\bash.exe"
StrCpy $0 "1"
${ElseIf} ${FileExists} "$PROGRAMFILES32\Git\bin\bash.exe"
${HermesLog} "Git: found bash.exe at $PROGRAMFILES32\Git\bin\bash.exe"
StrCpy $0 "1"
${ElseIf} ${FileExists} "$LOCALAPPDATA\Programs\Git\bin\bash.exe"
${HermesLog} "Git: found bash.exe at $LOCALAPPDATA\Programs\Git\bin\bash.exe"
StrCpy $0 "1"
${Else}
${HermesLog} "Git: bash.exe NOT found at any standard location"
${EndIf}
${If} $0 == "1"
DetailPrint "Git for Windows installed successfully."
${HermesLog} "Git install succeeded (filesystem probe positive)"
${Else}
DetailPrint "Git for Windows install did not complete (bash.exe not found at standard install locations)."
${HermesLog} "Git install FAILED (filesystem probe negative). User notified via MessageBox."
MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "Git for Windows install via winget did not complete successfully.$\r$\n$\r$\nSee log: $HermesLogPath$\r$\n$\r$\nYou can install Git for Windows manually from https://git-scm.com/download/win after Hermes setup finishes. Hermes' terminal tool will not work until Git Bash is available."
${EndIf}
${EndIf}
hermes_prereq_install_done:
${HermesLog} "=== customInstall: end ==="
; Flush by closing the log handle. NSIS doesn't expose fflush; FileClose
; both flushes and releases the handle. Subsequent macros become no-ops
; because we null out the handle. This is fine there are no more log
; sites after customInstall in the install path.
${If} $HermesLogHandle != ""
FileClose $HermesLogHandle
StrCpy $HermesLogHandle ""
${EndIf}
!macroend

View file

@ -15,20 +15,20 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && tsc -b && vite build",
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run stage:hermes && npm run builder -- --dir",
"dist": "npm run build && npm run stage:hermes && npm run builder",
"dist:mac": "npm run build && npm run stage:hermes && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run stage:hermes && npm run builder -- --mac dmg",
"dist:mac:zip": "npm run build && npm run stage:hermes && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run stage:hermes && npm run builder -- --win",
"dist:win:msi": "npm run build && npm run stage:hermes && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run stage:hermes && npm run builder -- --win nsis",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run builder -- --win",
"dist:win:msi": "npm run build && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs",
@ -140,8 +140,12 @@
"beforeBuild": "scripts/before-build.cjs",
"extraResources": [
{
"from": "build/hermes-agent",
"to": "hermes-agent"
"from": "build/install-stamp.json",
"to": "install-stamp.json"
},
{
"from": "build/native-deps",
"to": "native-deps"
}
],
"asar": true,
@ -202,7 +206,6 @@
"perMachine": false,
"shortcutName": "Hermes",
"uninstallDisplayName": "Hermes",
"include": "installer/prereq-check.nsh",
"warningsAsErrors": false
}
}

View file

@ -1,8 +1,10 @@
/**
* Desktop bundles ship precompiled renderer assets and a staged Hermes payload
* from extraResources. Returning false here tells electron-builder to skip the
* node_modules collector/install step, which avoids workspace dependency graph
* explosions and keeps packaging deterministic across environments.
* Desktop bundles ship precompiled renderer assets. Returning false here tells
* electron-builder to skip the node_modules collector/install step, which
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`.
*/
module.exports = async function beforeBuild() {
return false

View file

@ -1,109 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '../..')
const OUT_ROOT = path.join(DESKTOP_ROOT, 'build', 'hermes-agent')
const ROOT_FILES = [
'README.md',
'LICENSE',
'pyproject.toml',
'run_agent.py',
'model_tools.py',
'toolsets.py',
'batch_runner.py',
'trajectory_compressor.py',
'toolset_distributions.py',
'cli.py',
'hermes_constants.py',
'hermes_logging.py',
'hermes_state.py',
'hermes_time.py',
'rl_cli.py',
'utils.py'
]
const ROOT_DIRS = [
'acp_adapter',
'agent',
'cron',
'gateway',
'hermes_cli',
'plugins',
'scripts',
'skills',
'tools',
'tui_gateway'
]
const TUI_FILES = ['package.json', 'package-lock.json']
const TUI_DIRS = ['dist', 'packages/hermes-ink/dist']
const EXCLUDED_NAMES = new Set([
'.DS_Store',
'.git',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'.venv',
'__pycache__',
'node_modules',
'release',
'venv'
])
function keep(entry) {
return !EXCLUDED_NAMES.has(entry.name) && !entry.name.endsWith('.pyc') && !entry.name.endsWith('.pyo')
}
async function exists(target) {
try {
await fs.access(target)
return true
} catch {
return false
}
}
async function copyFileIfPresent(relativePath) {
const from = path.join(REPO_ROOT, relativePath)
if (!(await exists(from))) return
const to = path.join(OUT_ROOT, relativePath)
await fs.mkdir(path.dirname(to), { recursive: true })
await fs.copyFile(from, to)
}
async function copyDirIfPresent(relativePath) {
const from = path.join(REPO_ROOT, relativePath)
if (!(await exists(from))) return
const to = path.join(OUT_ROOT, relativePath)
await fs.cp(from, to, {
recursive: true,
filter: source => keep({ name: path.basename(source) })
})
}
async function main() {
await fs.rm(OUT_ROOT, { force: true, recursive: true })
await fs.mkdir(OUT_ROOT, { recursive: true })
await Promise.all(ROOT_FILES.map(copyFileIfPresent))
for (const dir of ROOT_DIRS) {
await copyDirIfPresent(dir)
}
for (const file of TUI_FILES) {
await copyFileIfPresent(path.join('ui-tui', file))
}
for (const dir of TUI_DIRS) {
await copyDirIfPresent(path.join('ui-tui', dir))
}
}
await main()

View file

@ -0,0 +1,127 @@
'use strict'
/**
* Stage native node-modules dependencies for electron-builder packaging.
*
* Workspace dedup hoists @homebridge/node-pty-prebuilt-multiarch into the
* root `node_modules/`, which electron-builder's default file collector
* (when `files:` is explicitly set in package.json) cannot reach. The
* result: packaged builds ship with no .node binaries and PTY initialization
* fails at runtime ("PTY support is unavailable").
*
* Rather than restructure the workspace dedup (would require nohoist /
* package.json shenanigans and risk breaking dev) or balloon the package
* with the whole node_modules tree, we copy ONLY the runtime-essential
* files of the native dep into apps/desktop/build/native-deps/ and ship
* THAT subtree via extraResources. main.cjs falls back to require()-ing
* from process.resourcesPath when the hoisted-root require fails.
*
* Runs as part of `npm run build`. Idempotent -- always re-stages on each
* build to pick up native binary updates.
*/
const fs = require('node:fs')
const path = require('node:path')
const APP_ROOT = path.resolve(__dirname, '..')
const REPO_ROOT = path.resolve(APP_ROOT, '..', '..')
const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps')
// Modules to stage. The "from" path is the hoisted location in the workspace
// root; "to" is the layout we want inside build/native-deps/. The "include"
// globs (relative to "from") select the runtime-essential files. Anything
// outside the include list is left behind (source, deps/, scripts/, etc.).
const NATIVE_DEPS = [
{
from: path.join(REPO_ROOT, 'node_modules', '@homebridge', 'node-pty-prebuilt-multiarch'),
to: path.join(STAGE_ROOT, '@homebridge', 'node-pty-prebuilt-multiarch'),
include: [
'package.json',
'lib/**',
'build/Release/*.node'
]
}
]
function rmrf(target) {
fs.rmSync(target, { recursive: true, force: true })
}
function ensureDir(target) {
fs.mkdirSync(target, { recursive: true })
}
function walk(root) {
const results = []
const stack = [root]
while (stack.length) {
const current = stack.pop()
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.isDirectory()) {
stack.push(full)
} else if (entry.isFile()) {
results.push(full)
}
}
}
return results
}
// Match a relative path against simple ** and * glob patterns. Implementation
// is intentionally tiny -- the include lists are small and don't need full
// minimatch support.
function matchGlob(rel, pattern) {
const r = rel.replace(/\\/g, '/')
const re = new RegExp(
'^' +
pattern
.replace(/\\/g, '/')
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '__DOUBLE_STAR__')
.replace(/\*/g, '[^/]*')
.replace(/__DOUBLE_STAR__/g, '.*') +
'$'
)
return re.test(r)
}
function stageOne(spec) {
if (!fs.existsSync(spec.from)) {
throw new Error(
`stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` +
`at the workspace root first.`
)
}
rmrf(spec.to)
ensureDir(spec.to)
const files = walk(spec.from)
let copied = 0
for (const abs of files) {
const rel = path.relative(spec.from, abs)
const included = spec.include.some(g => matchGlob(rel, g))
if (!included) continue
const dest = path.join(spec.to, rel)
ensureDir(path.dirname(dest))
fs.copyFileSync(abs, dest)
copied += 1
}
console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`)
}
function main() {
rmrf(STAGE_ROOT)
ensureDir(STAGE_ROOT)
for (const spec of NATIVE_DEPS) {
stageOne(spec)
}
}
main()

View file

@ -10,12 +10,54 @@ const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package
const MODE = process.argv[2] || 'help'
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const APP_PATH = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
const APP_BIN = path.join(APP_PATH, 'Contents', 'MacOS', 'Hermes')
// Default HERMES_HOME for non-sandboxed mac runs — matches main.cjs's
// resolveHermesHome(). The fresh-install sandbox launchFresh() sets its own
const PLATFORM = process.platform
// Platform-specific packaged-app layout. The thin installer ships an Electron
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
// no longer bundles the Hermes Agent Python payload (that's fetched at first
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
const APP = (() => {
if (PLATFORM === 'darwin') {
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
return {
appPath,
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
}
}
if (PLATFORM === 'win32') {
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
return {
appPath: unpacked,
binary: path.join(unpacked, 'Hermes.exe'),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
}
}
// linux unpacked layout matches windows but with different binary name
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
return {
appPath: unpacked,
binary: path.join(unpacked, 'hermes'),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
}
})()
// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this.
const DEFAULT_HERMES_HOME = path.join(os.homedir(), '.hermes')
const DEFAULT_HERMES_HOME = (() => {
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
return path.join(process.env.LOCALAPPDATA, 'hermes')
}
return path.join(os.homedir(), '.hermes')
})()
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
@ -28,7 +70,7 @@ function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || DESKTOP_ROOT,
env: options.env || process.env,
shell: Boolean(options.shell),
shell: Boolean(options.shell) || PLATFORM === 'win32',
stdio: 'inherit'
})
@ -37,19 +79,43 @@ function run(command, args, options = {}) {
}
}
function output(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
return result.status === 0 ? result.stdout.trim() : ''
}
function exists(target) {
return fs.existsSync(target)
}
// Match nodepty native binding location to what main.cjs's resolver fallback
// expects (apps/desktop/electron/main.cjs, packaged-build branch).
function expectedNativeDepPaths() {
const root = path.join(APP.resourcesPath, 'native-deps', '@homebridge', 'node-pty-prebuilt-multiarch')
const releaseDir = path.join(root, 'build', 'Release')
// Just check the package.json exists; the actual .node binary names vary
// (pty.node + conpty.node on Windows; pty.node on Unix), so we let the
// existence-of-the-directory + a non-empty list be enough.
return {
packageJson: path.join(root, 'package.json'),
releaseDir,
libIndex: path.join(root, 'lib', 'index.js')
}
}
function ensurePlatformBuilds() {
if (PLATFORM === 'darwin') return
if (PLATFORM === 'win32') return
die(
`Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` +
`is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` +
`install.sh's stage protocol.`
)
}
function ensurePackagedApp() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP.binary)) {
return
}
run('npm', ['run', 'pack'])
}
function resolveDmgPath() {
if (!exists(RELEASE_ROOT)) {
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
@ -67,49 +133,68 @@ function resolveDmgPath() {
return bMtime - aMtime
})
if (candidates.length > 0) {
return path.join(RELEASE_ROOT, candidates[0])
}
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
return candidates.length > 0
? path.join(RELEASE_ROOT, candidates[0])
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
function ensureMac() {
if (process.platform !== 'darwin') {
die('Desktop launch tests are macOS-only from this script.')
}
}
function ensurePackagedApp() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP_BIN)) {
return
}
run('npm', ['run', 'pack'])
function resolveNsisPath() {
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
if (!exists(RELEASE_ROOT)) return null
const candidates = fs
.readdirSync(RELEASE_ROOT)
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
.sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
return bMtime - aMtime
})
return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) : null
}
function ensureDmg() {
if (PLATFORM !== 'darwin') {
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
}
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
return
}
run('npm', ['run', 'dist:mac:dmg'])
}
function ensureNsis() {
if (PLATFORM !== 'win32') {
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
}
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
return
}
run('npm', ['run', 'dist:win:nsis'])
}
function openApp() {
if (!exists(APP_PATH)) {
die(`Missing packaged app: ${APP_PATH}`)
if (!exists(APP.binary)) {
die(`Missing packaged app: ${APP.binary}`)
}
run('open', ['-n', APP_PATH])
if (PLATFORM === 'darwin') {
run('open', ['-n', APP.appPath])
} else if (PLATFORM === 'win32') {
// Spawn detached so the test script exits while the app keeps running.
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
} else {
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
}
}
function openDmg() {
if (PLATFORM !== 'darwin') {
die('DMG mode is macOS-only.')
}
const dmgPath = resolveDmgPath()
if (!exists(dmgPath)) {
die(`Missing DMG: ${dmgPath}`)
}
run('open', [dmgPath])
}
@ -145,13 +230,8 @@ function isCredentialEnvVar(name) {
}
function launchFresh() {
if (!exists(APP_BIN)) {
die(`Missing app executable: ${APP_BIN}`)
}
const python = output('which', ['python3'])
if (!python) {
die('python3 is required for fresh bundled-runtime bootstrap.')
if (!exists(APP.binary)) {
die(`Missing app executable: ${APP.binary}`)
}
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
@ -164,9 +244,6 @@ function launchFresh() {
fs.mkdirSync(cwd, { recursive: true })
// Strip every credential-shaped env var so the sandbox is actually fresh.
// Without this, shell-set OPENAI_API_KEY/OPENAI_BASE_URL/etc. leak into the
// packaged backend, making setup.status report "configured" while the
// agent's own credential resolution still fails.
const env = {}
for (const [key, value] of Object.entries(process.env)) {
if (isCredentialEnvVar(key)) continue
@ -181,7 +258,7 @@ function launchFresh() {
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
const child = spawn(APP_BIN, [], {
const child = spawn(APP.binary, [], {
cwd: os.homedir(),
detached: true,
env,
@ -198,74 +275,143 @@ function launchFresh() {
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
}
// Validate the packaged bundle matches the thin-installer architecture:
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
// launch via install.ps1's stage protocol).
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with
// the package.json + lib/ + at least one .node binary (the renderer's
// integrated terminal needs this; see Phase 1F.6).
// - The renderer's dist/index.html is reachable (either unpacked or
// inside app.asar).
function validateBundle() {
const appAsar = path.join(APP_PATH, 'Contents', 'Resources', 'app.asar')
const unpackedIndex = path.join(APP_PATH, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
const required = [
APP_BIN,
path.join(APP_PATH, 'Contents', 'Resources', 'hermes-agent', 'hermes_cli', 'main.py')
]
for (const target of required) {
if (!exists(target)) {
die(`Missing packaged payload file: ${target}`)
}
if (!exists(APP.binary)) {
die(`Missing packaged app binary: ${APP.binary}`)
}
if (exists(unpackedIndex)) {
return
// Negative assertion: the OLD fat-installer factory payload must NOT be
// present anymore. If a stray ship of hermes_cli sneaks back in we want
// to fail loudly rather than re-introduce the 400MB delta we just removed.
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
if (exists(staleFactoryMarker)) {
die(
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
)
}
if (!exists(appAsar)) {
die(`Missing renderer payload: neither ${unpackedIndex} nor ${appAsar} exists`)
// Positive assertion: install-stamp.json carries a sane commit + branch
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
if (!exists(stampPath)) {
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
}
let stamp
try {
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
} catch (err) {
die(`install-stamp.json is not valid JSON: ${err.message}`)
}
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
}
if (!stamp.branch || typeof stamp.branch !== 'string') {
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
}
const files = listPackage(appAsar)
if (!files.includes('/dist/index.html') && !files.includes('dist/index.html')) {
die(`Missing renderer payload file in app.asar: ${appAsar} (expected dist/index.html)`)
// Positive assertion: node-pty native deps shipped
const native = expectedNativeDepPaths()
if (!exists(native.packageJson)) {
die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`)
}
if (!exists(native.libIndex)) {
die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`)
}
if (!exists(native.releaseDir)) {
die(`Missing node-pty build/Release directory: ${native.releaseDir}`)
}
const nodeBinaries = fs.readdirSync(native.releaseDir).filter(name => name.endsWith('.node'))
if (nodeBinaries.length === 0) {
die(`No .node native binaries found in: ${native.releaseDir}`)
}
// Renderer payload check (either unpacked or in the asar)
if (exists(APP.unpackedDistIndex)) {
return { stamp, nodeBinaries }
}
if (!exists(APP.asarPath)) {
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
}
const files = listPackage(APP.asarPath)
// Normalize separators because @electron/asar's listPackage returns
// backslash-prefixed entries on Windows ('\\dist\\index.html') and
// forward-slash on Unix.
const normalized = files.map(f => f.replace(/\\/g, '/').replace(/^\/+/, ''))
if (!normalized.includes('dist/index.html')) {
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
}
return { stamp, nodeBinaries }
}
function printArtifacts(options = {}) {
const runtimeRoot = options.runtimeRoot || VENV_ROOT
const stamp = options.stamp
console.log('\nDesktop artifacts:')
console.log(` app: ${APP_PATH}`)
console.log(` dmg: ${resolveDmgPath()}`)
console.log(` app: ${APP.appPath}`)
if (PLATFORM === 'darwin') {
console.log(` dmg: ${resolveDmgPath()}`)
} else if (PLATFORM === 'win32') {
const exe = resolveNsisPath()
if (exe) console.log(` installer: ${exe}`)
}
console.log(` runtime: ${runtimeRoot}`)
if (stamp) {
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
}
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
}
}
function help() {
console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
npm run test:desktop:dmg # build DMG and open it
npm run test:desktop:all # build DMG, validate app payload, print paths
npm run test:desktop:dmg # (macOS only) build DMG and open it
npm run test:desktop:nsis # (win32 only) build NSIS installer
npm run test:desktop:all # build installer, validate app payload, print paths
Fast rerun:
Fast rerun (skip rebuild if the packaged app already exists):
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
`)
}
ensureMac()
ensurePlatformBuilds()
if (MODE === 'existing') {
ensurePackagedApp()
validateBundle()
const result = validateBundle()
openApp()
printArtifacts()
printArtifacts(result)
} else if (MODE === 'fresh') {
ensurePackagedApp()
validateBundle()
printArtifacts(launchFresh())
const result = validateBundle()
printArtifacts({ ...launchFresh(), ...result })
} else if (MODE === 'dmg') {
ensureDmg()
openDmg()
printArtifacts()
} else if (MODE === 'nsis') {
ensureNsis()
printArtifacts(validateBundle())
} else if (MODE === 'all') {
ensureDmg()
validateBundle()
printArtifacts()
if (PLATFORM === 'darwin') {
ensureDmg()
} else if (PLATFORM === 'win32') {
ensureNsis()
} else {
ensurePackagedApp()
}
printArtifacts(validateBundle())
} else {
help()
}

View file

@ -0,0 +1,126 @@
"use strict"
/**
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
* .exe should pin to at first-launch bootstrap time. This file ships inside
* the packaged app via electron-builder's extraResources entry and is read
* by electron/main.cjs to drive the install.ps1 stage bootstrap flow.
*
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
* {
* "schemaVersion": 1,
* "commit": "<40-char SHA>",
* "branch": "<branch name>",
* "builtAt": "<ISO 8601 UTC timestamp>",
* "dirty": true|false,
* "source": "ci" | "local"
* }
*
* Source preference order:
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
* shallow clones, detached HEADs, etc. in CI.
* 2. Local `git rev-parse` against the parent repo (../..).
*
* Dev / out-of-repo builds without git produce an explicit error rather than
* silently writing an unstamped manifest -- the packaged app refuses to
* bootstrap without a stamp.
*/
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const STAMP_SCHEMA_VERSION = 1
const DESKTOP_ROOT = path.resolve(__dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
function tryExec(cmd, opts) {
try {
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
} catch {
return null
}
}
function fromCI() {
const sha = process.env.GITHUB_SHA
if (!sha) return null
const branch = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || null
return {
commit: sha,
branch: branch,
dirty: false, // CI builds from a checkout-of-ref by definition
source: "ci"
}
}
function fromLocalGit() {
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
if (!sha) return null
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
// We exclude untracked files (-uno) intentionally: a developer who's
// checked out an installer scratch dir alongside the repo shouldn't
// poison every local build with a [DIRTY] stamp. We DO care about
// tracked-but-modified files because those mean the .exe content
// differs from the commit being pinned.
const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
const dirty = status !== null && status.length > 0
return {
commit: sha,
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
dirty: dirty,
source: "local"
}
}
function main() {
const stamp = fromCI() || fromLocalGit()
if (!stamp || !stamp.commit) {
console.error(
"[write-build-stamp] ERROR: could not determine git commit.\n" +
" - $GITHUB_SHA not set\n" +
" - `git rev-parse HEAD` failed at " +
REPO_ROOT +
"\n" +
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
)
process.exit(1)
}
if (stamp.dirty) {
console.warn(
"[write-build-stamp] WARNING: working tree is dirty.\n" +
" Pinning to " +
stamp.commit.slice(0, 12) +
" but the packaged code may differ from that commit.\n" +
" Commit your changes before publishing this build."
)
}
const payload = {
schemaVersion: STAMP_SCHEMA_VERSION,
commit: stamp.commit,
branch: stamp.branch,
builtAt: new Date().toISOString(),
dirty: stamp.dirty,
source: stamp.source
}
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
console.log(
"[write-build-stamp] wrote " +
path.relative(REPO_ROOT, OUT_FILE) +
" -> " +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +
(stamp.dirty ? " [DIRTY]" : "")
)
}
main()

View file

@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -464,6 +465,7 @@ export function DesktopController() {
const overlays = (
<>
<DesktopInstallOverlay />
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {

View file

@ -0,0 +1,484 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
DesktopBootstrapStageResult,
DesktopBootstrapStageState,
DesktopBootstrapState
} from '@/global'
/**
* DesktopInstallOverlay
*
* Renders the first-launch install progress for Hermes Agent. Mounted always;
* shows itself only when main.cjs reports an in-flight bootstrap (state.active)
* OR an error from a completed-failed bootstrap (state.error). When the
* bootstrap finishes successfully the overlay fades out and the rest of the
* app (existing onboarding overlay -> main UI) takes over.
*
* Subscribes to two channels:
* - getBootstrapState() -- initial snapshot on mount
* - onBootstrapEvent(callback) -- live event stream
*
* The reducer is intentionally simple: every event mutates an in-component
* snapshot the same way main.cjs mutates its server-side snapshot. We don't
* try to reconcile -- if we miss an event (shouldn't happen) the initial
* getBootstrapState() call will resync the picture on the next render.
*
* Stages flagged needs_user_input render with a deliberately subdued style:
* they're expected to come back as skipped=true (install.ps1 short-circuits
* them under -NonInteractive). The post-install configuration flow that
* those stages cover (API key, model, persona, gateway autostart) is handled
* by the existing DesktopOnboardingOverlay, NOT by the install overlay.
*/
interface DesktopInstallOverlayProps {
/** When false, the overlay never renders -- useful for dev when we want
* to suppress it entirely. */
enabled?: boolean
}
interface StageRowProps {
descriptor: DesktopBootstrapStageDescriptor
result: DesktopBootstrapStageResult | undefined
isCurrent: boolean
}
const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
pending: 'Pending',
running: 'Installing',
succeeded: 'Done',
skipped: 'Skipped',
failed: 'Failed'
}
function formatStageName(name: string): string {
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) return name
return name
.split('-')
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
)
.join(' ')
}
function formatDuration(ms: number | null | undefined): string {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
if (ms < 1000) return `${ms} ms`
const s = ms / 1000
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rs = Math.round(s - m * 60)
return `${m}m ${rs}s`
}
function StageRow({ descriptor, result, isCurrent }: StageRowProps) {
const state: DesktopBootstrapStageState = result?.state || 'pending'
const icon = useMemo(() => {
switch (state) {
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'succeeded':
return <Check className="h-4 w-4 text-emerald-600" />
case 'skipped':
return <Check className="h-4 w-4 text-muted-foreground" />
case 'failed':
return <AlertTriangle className="h-4 w-4 text-destructive" />
case 'pending':
default:
return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" />
}
}, [state])
const reason = result?.json?.reason || result?.error || null
return (
<li
className={cn(
'flex items-start gap-3 rounded-md px-3 py-2 transition-colors',
isCurrent && 'bg-muted/60',
state === 'failed' && 'bg-destructive/10'
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className={cn(
'truncate text-sm font-medium',
state === 'pending' && 'text-muted-foreground'
)}
>
{formatStageName(descriptor.name)}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{state === 'running' ? STATE_LABEL[state] : null}
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
{state === 'failed' ? STATE_LABEL[state] : null}
</span>
</div>
{reason && state !== 'pending' && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
)}
</div>
</li>
)
}
const EMPTY_STATE: DesktopBootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
for (const stage of ev.stages) {
stages[stage.name] = { state: 'pending', durationMs: null, json: null, error: null }
}
return {
...state,
active: true,
manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion },
stages,
error: null,
startedAt: state.startedAt || Date.now()
}
}
if (ev.type === 'stage') {
return {
...state,
stages: {
...state.stages,
[ev.name]: {
state: ev.state,
durationMs: ev.durationMs ?? null,
json: ev.json ?? null,
error: ev.error ?? null
}
}
}
}
if (ev.type === 'log') {
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line })
while (next.length > 500) next.shift()
return { ...state, log: next }
}
if (ev.type === 'complete') {
return { ...state, active: false, completedAt: Date.now(), error: null }
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error' }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
active: false,
unsupportedPlatform: {
platform: ev.platform,
activeRoot: ev.activeRoot,
installCommand: ev.installCommand,
docsUrl: ev.docsUrl
}
}
}
return state
}
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const logEndRef = useRef<HTMLDivElement | null>(null)
// Subscribe to bootstrap events + load initial snapshot
useEffect(() => {
if (!enabled) return
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return
let cancelled = false
desktop
.getBootstrapState()
.then(snapshot => {
if (!cancelled && snapshot) setState(snapshot)
})
.catch(() => {
// Older Electron build without the IPC handler -- bootstrap UI just
// stays empty, app falls through to existing onboarding flow.
})
const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev)))
return () => {
cancelled = true
off?.()
}
}, [enabled])
// Autoscroll log to bottom when new lines arrive AND the log is open
useEffect(() => {
if (logOpen && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' })
}
}, [state.log.length, logOpen])
// Auto-expand the log panel when a bootstrap fails so the user immediately
// sees the install.ps1 output. Without this, the failure block shows just
// the top-level error message and the user has to click "Show installer
// output" to see WHY the stage failed.
useEffect(() => {
if (state.error) setLogOpen(true)
}, [state.error])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
const shouldShow = useMemo(() => {
if (!enabled) return false
if (state.active) return true
if (state.error) return true
if (state.unsupportedPlatform) return true
return false
}, [enabled, state.active, state.error, state.unsupportedPlatform])
if (!shouldShow) return null
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
// (no stage protocol equivalent yet). Show a copy-paste install command
// and the docs URL; user runs it from Terminal and relaunches the app.
if (state.unsupportedPlatform) {
const ups = state.unsupportedPlatform
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
<p className="mt-2 text-sm text-muted-foreground">
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
run the command below, then relaunch this app. Subsequent launches will skip this step.
</p>
<div className="mt-4">
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Install command</div>
<pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]">
<code>{ups.installCommand}</code>
</pre>
<div className="mt-2 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
void navigator.clipboard?.writeText(ups.installCommand).catch(() => {})
}}
>
Copy command
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
window.hermesDesktop?.openExternal?.(ups.docsUrl)
}}
>
View install docs
</Button>
</div>
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button
variant="default"
size="sm"
onClick={() => window.location.reload()}
>
I{'\u2019'}ve run it -- retry
</Button>
</div>
</div>
</div>
)
}
const stages = state.manifest?.stages || []
const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name
const completedCount = stages.filter(
s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped'
).length
const totalCount = stages.length
const failed = Boolean(state.error)
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border bg-card shadow-xl">
{/* Header -- always visible, never scrolls */}
<div className="flex-shrink-0 p-8 pb-4">
<h2 className="text-2xl font-semibold tracking-tight">
{failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'}
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
</p>
</div>
{/* Scrollable middle: progress, stages, error block, log */}
<div className="min-h-0 flex-1 overflow-y-auto px-8 pb-2">
{totalCount > 0 && (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>
{completedCount} of {totalCount} steps complete
{currentStage && ` -- now: ${formatStageName(currentStage)}`}
</span>
<span className="tabular-nums">{progressPct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full transition-all duration-300',
failed ? 'bg-destructive' : 'bg-primary'
)}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
{totalCount === 0 && state.active && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Fetching installer manifest...</span>
</div>
)}
{failed && state.error && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
<div className="mb-1 flex items-center gap-1.5 font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
<span>Error</span>
</div>
<p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
</div>
)}
{stages.length > 0 && (
<ol className="mb-4 space-y-1">
{stages.map(stage => (
<StageRow
key={stage.name}
descriptor={stage}
result={state.stages[stage.name]}
isCurrent={stage.name === currentStage}
/>
))}
</ol>
)}
<div className="border-t pt-3">
<button
type="button"
onClick={() => setLogOpen(v => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
</button>
{logOpen && (
<div className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}>
{state.log.length === 0 ? (
<div className="text-muted-foreground">No output yet.</div>
) : (
<>
{state.log.map((entry, i) => (
<div key={i} className="whitespace-pre-wrap break-words">
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
</div>
))}
<div ref={logEndRef} />
</>
)}
</div>
)}
</div>
</div>
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
const text = state.log
.map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line))
.join('\n')
const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text
try {
await navigator.clipboard.writeText(fullText)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// ignore -- some environments forbid clipboard writes
}
}}
>
{copied ? 'Copied!' : 'Copy output'}
</Button>
<Button
variant="default"
size="sm"
onClick={async () => {
// Tell main.cjs to clear its latched failure BEFORE we
// reload. Otherwise the renderer reload calls getConnection
// and main short-circuits to the latched error without
// re-running install.ps1.
try {
await window.hermesDesktop?.resetBootstrap?.()
} catch {
// best-effort -- continue with reload regardless
}
window.location.reload()
}}
>
Reload and retry
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -43,6 +43,9 @@ declare global {
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
check: () => Promise<DesktopUpdateStatus>
@ -172,6 +175,71 @@ export interface DesktopBootProgress {
timestamp: number
}
// First-launch install ("bootstrap") event types -- emitted by
// electron/bootstrap-runner.cjs and observed by the renderer install overlay.
// Mirrors the event shapes emitted by runBootstrap()'s onEvent callback.
export interface DesktopBootstrapStageDescriptor {
name: string
title?: string
category?: string
needs_user_input?: boolean
}
export type DesktopBootstrapStageState =
| 'pending'
| 'running'
| 'succeeded'
| 'skipped'
| 'failed'
export interface DesktopBootstrapStageResult {
state: DesktopBootstrapStageState
durationMs: number | null
json: { ok: boolean; skipped?: boolean; reason?: string | null; stage: string } | null
error: string | null
}
export interface DesktopBootstrapUnsupportedPlatform {
platform: string
activeRoot: string
installCommand: string
docsUrl: string
}
export interface DesktopBootstrapState {
active: boolean
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
stages: Record<string, DesktopBootstrapStageResult>
error: string | null
log: Array<{ ts: number; stage: string | null; line: string }>
startedAt: number | null
completedAt: number | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
}
export type DesktopBootstrapEvent =
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
| {
type: 'stage'
name: string
state: DesktopBootstrapStageState
durationMs?: number
json?: DesktopBootstrapStageResult['json']
error?: string | null
}
| { type: 'log'; stage?: string | null; line: string }
| { type: 'complete'; marker: Record<string, unknown> }
| { type: 'failed'; stage?: string | null; error: string }
| {
type: 'unsupported-platform'
platform: string
activeRoot: string
installCommand: string
docsUrl: string
}
export interface HermesApiRequest {
path: string
method?: string

View file

@ -105,10 +105,22 @@ _REVEAL_WINDOW_SECONDS = 30
# CORS: restrict to localhost origins only. The web UI is intended to run
# locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website
# read/modify config and secrets.
#
# Electron renderers load index.html via file:// URLs. Chromium sets the
# Origin header to "null" for such windows on the WebSocket upgrade request,
# which Starlette's CORSMiddleware rejects with HTTP 403 before the
# /api/ws route handler ever runs. We allow "null" explicitly so the
# packaged desktop app can connect; security is preserved because:
# 1. The gateway binds to 127.0.0.1 by default, so a malicious file://
# page on another machine can't reach it.
# 2. Every authenticated /api/ endpoint past the CORS layer is gated by
# the per-process session token, so even a local file:// page with
# Origin: null cannot make authenticated requests without already
# knowing the secret.
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
allow_origin_regex=r"^(https?://(localhost|127\.0\.0\.1)(:\d+)?|null)$",
allow_methods=["*"],
allow_headers=["*"],
)

View file

@ -16,6 +16,13 @@ param(
[switch]$NoVenv,
[switch]$SkipSetup,
[string]$Branch = "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 = "",
[string]$Tag = "",
[string]$HermesHome = "$env:LOCALAPPDATA\hermes",
[string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent",
@ -852,14 +859,36 @@ function Install-Repository {
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"
try {
git -c windows.appendAtomically=false fetch origin
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
git -c windows.appendAtomically=false checkout $Branch
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
git -c windows.appendAtomically=false pull origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git pull 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
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)" }
git -c windows.appendAtomically=false pull origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" }
}
} finally {
$ErrorActionPreference = $prevEAP
Pop-Location
}
$didUpdate = $true
@ -917,8 +946,20 @@ function Install-Repository {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Warn "Git clone failed -- downloading ZIP archive instead..."
try {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
$zipPath = "$env:TEMP\hermes-agent-$Branch.zip"
# 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://github.com/NousResearch/hermes-agent/archive/$Commit.zip"
$zipLabel = $Commit
} elseif ($Tag) {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/tags/$Tag.zip"
$zipLabel = $Tag
} else {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
$zipLabel = $Branch
}
$zipPath = "$env:TEMP\hermes-agent-$zipLabel.zip"
$extractPath = "$env:TEMP\hermes-agent-extract"
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
@ -960,6 +1001,37 @@ function Install-Repository {
Push-Location $InstallDir
git -c windows.appendAtomically=false config windows.appendAtomically 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
}
}
# Ensure submodules are initialized and updated
Write-Info "Initializing submodules..."
git -c windows.appendAtomically=false submodule update --init --recursive 2>$null