mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
705eaa054a
Converges the Windows packaged desktop installer onto a single canonical
install topology: drop the Electron shell only (~80MB instead of ~500MB),
clone Hermes Agent at a build-time-pinned commit on first launch via
install.ps1's stage protocol, and treat the resulting git checkout at
%LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location
(same path the CLI installer uses). Future updates flow through the
existing applyUpdates() git-pull path.
Replaces the previous fat-installer architecture where the .exe bundled
a pre-staged hermes-agent source tree under resources/hermes-agent/ that
was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated
factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT
mismatch on path resolve, isGitCheckout guard regressions, pyproject
hash drift detection inside the sync loop).
Architecture overview
---------------------
Build time
apps/desktop/scripts/write-build-stamp.cjs writes
apps/desktop/build/install-stamp.json with {commit, branch, builtAt,
dirty}. Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to
`git rev-parse HEAD` locally.
apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset
of @homebridge/node-pty-prebuilt-multiarch from the workspace-root
node_modules into apps/desktop/build/native-deps/. Workspace dedup
hoists this dep to the root, out of reach of electron-builder's
`files:`-restricted collector; staging gives us a deterministic
path to extraResources.
electron-builder ships both into resources/install-stamp.json and
resources/native-deps/ respectively.
Boot resolver (electron/main.cjs)
Resolver order:
1. HERMES_DESKTOP_HERMES_ROOT override
2. SOURCE_REPO_ROOT (dev mode)
3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete
marker -- the post-install fast path
4. `hermes` on PATH (CLI-installed user adding the desktop)
5. pip-installed hermes_cli via system Python
6. bootstrap-needed sentinel -> hand off to runBootstrap
Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER /
syncTreeExcludingVenv machinery (-200 lines). The isGitCheckout
guard that bit us in the install.ps1 PR is gone.
First-launch bootstrap (electron/bootstrap-runner.cjs)
1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else
download from GitHub raw at INSTALL_STAMP.commit (cached at
HERMES_HOME\bootstrap-cache\install-<sha>.ps1).
2. Fetch the stage manifest via install.ps1 -Manifest -Commit X
-Branch Y.
3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json
-Commit X -Branch Y per stage.
4. On all stages green: write the .hermes-bootstrap-complete
marker with {schemaVersion, pinnedCommit, pinnedBranch,
completedAt, desktopVersion}.
Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log. Cancellation
via AbortSignal. Manifest cache so retries don't re-download.
Install overlay (src/components/desktop-install-overlay.tsx)
Mounted alongside the existing onboarding overlay; flexbox card
with header (static) + middle (scrollable) + footer (failure-only,
static). Subscribes to hermes:bootstrap:event IPC + resyncs from
hermes:bootstrap:get on mount/reload. Renders:
- 14-stage checklist with per-stage state icons
- Overall progress bar + current-stage spotlight
- Auto-expanded installer-output panel on failure
- "Copy output" button (full ring buffer + error to clipboard)
- "Reload and retry" wired through hermes:bootstrap:reset to
clear main.cjs's latched failure
Synthetic empty-manifest event from main.cjs flips the overlay to
'active' immediately so the slow install.ps1 download doesn't
leave the user staring at the generic Preparing splash.
Failure latching (main.cjs)
bootstrapFailure module-scope variable holds the rejection after
install.ps1 fails. startHermes() throws the latched error
immediately when set, bypassing the entire ensureRuntime +
runBootstrap chain. Without this, the renderer's ensureGatewayOpen
retries would re-run install.ps1 in a 5-10 min hot loop while the
user was still reading the failure overlay. Cleared via
hermes:bootstrap:reset on user-driven retry.
Unsupported-platform overlay (1F)
macOS / Linux packaged builds (no install.sh stage protocol yet)
emit an unsupported-platform event with a copy-pasteable install
command + docs URL. Dedicated overlay branch with "Copy command"
+ "I've run it -- retry" buttons.
install.ps1 additions (Phase 1F.3 + 1F.5)
-----------------------------------------
New -Commit and -Tag string params. Precedence Commit > Tag >
Branch. Honoured by all three code paths (update / fresh clone /
ZIP fallback), with archive URL selection that handles each
ref-type variant. Detached-HEAD checkouts intentionally -- they're
pins, not branches the user pulls into.
EAP=Continue wrap around the new pin-step git invocations. `git
fetch origin <commit>` writes the routine 'From <url>' info line to
stderr; under the script's global EAP=Stop that terminates the
script even though fetch+checkout succeed. Matches the established
pattern in Install-Uv, Test-Python, _Run-NpmInstall.
Backend fix (hermes_cli/web_server.py)
--------------------------------------
CORS allow_origin_regex now accepts Origin: 'null'. Packaged
Electron loads index.html via file://; Chromium sets the WebSocket
upgrade Origin header to the opaque origin 'null', which the old
regex rejected with HTTP 403 before gateway_ws() ever ran. This
failure mode was masked in the older FACTORY_HERMES_ROOT
architecture because the resolver often found an existing hermes
on PATH with different binding behavior.
Security maintained: localhost-only bind keeps cross-machine pages
out; per-process session token still gates every authenticated
/api/ endpoint regardless of Origin.
Desktop QoL
-----------
DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I).
Field-debugging trade-off: tiny attack surface increase versus
a much better support story when CSP / WS / theme issues surface.
NSIS prereq-check page deleted (-767 lines). The standard
Welcome -> License -> Directory -> InstallFiles -> Finish wizard
now installs without custom Python/Git/ripgrep detection -- those
prereqs are install.ps1's job at first launch.
Test infrastructure (Phase 1G)
------------------------------
apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform
bundle validator (was darwin-only and asserted on dead factory-
payload paths):
NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard)
POSITIVE: install-stamp.json carries a real commit + branch
POSITIVE: node-pty native deps shipped under resources/native-deps
POSITIVE: renderer dist/index.html reachable (asar or unpacked)
New nsis mode and npm run test:desktop:nsis script.
Validated end-to-end on clean Win10 VM
--------------------------------------
Confirmed: NSIS installer drops Electron shell, app launches,
install overlay shows progress, install.ps1 clones the pinned
commit, 14 stages run to completion, marker written, backend
spawns, WebSocket connects, onboarding overlay asks for API key,
main UI loads, integrated terminal works.
Failures handled: bootstrap stays failed (no hot-loop retry),
"Copy output" gives actionable transcript, "Reload and retry"
explicitly re-runs install.ps1.
What's deferred
---------------
- MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest
with runFullTrust, signed and submitted to Microsoft Store.
- install.sh stage protocol parity (Phase 2): once shipped, the
unsupported-platform overlay becomes drive-it-yourself and
macOS/Linux packaged installers gain feature parity with Windows.
467 lines
16 KiB
JavaScript
467 lines
16 KiB
JavaScript
'use strict'
|
|
|
|
/**
|
|
* bootstrap-runner.cjs
|
|
*
|
|
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
|
|
* scripts/install.ps1 stage-by-stage and streaming progress events back to
|
|
* the renderer.
|
|
*
|
|
* Wired from electron/main.cjs:
|
|
* const { runBootstrap } = require('./bootstrap-runner.cjs')
|
|
* const result = await runBootstrap({
|
|
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
|
|
* activeRoot, // ACTIVE_HERMES_ROOT
|
|
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
|
|
* hermesHome, // HERMES_HOME
|
|
* logRoot, // HERMES_HOME/logs
|
|
* emit: ev => {...} // event sink (sender.send or similar)
|
|
* })
|
|
*
|
|
* Emits events with shape:
|
|
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
|
|
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
|
|
* json?, durationMs?, error? }
|
|
* { type: 'log', stage?, line } // raw line from install.ps1
|
|
* { type: 'complete', marker: <written marker payload> }
|
|
* { type: 'failed', stage?, error } // bootstrap aborted
|
|
*
|
|
* Resolves with the same shape as the final 'complete' or 'failed' event so
|
|
* callers can await either way.
|
|
*
|
|
* NOT implemented yet (deferred to Phase 1E / 1F):
|
|
* - User-facing retry / cancel from the renderer (event channels exist;
|
|
* no UI consumes them yet)
|
|
* - macOS / Linux install.sh equivalent
|
|
*/
|
|
|
|
const fs = require('node:fs')
|
|
const fsp = require('node:fs/promises')
|
|
const path = require('node:path')
|
|
const https = require('node:https')
|
|
const { spawn } = require('node:child_process')
|
|
|
|
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
|
|
|
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
|
// runner (passed -NonInteractive to install.ps1, which the install script
|
|
// itself handles by emitting skipped=true frames). The renderer / 1E onboarding
|
|
// overlay takes over for those concerns (API keys, model, persona, gateway).
|
|
// We let install.ps1's own -NonInteractive logic drive this rather than
|
|
// filtering client-side -- single source of truth.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// install.ps1 source resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveLocalInstallScript(sourceRepoRoot) {
|
|
if (!sourceRepoRoot) return null
|
|
const candidate = path.join(sourceRepoRoot, 'scripts', 'install.ps1')
|
|
try {
|
|
fs.accessSync(candidate, fs.constants.R_OK)
|
|
return candidate
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function bootstrapCacheDir(hermesHome) {
|
|
return path.join(hermesHome, 'bootstrap-cache')
|
|
}
|
|
|
|
function cachedScriptPath(hermesHome, commit) {
|
|
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.ps1`)
|
|
}
|
|
|
|
function downloadInstallScript(commit, destPath) {
|
|
// Fetch from GitHub raw at the pinned commit. The raw URL with a SHA
|
|
// is immutable (unlike a branch ref), so we don't need integrity
|
|
// verification beyond "did the file we wrote pass a syntax probe."
|
|
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/install.ps1`
|
|
return new Promise((resolve, reject) => {
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
|
const tmpPath = destPath + '.tmp'
|
|
const out = fs.createWriteStream(tmpPath)
|
|
https
|
|
.get(url, res => {
|
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
// GitHub raw shouldn't redirect for a SHA URL, but follow once
|
|
// defensively.
|
|
out.close()
|
|
fs.unlinkSync(tmpPath)
|
|
https
|
|
.get(res.headers.location, res2 => {
|
|
if (res2.statusCode !== 200) {
|
|
reject(new Error(`Failed to download install.ps1: HTTP ${res2.statusCode} from redirect ${res.headers.location}`))
|
|
return
|
|
}
|
|
const out2 = fs.createWriteStream(tmpPath)
|
|
res2.pipe(out2)
|
|
out2.on('finish', () => {
|
|
out2.close()
|
|
fs.renameSync(tmpPath, destPath)
|
|
resolve(destPath)
|
|
})
|
|
out2.on('error', reject)
|
|
})
|
|
.on('error', reject)
|
|
return
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
out.close()
|
|
try {
|
|
fs.unlinkSync(tmpPath)
|
|
} catch {}
|
|
reject(new Error(`Failed to download install.ps1: HTTP ${res.statusCode} from ${url}`))
|
|
return
|
|
}
|
|
res.pipe(out)
|
|
out.on('finish', () => {
|
|
out.close()
|
|
fs.renameSync(tmpPath, destPath)
|
|
resolve(destPath)
|
|
})
|
|
out.on('error', err => {
|
|
try {
|
|
fs.unlinkSync(tmpPath)
|
|
} catch {}
|
|
reject(err)
|
|
})
|
|
})
|
|
.on('error', err => {
|
|
try {
|
|
fs.unlinkSync(tmpPath)
|
|
} catch {}
|
|
reject(err)
|
|
})
|
|
})
|
|
}
|
|
|
|
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
|
// 1. Dev shortcut: prefer a local checkout's install.ps1 so we can iterate
|
|
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
|
// of APP_ROOT/../..).
|
|
const localScript = resolveLocalInstallScript(sourceRepoRoot)
|
|
if (localScript) {
|
|
emit({ type: 'log', line: `[bootstrap] using local install.ps1 at ${localScript}` })
|
|
return { path: localScript, source: 'local' }
|
|
}
|
|
|
|
// 2. Packaged path: download from GitHub at the pinned commit (1B's stamp).
|
|
if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) {
|
|
throw new Error(
|
|
'Cannot resolve install.ps1: no SOURCE_REPO_ROOT and no install stamp. ' +
|
|
'This packaged build was produced without a valid build-time stamp.'
|
|
)
|
|
}
|
|
|
|
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
|
try {
|
|
await fsp.access(cached, fs.constants.R_OK)
|
|
emit({ type: 'log', line: `[bootstrap] using cached install.ps1 for ${installStamp.commit.slice(0, 12)}` })
|
|
return { path: cached, source: 'cache', commit: installStamp.commit }
|
|
} catch {
|
|
// not cached; download
|
|
}
|
|
|
|
emit({ type: 'log', line: `[bootstrap] fetching install.ps1 for ${installStamp.commit.slice(0, 12)} from GitHub` })
|
|
await downloadInstallScript(installStamp.commit, cached)
|
|
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
|
return { path: cached, source: 'download', commit: installStamp.commit }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// powershell wrapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const ps = process.platform === 'win32' ? 'powershell.exe' : 'pwsh'
|
|
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
|
|
|
const child = spawn(ps, fullArgs, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
// Pass HERMES_HOME through so install.ps1 respects the caller's
|
|
// choice rather than re-computing the default.
|
|
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
|
}
|
|
})
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let killed = false
|
|
|
|
const onAbort = () => {
|
|
killed = true
|
|
try {
|
|
child.kill('SIGTERM')
|
|
} catch {}
|
|
}
|
|
if (abortSignal) {
|
|
if (abortSignal.aborted) {
|
|
onAbort()
|
|
} else {
|
|
abortSignal.addEventListener('abort', onAbort, { once: true })
|
|
}
|
|
}
|
|
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
|
|
// Stream stdout line-by-line so the renderer sees progress in real time.
|
|
let stdoutBuf = ''
|
|
child.stdout.on('data', chunk => {
|
|
stdout += chunk
|
|
stdoutBuf += chunk
|
|
let nl
|
|
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
|
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
|
stdoutBuf = stdoutBuf.slice(nl + 1)
|
|
if (line) emit && emit({ type: 'log', stage: stageName, line })
|
|
}
|
|
})
|
|
|
|
let stderrBuf = ''
|
|
child.stderr.on('data', chunk => {
|
|
stderr += chunk
|
|
stderrBuf += chunk
|
|
let nl
|
|
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
|
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
|
stderrBuf = stderrBuf.slice(nl + 1)
|
|
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
|
|
}
|
|
})
|
|
|
|
child.on('error', err => {
|
|
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
|
reject(err)
|
|
})
|
|
|
|
child.on('close', (code, signal) => {
|
|
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
|
// Flush any trailing bytes
|
|
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
|
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
|
resolve({ stdout, stderr, code, signal, killed })
|
|
})
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manifest + stage dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
|
|
// so the repository stage clones the exact SHA the .exe was tested with
|
|
// instead of falling back to install.ps1's default ($Branch = "main").
|
|
function buildPinArgs(installStamp) {
|
|
const args = []
|
|
if (installStamp && installStamp.commit) {
|
|
args.push('-Commit', installStamp.commit)
|
|
}
|
|
if (installStamp && installStamp.branch) {
|
|
args.push('-Branch', installStamp.branch)
|
|
}
|
|
return args
|
|
}
|
|
|
|
async function fetchManifest({ scriptPath, emit, hermesHome, installStamp }) {
|
|
const pinArgs = buildPinArgs(installStamp)
|
|
const result = await spawnPowerShell(scriptPath, ['-Manifest', ...pinArgs], {
|
|
emit,
|
|
stageName: '__manifest__',
|
|
hermesHome
|
|
})
|
|
if (result.code !== 0) {
|
|
throw new Error(`install.ps1 -Manifest failed: exit ${result.code}\n${result.stderr || result.stdout}`)
|
|
}
|
|
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
|
// banner / info lines first depending on Console.OutputEncoding effects).
|
|
// Find the last line that parses as JSON with a `stages` field.
|
|
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const parsed = JSON.parse(lines[i])
|
|
if (parsed && Array.isArray(parsed.stages)) {
|
|
return parsed
|
|
}
|
|
} catch {}
|
|
}
|
|
throw new Error(`install.ps1 -Manifest produced no parseable JSON payload\n${result.stdout}`)
|
|
}
|
|
|
|
// Parse the JSON result frame from a stage run. The protocol guarantees
|
|
// exactly one JSON line per stage in -Json or -Stage mode (post #27224 fix
|
|
// for the double-emit bug we addressed in the install.ps1 PR).
|
|
function parseStageResult(stdout) {
|
|
const lines = stdout.split(/\r?\n/).filter(Boolean)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const parsed = JSON.parse(lines[i])
|
|
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
|
|
return parsed
|
|
}
|
|
} catch {}
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, installStamp }) {
|
|
const startedAt = Date.now()
|
|
emit({ type: 'stage', name: stage.name, state: 'running' })
|
|
|
|
const pinArgs = buildPinArgs(installStamp)
|
|
const result = await spawnPowerShell(
|
|
scriptPath,
|
|
['-Stage', stage.name, '-NonInteractive', '-Json', ...pinArgs],
|
|
{ emit, stageName: stage.name, abortSignal, hermesHome }
|
|
)
|
|
|
|
const durationMs = Date.now() - startedAt
|
|
|
|
if (result.killed) {
|
|
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
|
|
emit(ev)
|
|
return ev
|
|
}
|
|
|
|
const json = parseStageResult(result.stdout)
|
|
|
|
if (!json) {
|
|
const ev = {
|
|
type: 'stage',
|
|
name: stage.name,
|
|
state: 'failed',
|
|
durationMs,
|
|
error: `install.ps1 -Stage ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
|
json: null
|
|
}
|
|
emit(ev)
|
|
return ev
|
|
}
|
|
|
|
if (json.ok && json.skipped) {
|
|
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
|
|
emit(ev)
|
|
return ev
|
|
}
|
|
if (json.ok) {
|
|
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
|
|
emit(ev)
|
|
return ev
|
|
}
|
|
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
|
|
emit(ev)
|
|
return ev
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-run log file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function openRunLog(logRoot) {
|
|
fs.mkdirSync(logRoot, { recursive: true })
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
|
|
const stream = fs.createWriteStream(logPath, { flags: 'a' })
|
|
return { path: logPath, stream }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entrypoint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runBootstrap(opts) {
|
|
const {
|
|
installStamp,
|
|
activeRoot,
|
|
sourceRepoRoot,
|
|
hermesHome,
|
|
logRoot,
|
|
onEvent,
|
|
abortSignal,
|
|
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
|
} = opts
|
|
|
|
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
|
|
|
|
// Tee every event to the runLog AND the caller's onEvent. This gives us a
|
|
// forensic trail per bootstrap run AND lets the renderer subscribe live.
|
|
const emit = ev => {
|
|
try {
|
|
runLog.stream.write(JSON.stringify(ev) + '\n')
|
|
} catch {}
|
|
try {
|
|
if (typeof onEvent === 'function') onEvent(ev)
|
|
} catch (err) {
|
|
// Don't let a subscriber bug crash the bootstrap
|
|
runLog.stream.write(`emit error: ${err && err.message}\n`)
|
|
}
|
|
}
|
|
|
|
emit({
|
|
type: 'log',
|
|
line:
|
|
`[bootstrap] starting at ${new Date().toISOString()}; ` +
|
|
`activeRoot=${activeRoot}; ` +
|
|
`stamp=${installStamp ? installStamp.commit.slice(0, 12) : '<none>'}; ` +
|
|
`runLog=${runLog.path}`
|
|
})
|
|
|
|
try {
|
|
// 1. Resolve install.ps1
|
|
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
|
|
|
// 2. Fetch manifest
|
|
const manifest = await fetchManifest({ scriptPath: scriptInfo.path, emit, hermesHome, installStamp })
|
|
emit({
|
|
type: 'manifest',
|
|
stages: manifest.stages,
|
|
protocolVersion: manifest.protocol_version || manifest.protocolVersion || null
|
|
})
|
|
|
|
// 3. Iterate stages in order. Stages flagged needs_user_input are still
|
|
// invoked -- install.ps1's own -NonInteractive handler in those stages
|
|
// emits skipped=true. We trust the protocol rather than filtering
|
|
// client-side.
|
|
for (const stage of manifest.stages) {
|
|
if (abortSignal && abortSignal.aborted) {
|
|
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
|
|
return { ok: false, cancelled: true }
|
|
}
|
|
const ev = await runStage({ scriptPath: scriptInfo.path, stage, emit, hermesHome, abortSignal, installStamp })
|
|
if (ev.state === 'failed') {
|
|
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
|
|
return { ok: false, failedStage: stage.name, error: ev.error }
|
|
}
|
|
}
|
|
|
|
// 4. Write the bootstrap-complete marker.
|
|
const markerPayload = {
|
|
pinnedCommit: installStamp ? installStamp.commit : null,
|
|
pinnedBranch: installStamp ? installStamp.branch : null
|
|
}
|
|
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
|
|
emit({ type: 'complete', marker })
|
|
return { ok: true, marker }
|
|
} catch (err) {
|
|
emit({ type: 'failed', error: err.message || String(err) })
|
|
return { ok: false, error: err.message || String(err) }
|
|
} finally {
|
|
try {
|
|
runLog.stream.end()
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
runBootstrap,
|
|
// Exposed for testability
|
|
parseStageResult,
|
|
resolveLocalInstallScript,
|
|
cachedScriptPath
|
|
}
|