mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
Merge pull request #27822 from NousResearch/jq/desktop-thin-installer
feat(desktop): thin installer + first-launch install.ps1 bootstrap
This commit is contained in:
commit
bed626bdb2
17 changed files with 2072 additions and 1212 deletions
3
.github/workflows/desktop-release.yml
vendored
3
.github/workflows/desktop-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
466
apps/desktop/electron/bootstrap-runner.cjs
Normal file
466
apps/desktop/electron/bootstrap-runner.cjs
Normal 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
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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.11–3.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.11–3.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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
127
apps/desktop/scripts/stage-native-deps.cjs
Normal file
127
apps/desktop/scripts/stage-native-deps.cjs
Normal 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()
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
126
apps/desktop/scripts/write-build-stamp.cjs
Normal file
126
apps/desktop/scripts/write-build-stamp.cjs
Normal 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()
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
484
apps/desktop/src/components/desktop-install-overlay.tsx
Normal file
484
apps/desktop/src/components/desktop-install-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
apps/desktop/src/global.d.ts
vendored
68
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=["*"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue