mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(desktop): automate first-launch bootstrap on macOS/Linux
Previously a packaged macOS/Linux app with no Hermes install hit a
dead-end ("first-launch install is not yet automated -- run install.sh
manually") because install.sh lacked the staged protocol install.ps1
exposes. Now both platforms bootstrap on first launch with the same
structured, per-step progress UI as Windows.
- install.sh: add --manifest / --stage / --json / --non-interactive plus
a stage dispatcher (prerequisites, repository, venv, python-deps,
node-deps, path, config, setup, gateway, complete). User-input stages
(setup, gateway) are skipped under --non-interactive; the in-app
onboarding overlay owns API keys/model, matching the Windows flow.
Each stage runs inside the install dir (its own process) and a new
--commit flag pins the checkout to the build-stamp SHA.
- bootstrap-runner.cjs: drive the staged manifest/stage/JSON protocol for
both install.ps1 (PowerShell) and install.sh (bash), selected by
installer kind; removed the single-blob POSIX shim.
- main.cjs: drop the macOS/Linux unsupported-platform dead-end so the
bootstrap-needed path runs the installer on every platform.
This commit is contained in:
parent
6e3f50a3a8
commit
2e157a2154
3 changed files with 353 additions and 60 deletions
|
|
@ -32,7 +32,6 @@
|
|||
* 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')
|
||||
|
|
@ -54,9 +53,17 @@ const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
|||
// install.ps1 source resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function installScriptName() {
|
||||
return process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
}
|
||||
|
||||
function installScriptKind() {
|
||||
return process.platform === 'win32' ? 'powershell' : 'posix'
|
||||
}
|
||||
|
||||
function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) return null
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', 'install.ps1')
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
return candidate
|
||||
|
|
@ -70,14 +77,15 @@ function bootstrapCacheDir(hermesHome) {
|
|||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.ps1`)
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
}
|
||||
|
||||
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`
|
||||
const scriptName = installScriptName()
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const tmpPath = destPath + '.tmp'
|
||||
|
|
@ -92,7 +100,9 @@ function downloadInstallScript(commit, destPath) {
|
|||
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}`))
|
||||
reject(
|
||||
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
const out2 = fs.createWriteStream(tmpPath)
|
||||
|
|
@ -112,7 +122,7 @@ function downloadInstallScript(commit, destPath) {
|
|||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res.statusCode} from ${url}`))
|
||||
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
|
||||
return
|
||||
}
|
||||
res.pipe(out)
|
||||
|
|
@ -138,19 +148,19 @@ function downloadInstallScript(commit, destPath) {
|
|||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
||||
// 1. Dev shortcut: prefer a local checkout's install.ps1 so we can iterate
|
||||
// 1. Dev shortcut: prefer a local checkout's installer 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' }
|
||||
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
|
||||
return { path: localScript, source: 'local', kind: installScriptKind() }
|
||||
}
|
||||
|
||||
// 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. ' +
|
||||
`Cannot resolve ${installScriptName()}: no SOURCE_REPO_ROOT and no install stamp. ` +
|
||||
'This packaged build was produced without a valid build-time stamp.'
|
||||
)
|
||||
}
|
||||
|
|
@ -158,16 +168,16 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
|
|||
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 }
|
||||
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// not cached; download
|
||||
}
|
||||
|
||||
emit({ type: 'log', line: `[bootstrap] fetching install.ps1 for ${installStamp.commit.slice(0, 12)} from GitHub` })
|
||||
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} 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 }
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -250,6 +260,75 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||
})
|
||||
}
|
||||
|
||||
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('bash', [scriptPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
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')
|
||||
|
||||
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)
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -268,15 +347,29 @@ function buildPinArgs(installStamp) {
|
|||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, emit, hermesHome, installStamp }) {
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(scriptPath, ['-Manifest', ...pinArgs], {
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
|
||||
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('--branch', installStamp.branch)
|
||||
}
|
||||
if (installStamp && installStamp.commit) {
|
||||
args.push('--commit', installStamp.commit)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
const args = isPosix
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp)]
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
stageName: '__manifest__',
|
||||
hermesHome
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`install.ps1 -Manifest failed: exit ${result.code}\n${result.stderr || result.stdout}`)
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : '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).
|
||||
|
|
@ -290,7 +383,7 @@ async function fetchManifest({ scriptPath, emit, hermesHome, installStamp }) {
|
|||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`install.ps1 -Manifest produced no parseable JSON payload\n${result.stdout}`)
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
|
||||
}
|
||||
|
||||
// Parse the JSON result frame from a stage run. The protocol guarantees
|
||||
|
|
@ -309,14 +402,17 @@ function parseStageResult(stdout) {
|
|||
return null
|
||||
}
|
||||
|
||||
async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, installStamp }) {
|
||||
async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) {
|
||||
const startedAt = Date.now()
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(
|
||||
const isPosix = installerKind === 'posix'
|
||||
const args = isPosix
|
||||
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(
|
||||
scriptPath,
|
||||
['-Stage', stage.name, '-NonInteractive', '-Json', ...pinArgs],
|
||||
args,
|
||||
{ emit, stageName: stage.name, abortSignal, hermesHome }
|
||||
)
|
||||
|
||||
|
|
@ -336,7 +432,7 @@ async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, inst
|
|||
name: stage.name,
|
||||
state: 'failed',
|
||||
durationMs,
|
||||
error: `install.ps1 -Stage ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
json: null
|
||||
}
|
||||
emit(ev)
|
||||
|
|
@ -412,11 +508,19 @@ async function runBootstrap(opts) {
|
|||
})
|
||||
|
||||
try {
|
||||
// 1. Resolve install.ps1
|
||||
// 1. Resolve the platform installer.
|
||||
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
||||
const installerKind = scriptInfo.kind || 'powershell'
|
||||
|
||||
// 2. Fetch manifest
|
||||
const manifest = await fetchManifest({ scriptPath: scriptInfo.path, emit, hermesHome, installStamp })
|
||||
const manifest = await fetchManifest({
|
||||
scriptPath: scriptInfo.path,
|
||||
installerKind,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp
|
||||
})
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
|
|
@ -432,7 +536,16 @@ async function runBootstrap(opts) {
|
|||
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 })
|
||||
const ev = await runStage({
|
||||
scriptPath: scriptInfo.path,
|
||||
installerKind,
|
||||
stage,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -1447,43 +1447,15 @@ async function ensureRuntime(backend) {
|
|||
}
|
||||
|
||||
// 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.
|
||||
// find anything to spawn. Hand off to the bootstrap runner which drives the
|
||||
// platform installer, 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
|
||||
|
|
|
|||
|
|
@ -71,8 +71,13 @@ USE_VENV=true
|
|||
RUN_SETUP=true
|
||||
SKIP_BROWSER=false
|
||||
BRANCH="main"
|
||||
INSTALL_COMMIT=""
|
||||
ENSURE_DEPS=""
|
||||
POSTINSTALL_MODE=false
|
||||
MANIFEST_MODE=false
|
||||
STAGE_NAME=""
|
||||
JSON_OUTPUT=false
|
||||
NON_INTERACTIVE=false
|
||||
|
||||
# Detect non-interactive mode (e.g. curl | bash)
|
||||
# When stdin is not a terminal, read -p will fail with EOF,
|
||||
|
|
@ -102,6 +107,26 @@ while [[ $# -gt 0 ]]; do
|
|||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--commit)
|
||||
INSTALL_COMMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--manifest|-Manifest)
|
||||
MANIFEST_MODE=true
|
||||
shift
|
||||
;;
|
||||
--stage|-Stage)
|
||||
STAGE_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--json|-Json)
|
||||
JSON_OUTPUT=true
|
||||
shift
|
||||
;;
|
||||
--non-interactive|-NonInteractive)
|
||||
NON_INTERACTIVE=true
|
||||
shift
|
||||
;;
|
||||
--dir)
|
||||
INSTALL_DIR="$2"
|
||||
INSTALL_DIR_EXPLICIT=true
|
||||
|
|
@ -129,6 +154,11 @@ while [[ $# -gt 0 ]]; do
|
|||
echo " --skip-setup Skip interactive setup wizard"
|
||||
echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)"
|
||||
echo " --branch NAME Git branch to install (default: main)"
|
||||
echo " --commit SHA Pin checkout to a specific commit after clone/update"
|
||||
echo " --manifest Print desktop bootstrap stage manifest as JSON"
|
||||
echo " --stage NAME Run one desktop bootstrap stage"
|
||||
echo " --json Print a JSON result frame for --stage"
|
||||
echo " --non-interactive Skip stages that require user input"
|
||||
echo " --dir PATH Installation directory"
|
||||
echo " default (non-root): ~/.hermes/hermes-agent"
|
||||
echo " default (root, Linux): /usr/local/lib/hermes-agent"
|
||||
|
|
@ -189,6 +219,41 @@ log_error() {
|
|||
echo -e "${RED}✗${NC} $1"
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
# Enough for short installer status strings; avoids requiring jq during
|
||||
# pre-install bootstrap.
|
||||
printf '%s' "$1" | tr '\n' ' ' | sed \
|
||||
-e 's/\\/\\\\/g' \
|
||||
-e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
emit_manifest() {
|
||||
cat <<'JSON'
|
||||
{"protocol_version":1,"stages":[{"name":"prerequisites","title":"System prerequisites","category":"runtime","needs_user_input":false},{"name":"repository","title":"Download Hermes Agent","category":"runtime","needs_user_input":false},{"name":"venv","title":"Create Python virtual environment","category":"runtime","needs_user_input":false},{"name":"python-deps","title":"Install Python dependencies","category":"runtime","needs_user_input":false},{"name":"node-deps","title":"Install browser-tool dependencies","category":"runtime","needs_user_input":false},{"name":"path","title":"Install hermes command","category":"runtime","needs_user_input":false},{"name":"config","title":"Prepare config and skills","category":"configuration","needs_user_input":false},{"name":"setup","title":"Configure API keys and settings","category":"configuration","needs_user_input":true},{"name":"gateway","title":"Configure gateway service","category":"configuration","needs_user_input":true},{"name":"complete","title":"Finish install","category":"runtime","needs_user_input":false}]}
|
||||
JSON
|
||||
}
|
||||
|
||||
stage_needs_user_input() {
|
||||
case "$1" in
|
||||
setup|gateway) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
emit_stage_json() {
|
||||
local stage="$1"
|
||||
local ok="$2"
|
||||
local skipped="${3:-false}"
|
||||
local reason="${4:-}"
|
||||
local escaped_reason
|
||||
escaped_reason="$(json_escape "$reason")"
|
||||
if [ -n "$escaped_reason" ]; then
|
||||
printf '{"ok":%s,"stage":"%s","skipped":%s,"reason":"%s"}\n' "$ok" "$stage" "$skipped" "$escaped_reason"
|
||||
else
|
||||
printf '{"ok":%s,"stage":"%s","skipped":%s}\n' "$ok" "$stage" "$skipped"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_yes_no() {
|
||||
local question="$1"
|
||||
local default="${2:-yes}"
|
||||
|
|
@ -201,7 +266,9 @@ prompt_yes_no() {
|
|||
*) prompt_suffix="[y/N]" ;;
|
||||
esac
|
||||
|
||||
if [ "$IS_INTERACTIVE" = true ]; then
|
||||
if [ "$NON_INTERACTIVE" = true ]; then
|
||||
answer=""
|
||||
elif [ "$IS_INTERACTIVE" = true ]; then
|
||||
read -r -p "$question $prompt_suffix " answer || answer=""
|
||||
elif [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
printf "%s %s " "$question" "$prompt_suffix" > /dev/tty
|
||||
|
|
@ -986,6 +1053,14 @@ clone_repo() {
|
|||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
if [ -n "$INSTALL_COMMIT" ]; then
|
||||
log_info "Pinning checkout to commit $INSTALL_COMMIT..."
|
||||
if ! git cat-file -e "$INSTALL_COMMIT^{commit}" 2>/dev/null; then
|
||||
git fetch origin "$INSTALL_COMMIT" || true
|
||||
fi
|
||||
git checkout --detach "$INSTALL_COMMIT"
|
||||
fi
|
||||
|
||||
log_success "Repository ready"
|
||||
}
|
||||
|
||||
|
|
@ -2040,6 +2115,135 @@ postinstall_mode() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Each --stage runs in its own process, so (unlike the monolithic main() where
|
||||
# clone_repo cd's once and later steps inherit it) a stage that operates on the
|
||||
# checkout must cd into it explicitly. Without this, install_deps/setup_path run
|
||||
# from the desktop app's cwd and resolve `.` / the venv against the wrong tree.
|
||||
require_install_dir() {
|
||||
if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then
|
||||
log_error "Install directory not found: ${INSTALL_DIR:-<unset>}"
|
||||
log_info "The 'repository' stage must run before this one."
|
||||
return 1
|
||||
fi
|
||||
cd "$INSTALL_DIR"
|
||||
}
|
||||
|
||||
# Desktop bootstrap stage protocol. Mirrors the Windows install.ps1 surface
|
||||
# closely enough for the Electron bootstrap runner to show structured progress.
|
||||
run_stage_body() {
|
||||
local stage="$1"
|
||||
|
||||
case "$stage" in
|
||||
prerequisites)
|
||||
print_banner
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
install_uv
|
||||
check_python
|
||||
check_git
|
||||
check_node
|
||||
check_network_prerequisites
|
||||
install_system_packages
|
||||
;;
|
||||
repository)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
check_git
|
||||
clone_repo
|
||||
;;
|
||||
venv)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
install_uv
|
||||
check_python
|
||||
setup_venv
|
||||
;;
|
||||
python-deps)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
install_uv
|
||||
check_python
|
||||
install_deps
|
||||
;;
|
||||
node-deps)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
check_node
|
||||
install_node_deps
|
||||
;;
|
||||
path)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
setup_path
|
||||
;;
|
||||
config)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
copy_config_templates
|
||||
;;
|
||||
setup)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
run_setup_wizard
|
||||
;;
|
||||
gateway)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
maybe_start_gateway
|
||||
;;
|
||||
complete)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
print_success
|
||||
echo "git" > "$HERMES_HOME/.install_method"
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown stage: $stage"
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_stage_protocol() {
|
||||
local stage="$1"
|
||||
if [ -z "$stage" ]; then
|
||||
log_error "--stage requires a stage name"
|
||||
if [ "$JSON_OUTPUT" = true ]; then
|
||||
emit_stage_json "" false false "missing stage name"
|
||||
fi
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [ "$NON_INTERACTIVE" = true ] && stage_needs_user_input "$stage"; then
|
||||
log_info "Skipping $stage (non-interactive bootstrap)"
|
||||
if [ "$JSON_OUTPUT" = true ]; then
|
||||
emit_stage_json "$stage" true true
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
set +e
|
||||
run_stage_body "$stage"
|
||||
local code=$?
|
||||
set -e
|
||||
|
||||
if [ "$JSON_OUTPUT" = true ]; then
|
||||
if [ "$code" -eq 0 ]; then
|
||||
emit_stage_json "$stage" true false
|
||||
else
|
||||
emit_stage_json "$stage" false false "exit code $code"
|
||||
fi
|
||||
fi
|
||||
return "$code"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
|
@ -2070,7 +2274,11 @@ main() {
|
|||
echo "git" > "$HERMES_HOME/.install_method"
|
||||
}
|
||||
|
||||
if [ -n "$ENSURE_DEPS" ]; then
|
||||
if [ "$MANIFEST_MODE" = true ]; then
|
||||
emit_manifest
|
||||
elif [ -n "$STAGE_NAME" ]; then
|
||||
run_stage_protocol "$STAGE_NAME"
|
||||
elif [ -n "$ENSURE_DEPS" ]; then
|
||||
ensure_mode
|
||||
elif [ "$POSTINSTALL_MODE" = true ]; then
|
||||
postinstall_mode
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue