diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 8eeb493d9c3..f2280cfb3d4 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8c2657b0da4..34ea5496601 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 -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): diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs new file mode 100644 index 00000000000..15e0ca7f7cd --- /dev/null +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -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: } + * { 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) : ''}; ` + + `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 +} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 0d1c77d514c..40a6d3ebc67 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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: "" | null, +// completedAt: "", +// desktopVersion: "" // 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 /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) => { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 2cc04f5a62e..44554996c9c 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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'), diff --git a/apps/desktop/installer/prereq-check.nsh b/apps/desktop/installer/prereq-check.nsh deleted file mode 100644 index 088679d7101..00000000000 --- a/apps/desktop/installer/prereq-check.nsh +++ /dev/null @@ -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): , , ... - ; 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\\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: (e.g. 'install -e --id Python...') -; (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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 634356f55f0..fa7f5acfe23 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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 } } diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.cjs index 5b02750438c..673aca380d3 100644 --- a/apps/desktop/scripts/before-build.cjs +++ b/apps/desktop/scripts/before-build.cjs @@ -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 diff --git a/apps/desktop/scripts/stage-hermes-payload.mjs b/apps/desktop/scripts/stage-hermes-payload.mjs deleted file mode 100644 index aab317974e1..00000000000 --- a/apps/desktop/scripts/stage-hermes-payload.mjs +++ /dev/null @@ -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() diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs new file mode 100644 index 00000000000..b8e037f54c6 --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.cjs @@ -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() diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index 029cbf3d2bb..161664d5c9d 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -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() } diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.cjs new file mode 100644 index 00000000000..72b978c5f9a --- /dev/null +++ b/apps/desktop/scripts/write-build-stamp.cjs @@ -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": "", + * "builtAt": "", + * "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() diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index c6f9143ed0e..0f0a64dd5fd 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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 = ( <> + { diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx new file mode 100644 index 00000000000..af6078d8835 --- /dev/null +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -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 = { + 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 + case 'succeeded': + return + case 'skipped': + return + case 'failed': + return + case 'pending': + default: + return
+ } + }, [state]) + + const reason = result?.json?.reason || result?.error || null + + return ( +
  • +
    + {icon} +
    +
    +
    + + {formatStageName(descriptor.name)} + + + {state === 'running' ? STATE_LABEL[state] : null} + {state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null} + {state === 'failed' ? STATE_LABEL[state] : null} + +
    + {reason && state !== 'pending' && ( +

    {reason}

    + )} +
    +
  • + ) +} + +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 = {} + 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(EMPTY_STATE) + const [logOpen, setLogOpen] = useState(false) + const [copied, setCopied] = useState(false) + const logEndRef = useRef(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 ( +
    +
    +

    Hermes needs a one-time install

    +

    + 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. +

    + +
    +
    Install command
    +
    +              {ups.installCommand}
    +            
    +
    + + +
    +
    + +
    + + Will install to {ups.activeRoot} + + +
    +
    +
    + ) + } + + 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 ( +
    +
    + {/* Header -- always visible, never scrolls */} +
    +

    + {failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'} +

    +

    + {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.'} +

    +
    + + {/* Scrollable middle: progress, stages, error block, log */} +
    + {totalCount > 0 && ( +
    +
    + + {completedCount} of {totalCount} steps complete + {currentStage && ` -- now: ${formatStageName(currentStage)}`} + + {progressPct}% +
    +
    +
    +
    +
    + )} + + {totalCount === 0 && state.active && ( +
    + + Fetching installer manifest... +
    + )} + + {failed && state.error && ( +
    +
    + + Error +
    +

    {state.error}

    +
    + )} + + {stages.length > 0 && ( +
      + {stages.map(stage => ( + + ))} +
    + )} + +
    + + + {logOpen && ( +
    + {state.log.length === 0 ? ( +
    No output yet.
    + ) : ( + <> + {state.log.map((entry, i) => ( +
    + {entry.stage ? [{entry.stage}] : null} + {entry.line} +
    + ))} +
    + + )} +
    + )} +
    +
    + + {/* Footer -- always visible, never scrolls; only renders on failure */} + {failed && ( +
    +
    + + Full transcript saved to %LOCALAPPDATA%\hermes\logs\ + +
    + + +
    +
    +
    + )} +
    +
    + ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 0b7a02e5646..732a80776ac 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 + resetBootstrap: () => Promise<{ ok: boolean }> + onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void getVersion: () => Promise updates: { check: () => Promise @@ -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 + 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 } + | { 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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0f3f1e8f4cd..a8c4db3b75f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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=["*"], ) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c774e9a860c..d3117af1ced 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 ' 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 ' + # 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