From 2e157a2154e92586c5f9b0bd04e883167c2f2a01 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 30 May 2026 01:40:53 -0500 Subject: [PATCH] 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. --- apps/desktop/electron/bootstrap-runner.cjs | 167 +++++++++++++--- apps/desktop/electron/main.cjs | 34 +--- scripts/install.sh | 212 ++++++++++++++++++++- 3 files changed, 353 insertions(+), 60 deletions(-) diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 15e0ca7f7cd..9e427d147d5 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -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 } diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 3ac34873bdf..17a37e15d6f 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 7d1df04124e..764708baa9a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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:-}" + 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