PM's musl Node is the unofficial-builds musl archive, which links the system libstdc++. On a stock Alpine (bash, git, curl) node and npm then fail staged verification with raw relocation errors after the clone and downloads. Check in the prerequisites stage and name the package.
865 lines
39 KiB
Bash
Executable File
865 lines
39 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Hermes Agent bootstrap: clone, acquire uv/Python, then hand the checkout to
|
|
# the same completion an update runs -- command publication, product builds and
|
|
# post-build maintenance -- so a fresh install and a finished update land in one
|
|
# state. Heavy dependencies (tool binaries, browsers, node) are pm's job:
|
|
# `hermes pm install`.
|
|
#
|
|
# Stage protocol kept for Hermes-Setup:
|
|
# --manifest print the stage list as JSON
|
|
# --stage NAME [--json] run one stage
|
|
# --non-interactive skip stages that need input
|
|
# --include-desktop build the desktop app too (products stage)
|
|
# --verbose stream every child command's output (the default
|
|
# off a terminal and in CI)
|
|
set -u
|
|
|
|
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
|
# wrong user's home directory when running under sudo -u <user>. See #21269.
|
|
# pm's own venv sync re-isolates (pm/environment.py), so this bootstrap
|
|
# hygiene can't break the locked sync the way it used to before pm owned it.
|
|
export UV_NO_CONFIG=1
|
|
|
|
REPO_URL="${HERMES_REPO_URL:-https://github.com/NousResearch/hermes-agent.git}"
|
|
BRANCH="main"
|
|
INSTALL_COMMIT=""
|
|
INSTALL_DIR="${HERMES_INSTALL_DIR:-}"
|
|
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
|
STAGE=""
|
|
WANT_MANIFEST=false
|
|
JSON=false
|
|
NON_INTERACTIVE=false
|
|
INCLUDE_DESKTOP=false
|
|
VERBOSE=false
|
|
SKIP_BROWSER=false
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--branch|-Branch|--commit|-Commit|--dir|--hermes-home|-HermesHome|--stage|-Stage)
|
|
option="$1"
|
|
if [ $# -lt 2 ] || [ -z "$2" ] || [[ "$2" == -* ]]; then
|
|
printf '%s needs a value\n' "$option" >&2
|
|
exit 2
|
|
fi
|
|
case "$option" in
|
|
--branch|-Branch) BRANCH="$2" ;;
|
|
--commit|-Commit) INSTALL_COMMIT="$2" ;;
|
|
--dir) INSTALL_DIR="$2" ;;
|
|
--hermes-home|-HermesHome) HERMES_HOME="$2" ;;
|
|
--stage|-Stage) STAGE="$2" ;;
|
|
esac
|
|
shift 2 ;;
|
|
--manifest|-Manifest) WANT_MANIFEST=true; shift ;;
|
|
--json|-Json) JSON=true; shift ;;
|
|
--non-interactive|-NonInteractive) NON_INTERACTIVE=true; shift ;;
|
|
--skip-setup) NON_INTERACTIVE=true; shift ;;
|
|
--skip-browser|--no-playwright|-SkipBrowser) SKIP_BROWSER=true; shift ;;
|
|
--include-desktop|-IncludeDesktop) INCLUDE_DESKTOP=true; shift ;;
|
|
--verbose|-Verbose) VERBOSE=true; shift ;;
|
|
-h|--help)
|
|
echo "Usage: install.sh [--branch NAME] [--commit SHA] [--dir PATH]"
|
|
echo " [--hermes-home PATH]"
|
|
echo " [--manifest] [--stage NAME] [--json]"
|
|
echo " [--non-interactive] [--include-desktop] [--verbose]"
|
|
echo " [--skip-browser]"
|
|
echo
|
|
echo " --skip-browser Do not install the browser tools (agent-browser + Chromium)."
|
|
echo " Alias: --no-playwright. Remembered by later installs and"
|
|
echo " 'hermes update'; undo with 'hermes pm install agent-browser'."
|
|
exit 0 ;;
|
|
*) echo "unknown option: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
INSTALL_DIR="${INSTALL_DIR:-$HERMES_HOME/hermes-agent}"
|
|
export HERMES_HOME
|
|
|
|
INSTALL_LOG="$HERMES_HOME/logs/install.log"
|
|
|
|
# Same glyphs as the pre-pm installer. Colour only on a terminal, so CI
|
|
# transcripts and the Hermes-Setup driver read plain text.
|
|
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
|
C_RED=$'\033[0;31m' C_GREEN=$'\033[0;32m' C_YELLOW=$'\033[0;33m'
|
|
C_CYAN=$'\033[0;36m' C_MAGENTA=$'\033[0;35m' C_BOLD=$'\033[1m'
|
|
C_DIM=$'\033[2m' C_NC=$'\033[0m'
|
|
else
|
|
C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_MAGENTA="" C_BOLD="" C_DIM="" C_NC=""
|
|
fi
|
|
|
|
log() { printf '%s→%s %s\n' "$C_CYAN" "$C_NC" "$1"; }
|
|
log_success() { printf '%s✓%s %s\n' "$C_GREEN" "$C_NC" "$1"; }
|
|
log_warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_NC" "$1"; }
|
|
log_error() { printf '%s✗%s %s\n' "$C_RED" "$C_NC" "$1" >&2; }
|
|
fail() { STAGE_REASON="$1"; log_error "$1"; exit 1; }
|
|
|
|
print_banner() {
|
|
printf '\n%s%s' "$C_MAGENTA" "$C_BOLD"
|
|
printf '%s\n' "┌─────────────────────────────────────────────────────────┐"
|
|
printf '%s\n' "│ ☤ Hermes Agent Installer │"
|
|
printf '%s\n' "├─────────────────────────────────────────────────────────┤"
|
|
printf '%s\n' "│ An open source AI agent by Nous Research. │"
|
|
printf '%s\n' "└─────────────────────────────────────────────────────────┘"
|
|
printf '%s\n' "$C_NC"
|
|
}
|
|
|
|
# Interactive runs collapse child-process output (git, uv, pm, the builds)
|
|
# into one status line. CI, --verbose and a non-terminal stdout -- the
|
|
# Hermes-Setup --json driver, E2E transcripts -- keep the full stream those
|
|
# readers parse.
|
|
quiet_output() {
|
|
[ "$VERBOSE" = true ] && return 1
|
|
if [ -n "${CI:-}" ] || [ -n "${GITHUB_ACTIONS:-}" ] || [ -n "${HERMES_INSTALL_VERBOSE:-}" ]; then
|
|
return 1
|
|
fi
|
|
[ -t 1 ]
|
|
}
|
|
|
|
status_line() {
|
|
local text=" $1" width=$(( $2 - 1 ))
|
|
[ "${#text}" -le "$width" ] || text="${text:0:width}"
|
|
printf '\r\033[K%s%s%s' "$C_DIM" "$text" "$C_NC"
|
|
}
|
|
|
|
# run_logged [--may-fail] LABEL CMD...: run CMD. Quiet mode shows LABEL with
|
|
# CMD's latest output line rewritten in place, appends everything to
|
|
# $INSTALL_LOG and, on failure, prints the tail and the log path; --may-fail
|
|
# is for probes whose failure the caller handles (no report). Otherwise LABEL
|
|
# is logged and the output streams untouched. Returns CMD's exit status.
|
|
run_logged() {
|
|
local may_fail=false
|
|
if [ "$1" = --may-fail ]; then may_fail=true; shift; fi
|
|
local label="$1"; shift
|
|
if ! quiet_output || ! { mkdir -p "${INSTALL_LOG%/*}" && : >> "$INSTALL_LOG"; } 2>/dev/null; then
|
|
log "$label"
|
|
"$@"
|
|
return
|
|
fi
|
|
local start cols rc line shown
|
|
start=$(( $(wc -l < "$INSTALL_LOG") + 1 ))
|
|
cols="$(tput cols 2>/dev/null)" || cols=80
|
|
[ "${cols:-0}" -gt 20 ] 2>/dev/null || cols=80
|
|
printf '==> %s (%s)\n' "$label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$INSTALL_LOG"
|
|
status_line "$label" "$cols"
|
|
# stdin closed: under `curl | bash` it is the script itself, and nothing
|
|
# run here may prompt behind a status line.
|
|
"$@" </dev/null 2>&1 | {
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
line="${line%$'\r'}"
|
|
printf '%s\n' "$line" >&3
|
|
# git and uv redraw progress with bare CRs; show the newest.
|
|
shown="${line##*$'\r'}"
|
|
[ -z "$shown" ] || status_line "$label: $shown" "$cols"
|
|
done
|
|
} 3>>"$INSTALL_LOG"
|
|
rc=${PIPESTATUS[0]}
|
|
printf '\r\033[K'
|
|
if [ "$rc" -ne 0 ] && [ "$may_fail" = false ]; then
|
|
log_error "$label failed (exit $rc). Last output:"
|
|
tail -n +"$(( start + 1 ))" "$INSTALL_LOG" | tail -n 20 | sed 's/^/ /' >&2
|
|
printf ' full log: %s\n' "$INSTALL_LOG" >&2
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
# --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) ---
|
|
# Derived from pm/lock.json. DO NOT EDIT BY HAND:
|
|
# run scripts/gen-bootstrap-pins.py after a pin bump.
|
|
UV_PIN_VERSION="0.12.3"
|
|
|
|
# Sets UV_PIN_URL + UV_PIN_SHA256 for a <os>-<arch> target key.
|
|
uv_bootstrap_pin() {
|
|
case "$1" in
|
|
linux-x64)
|
|
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-gnu.tar.gz"
|
|
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101"
|
|
UV_PIN_SHA256="600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101"
|
|
;;
|
|
linux-arm64)
|
|
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-gnu.tar.gz"
|
|
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2"
|
|
UV_PIN_SHA256="bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2"
|
|
;;
|
|
linux-x64-musl)
|
|
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-musl.tar.gz"
|
|
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/0643b9fb8c9fb27458e709ce6ff939695013c41975ff7b02d3f3b138d8d4bdb3"
|
|
UV_PIN_SHA256="0643b9fb8c9fb27458e709ce6ff939695013c41975ff7b02d3f3b138d8d4bdb3"
|
|
;;
|
|
linux-arm64-musl)
|
|
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-musl.tar.gz"
|
|
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/fa513fca1eb2913334c944fe9adbdd410274a1cbe8dd05d03699a9eb85311d4e"
|
|
UV_PIN_SHA256="fa513fca1eb2913334c944fe9adbdd410274a1cbe8dd05d03699a9eb85311d4e"
|
|
;;
|
|
darwin-x64)
|
|
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-apple-darwin.tar.gz"
|
|
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b"
|
|
UV_PIN_SHA256="4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b"
|
|
;;
|
|
darwin-arm64)
|
|
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-apple-darwin.tar.gz"
|
|
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843"
|
|
UV_PIN_SHA256="546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843"
|
|
;;
|
|
*)
|
|
UV_PIN_URL=""
|
|
UV_PIN_SHA256=""
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
# --- END GENERATED: bootstrap pins ---
|
|
|
|
uv_bootstrap_target() {
|
|
# Map this host to a pm/lock.json target key (<os>-<arch>).
|
|
local _arch
|
|
case "$(uname -m)" in
|
|
arm64|aarch64) _arch="arm64" ;;
|
|
x86_64|amd64) _arch="x64" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
case "$(uname -s)" in
|
|
Linux)
|
|
# Same precedence as pm/store.py::_is_musl_libc: the native
|
|
# userland's ELF interpreter decides; ldd and a musl loader on
|
|
# disk are fallbacks only (a glibc host may carry musl as a
|
|
# secondary toolchain, and minimal musl roots may lack ldd).
|
|
local _libc="" _probe _head
|
|
for _probe in /bin/sh /bin/ls; do
|
|
_head="$(head -c 8192 "$_probe" 2>/dev/null | LC_ALL=C tr -d '\000')" || continue
|
|
[[ "$_head" == $'\x7f'ELF* ]] || continue
|
|
case "$_head" in
|
|
*ld-musl-*) _libc="musl"; break ;;
|
|
*ld-linux*) _libc="glibc"; break ;;
|
|
esac
|
|
done
|
|
if [[ -z "$_libc" ]]; then
|
|
_libc="$(ldd --version 2>&1 || true)"
|
|
_libc="${_libc,,}"
|
|
fi
|
|
if [[ "$_libc" == *musl* ]]; then
|
|
echo "linux-$_arch-musl"
|
|
elif [[ "$_libc" == *glibc* || "$_libc" == *"gnu libc"* || "$_libc" == *"gnu c library"* ]]; then
|
|
echo "linux-$_arch"
|
|
elif compgen -G '/lib/ld-musl-*.so.1' >/dev/null; then
|
|
echo "linux-$_arch-musl"
|
|
else
|
|
echo "linux-$_arch"
|
|
fi
|
|
;;
|
|
Darwin) echo "darwin-$_arch" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# version_at_least HAVE WANT: dotted numeric comparison; a pre-release or
|
|
# build suffix on a component is ignored ("0.12.3-rc1" reads as 0.12.3).
|
|
version_at_least() {
|
|
local have="$1" want="$2" h w
|
|
while [ -n "$want" ]; do
|
|
h="${have%%.*}"; h="${h%%[!0-9]*}"
|
|
w="${want%%.*}"; w="${w%%[!0-9]*}"
|
|
[ "${h:-0}" -gt "${w:-0}" ] && return 0
|
|
[ "${h:-0}" -lt "${w:-0}" ] && return 1
|
|
case "$have" in *.*) have="${have#*.}" ;; *) have="" ;; esac
|
|
case "$want" in *.*) want="${want#*.}" ;; *) want="" ;; esac
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Provision uv for this host from the pinned pm/lock.json artifact. Stages
|
|
# the EXACT artifact pm itself uses into the same store slot
|
|
# (<store>/uv-<version>-<target>/, the store pm's store_root() resolves),
|
|
# sha256-verified, so the byte authority is pm/lock.json - no astral-latest,
|
|
# no curl|sh.
|
|
UV_CMD=""
|
|
ensure_uv() {
|
|
[ -n "$UV_CMD" ] && return 0
|
|
local _path_uv _path_version
|
|
if _path_uv="$(command -v uv 2>/dev/null)"; then
|
|
# Developer shortcut: a uv on PATH fetches nothing, but only one at
|
|
# least as new as the pin -- the bootstrap passes flags older uv
|
|
# lacks (`python install --no-bin` arrived in 0.7).
|
|
_path_version="$("$_path_uv" --version 2>/dev/null | awk '{print $2}')"
|
|
if [ -n "$_path_version" ] && version_at_least "$_path_version" "$UV_PIN_VERSION"; then
|
|
UV_CMD="$_path_uv"
|
|
return 0
|
|
fi
|
|
log_warn "uv on PATH (${_path_version:-does not run}) is older than the pinned $UV_PIN_VERSION; staging the pin"
|
|
fi
|
|
local _target
|
|
if ! _target="$(uv_bootstrap_target)"; then
|
|
fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); install uv manually: https://docs.astral.sh/uv/"
|
|
fi
|
|
if ! uv_bootstrap_pin "$_target"; then
|
|
fail "no pinned uv artifact for $_target; install uv manually: https://docs.astral.sh/uv/"
|
|
fi
|
|
local _store="${HERMES_RUNTIME_DIR:-$HERMES_HOME/tools}"
|
|
local _entry="$_store/uv-$UV_PIN_VERSION-$_target"
|
|
UV_CMD="$_entry/uv"
|
|
if [ ! -x "$UV_CMD" ]; then
|
|
log "Downloading uv $UV_PIN_VERSION ($_target)"
|
|
local _tmp
|
|
# no-tmp: ok — last-resort fallback when mktemp itself is missing
|
|
_tmp="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-uv-bootstrap.$$")"
|
|
mkdir -p "$_tmp"
|
|
local _fetched_from="$UV_PIN_URL"
|
|
# Only network availability failures permit trying identical mirrored bytes.
|
|
if curl -LsSf "$UV_PIN_URL" -o "$_tmp/uv.tar.gz"; then
|
|
:
|
|
else
|
|
local _curl_status=$?
|
|
case "$_curl_status" in
|
|
5|6|7|18|22|28|52|55|56) ;;
|
|
*) rm -rf "$_tmp"; fail "failed to download pinned uv from $UV_PIN_URL (curl $_curl_status)" ;;
|
|
esac
|
|
if [ -n "${UV_PIN_MIRROR:-}" ] && curl -LsSf "$UV_PIN_MIRROR" -o "$_tmp/uv.tar.gz"; then
|
|
_fetched_from="$UV_PIN_MIRROR"
|
|
else
|
|
rm -rf "$_tmp"
|
|
fail "failed to download pinned uv from $UV_PIN_URL or ${UV_PIN_MIRROR:-no mirror}"
|
|
fi
|
|
fi
|
|
local _digest
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
_digest="$(sha256sum "$_tmp/uv.tar.gz" | cut -d' ' -f1)"
|
|
else
|
|
_digest="$(shasum -a 256 "$_tmp/uv.tar.gz" | cut -d' ' -f1)"
|
|
fi
|
|
if [ "$_digest" != "$UV_PIN_SHA256" ]; then
|
|
rm -rf "$_tmp"
|
|
fail "uv download digest mismatch from $_fetched_from (expected $UV_PIN_SHA256, got $_digest)"
|
|
fi
|
|
if ! tar -xzf "$_tmp/uv.tar.gz" -C "$_tmp"; then
|
|
rm -rf "$_tmp"
|
|
fail "failed to extract pinned uv archive"
|
|
fi
|
|
local _unpacked
|
|
_unpacked="$(find "$_tmp" -mindepth 1 -maxdepth 2 -name uv -type f | head -n1)"
|
|
if [ -z "$_unpacked" ]; then
|
|
rm -rf "$_tmp"
|
|
fail "uv binary not found in the downloaded archive"
|
|
fi
|
|
mkdir -p "$_entry"
|
|
mv "$_unpacked" "$UV_CMD"
|
|
[ -f "$(dirname "$_unpacked")/uvx" ] && mv "$(dirname "$_unpacked")/uvx" "$_entry/uvx"
|
|
chmod +x "$UV_CMD"
|
|
chmod +x "$_entry/uvx" 2>/dev/null || true
|
|
rm -rf "$_tmp"
|
|
fi
|
|
# Bootstrap keeps the installer private; only UV_CMD invokes it.
|
|
if ! "$UV_CMD" --version >/dev/null 2>&1; then
|
|
fail "pinned uv staged but does not run on this host"
|
|
fi
|
|
log_success "uv ready ($("$UV_CMD" --version 2>/dev/null))"
|
|
}
|
|
|
|
check_platform() {
|
|
# Termux is Linux by uname, but this installer builds a glibc source
|
|
# install the phone cannot run (no Android wheels in the lock). The
|
|
# signed APT package is the only supported shape there.
|
|
if [ -n "${TERMUX_VERSION:-}" ] || case "${PREFIX:-}" in *com.termux/files/usr*) true ;; *) false ;; esac; then
|
|
fail "Termux is installed from its APT repository, not install.sh: pkg install hermes-agent (setup: https://hermes-agent.nousresearch.com/docs/getting-started/termux)"
|
|
fi
|
|
case "$(uname -s 2>/dev/null)" in
|
|
Linux*) : ;;
|
|
Darwin*) : ;;
|
|
*) fail "unsupported platform: $(uname -s). On Windows use install.ps1." ;;
|
|
esac
|
|
}
|
|
|
|
json_string() {
|
|
local value="$1" code char escaped
|
|
value="${value//\\/\\\\}"
|
|
value="${value//\"/\\\"}"
|
|
for ((code = 1; code < 32; code++)); do
|
|
printf -v char '\\%03o' "$code"
|
|
printf -v char '%b' "$char"
|
|
printf -v escaped '\\u%04x' "$code"
|
|
value="${value//"$char"/$escaped}"
|
|
done
|
|
printf '"%s"' "$value"
|
|
}
|
|
|
|
json_frame() {
|
|
# $1 ok, $2 stage, $3 skipped, $4 reason
|
|
if [ -n "${4:-}" ]; then
|
|
printf '{"ok":%s,"stage":%s,"skipped":%s,"reason":%s}\n' "$1" "$(json_string "$2")" "$3" "$(json_string "$4")"
|
|
else
|
|
printf '{"ok":%s,"stage":%s,"skipped":%s}\n' "$1" "$(json_string "$2")" "$3"
|
|
fi
|
|
}
|
|
|
|
stage_result() {
|
|
local code="$1" ok=false reason="${STAGE_REASON:-}"
|
|
if [ "$code" -eq 0 ]; then
|
|
ok=true
|
|
else
|
|
reason="${reason:-stage failed (exit $code)}"
|
|
fi
|
|
if [ "$JSON" = true ]; then
|
|
json_frame "$ok" "$STAGE" "${STAGE_SKIPPED:-false}" "$reason"
|
|
fi
|
|
}
|
|
|
|
# The single authoritative stage list: emit_manifest prints it AND the
|
|
# no-flag ladder runs it. `products` is the shared completion tail -- the same
|
|
# call `hermes update` makes -- so the manifest and the run cannot disagree.
|
|
# `desktop` stays directly dispatchable via --stage for external callers, but
|
|
# is never listed: --include-desktop selects the desktop product inside
|
|
# `products` instead of adding a second build stage.
|
|
stage_names() {
|
|
printf '%s\n' prerequisites repository venv python-deps config products setup gateway complete
|
|
}
|
|
|
|
# "title|category|needs_user_input".
|
|
products_record() {
|
|
if [ "$INCLUDE_DESKTOP" = true ]; then
|
|
echo "Install command and app + desktop|runtime|false"
|
|
else
|
|
echo "Install command and app|runtime|false"
|
|
fi
|
|
}
|
|
|
|
# "$1" stage name -> its manifest record fields (title|category|needs_user_input).
|
|
stage_record() {
|
|
case "$1" in
|
|
prerequisites) echo "System prerequisites|runtime|false" ;;
|
|
repository) echo "Download Hermes Agent|runtime|false" ;;
|
|
venv) echo "Create Python environment|runtime|false" ;;
|
|
python-deps) echo "Install Python dependencies|runtime|false" ;;
|
|
config) echo "Prepare config and skills|configuration|false" ;;
|
|
products) products_record ;;
|
|
setup) echo "Configure API keys and settings|configuration|true" ;;
|
|
gateway) echo "Configure gateway service|configuration|true" ;;
|
|
desktop) echo "Build desktop app|runtime|false" ;;
|
|
complete) echo "Finish install|runtime|false" ;;
|
|
esac
|
|
}
|
|
|
|
emit_manifest() {
|
|
printf '%s' '{"protocol_version":1,"stages":['
|
|
_sep=""
|
|
for _s in $(stage_names); do
|
|
IFS='|' read -r _title _category _needs <<< "$(stage_record "$_s")"
|
|
printf '%s{"name":"%s","title":"%s","category":"%s","needs_user_input":%s}' \
|
|
"$_sep" "$_s" "$_title" "$_category" "$_needs"
|
|
_sep=","
|
|
done
|
|
printf '%s\n' ']}'
|
|
}
|
|
|
|
stage_prerequisites() {
|
|
command -v git >/dev/null 2>&1 || fail "git is required. Install it with your system package manager."
|
|
command -v curl >/dev/null 2>&1 || fail "curl is required. Install it with your system package manager."
|
|
# PM's Node on musl is the unofficial-builds musl archive, which links the
|
|
# system libstdc++; without it every node/npm stage fails verification.
|
|
if [[ "$(uv_bootstrap_target 2>/dev/null)" == *-musl ]]; then
|
|
local _libdir _stdcxx=""
|
|
for _libdir in /lib /usr/lib /usr/local/lib; do
|
|
compgen -G "$_libdir/libstdc++.so.6*" >/dev/null && { _stdcxx=yes; break; }
|
|
done
|
|
[ -n "$_stdcxx" ] || fail "musl host: the Node.js runtime needs the system libstdc++. Install it (Alpine: apk add libstdc++, Void: xbps-install libstdc++) and re-run."
|
|
fi
|
|
log_success "prerequisites ok (git, curl)"
|
|
}
|
|
|
|
stage_repository() {
|
|
# An interrupted clone from an older installer can leave a .git with no
|
|
# initial commit, where stash/checkout abort ("You do not have the
|
|
# initial commit yet", #40998). Move it aside -- never delete it, it may
|
|
# hold something the user wants -- and clone fresh below.
|
|
if [ -d "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
local broken
|
|
broken="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)"
|
|
log_warn "$INSTALL_DIR has no commits (interrupted clone); moving it aside to $broken"
|
|
mv "$INSTALL_DIR" "$broken" || fail "cannot move $INSTALL_DIR aside"
|
|
fi
|
|
if [ -d "$INSTALL_DIR/.git" ]; then
|
|
log "Updating $INSTALL_DIR ($BRANCH)"
|
|
# An explicit HERMES_REPO_URL names the source for reruns too, not
|
|
# just the first clone.
|
|
if [ -n "${HERMES_REPO_URL:-}" ]; then
|
|
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" || fail "cannot point origin at $REPO_URL"
|
|
fi
|
|
run_logged "Fetching origin/$BRANCH" git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed"
|
|
local stamp
|
|
stamp="$(date -u +%Y%m%d-%H%M%S)"
|
|
# Park local work BEFORE switching branches: checkout refuses a dirty
|
|
# tree that conflicts, and the reset below would discard it. Work
|
|
# that cannot be parked stops the install -- never overwrite it.
|
|
if [ -n "$(git -C "$INSTALL_DIR" status --porcelain)" ]; then
|
|
# An interrupted update can leave unmerged index entries, where
|
|
# stash aborts ("could not write index"). Dropping only the
|
|
# index-level conflict state keeps the working-tree changes for
|
|
# the stash below (#4735).
|
|
if [ -n "$(git -C "$INSTALL_DIR" ls-files --unmerged)" ]; then
|
|
log_warn "clearing unmerged index entries from a previous conflict"
|
|
git -C "$INSTALL_DIR" reset -q || fail "cannot clear the unmerged index in $INSTALL_DIR"
|
|
fi
|
|
run_logged "Stashing local changes" \
|
|
git -C "$INSTALL_DIR" stash push --include-untracked -m "hermes-install-autostash-$stamp" \
|
|
|| fail "could not stash local changes in $INSTALL_DIR; commit or move them aside, then rerun"
|
|
log_warn "local changes stashed as hermes-install-autostash-$stamp"
|
|
fi
|
|
run_logged "Checking out $BRANCH" git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed"
|
|
if ! run_logged --may-fail "Fast-forwarding to origin/$BRANCH" \
|
|
git -C "$INSTALL_DIR" merge --ff-only "origin/$BRANCH"; then
|
|
# A release cut off the main line, a force-pushed remote, or the
|
|
# user's own commits cannot fast-forward. Every stage below reads
|
|
# files only the new tree has (pm/), so an install left on the old
|
|
# tree cannot finish -- match the remote the way `hermes update`
|
|
# does, after parking the old tip.
|
|
# Only commits absent from origin need a rescue ref. Keep the same
|
|
# namespace as `hermes update` so its pruning and recovery work.
|
|
local dropped rescue_kind rescue_ref prior
|
|
dropped="$(git -C "$INSTALL_DIR" rev-list --count "origin/$BRANCH..HEAD")" \
|
|
|| fail "cannot count commits before reset"
|
|
if [ "$dropped" -gt 0 ]; then
|
|
rescue_kind="diverged"
|
|
git -C "$INSTALL_DIR" merge-base HEAD "origin/$BRANCH" >/dev/null 2>&1 \
|
|
|| rescue_kind="orphan"
|
|
prior="$(git -C "$INSTALL_DIR" rev-parse --short=12 HEAD)" \
|
|
|| fail "cannot identify commits before reset"
|
|
rescue_ref="refs/hermes-update-backups/$rescue_kind-$BRANCH-$stamp-$prior"
|
|
git -C "$INSTALL_DIR" update-ref "$rescue_ref" HEAD \
|
|
|| fail "cannot back up $dropped local commit(s); refusing to reset"
|
|
log_warn "$dropped commit(s) not on origin/$BRANCH backed up to $rescue_ref"
|
|
log "List them with: git -C \"$INSTALL_DIR\" log origin/$BRANCH..$rescue_ref"
|
|
fi
|
|
run_logged "Resetting to origin/$BRANCH" git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH" \
|
|
|| fail "git reset failed"
|
|
log_warn "not fast-forwardable; reset to origin/$BRANCH"
|
|
fi
|
|
else
|
|
# `mv <clone> <existing dir>` nests the checkout INSIDE it as
|
|
# <dir>/tree, so a pre-existing destination must be empty (we take
|
|
# the empty dir over) or we refuse: whatever lives there is not ours.
|
|
if [ -e "$INSTALL_DIR" ] || [ -L "$INSTALL_DIR" ]; then
|
|
if [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -z "$(ls -A "$INSTALL_DIR")" ]; then
|
|
rmdir "$INSTALL_DIR" || fail "cannot replace empty $INSTALL_DIR"
|
|
else
|
|
fail "$INSTALL_DIR exists and is not a Hermes git checkout. Move it aside, or install elsewhere with --dir <path>."
|
|
fi
|
|
fi
|
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
|
local staged attempt label cloned=false progress=()
|
|
# Phase lines ("Receiving objects: 42%") feed the status line; git
|
|
# prints none to a pipe unless asked.
|
|
if quiet_output; then progress=(--progress); fi
|
|
staged="$(mktemp -d "$(dirname "$INSTALL_DIR")/.hermes-clone-XXXXXX")" || fail "cannot stage clone"
|
|
for attempt in 1 2 3; do
|
|
# Treeless: every commit and release tag (runtime identity is the
|
|
# nearest reachable release; --commit pins and branch switches
|
|
# still resolve), trees and blobs fetched on demand, so the
|
|
# download stays close to a --depth 1 clone.
|
|
label="Cloning $REPO_URL ($BRANCH) into $INSTALL_DIR"
|
|
[ "$attempt" = 1 ] || label="$label (attempt $attempt of 3)"
|
|
if run_logged "$label" git clone ${progress[@]+"${progress[@]}"} \
|
|
--filter=tree:0 --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
|
|
cloned=true
|
|
break
|
|
fi
|
|
rm -rf "$staged/tree"
|
|
[ "$attempt" = 3 ] || sleep "$((attempt * 5))"
|
|
done
|
|
if [ "$cloned" = false ]; then
|
|
# The checkout step is where throttled downloads die: clone the
|
|
# graph alone, then retry materializing the tree separately.
|
|
log_warn "direct clone failed; trying deferred checkout"
|
|
if run_logged "Cloning history" git clone ${progress[@]+"${progress[@]}"} \
|
|
--filter=tree:0 --no-checkout --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
|
|
for attempt in 1 2; do
|
|
if run_logged "Checking out files (attempt $attempt of 2)" \
|
|
git -C "$staged/tree" reset --hard HEAD; then
|
|
cloned=true
|
|
break
|
|
fi
|
|
[ "$attempt" = 2 ] || sleep 5
|
|
done
|
|
fi
|
|
fi
|
|
if [ "$cloned" = false ]; then
|
|
rm -rf "$staged"
|
|
fail "git clone failed; no checkout published"
|
|
fi
|
|
if ! mv "$staged/tree" "$INSTALL_DIR"; then
|
|
rm -rf "$staged"
|
|
fail "cannot publish cloned checkout"
|
|
fi
|
|
rmdir "$staged"
|
|
log_success "Hermes Agent cloned"
|
|
fi
|
|
if [ -n "$INSTALL_COMMIT" ]; then
|
|
# A pin must come from the branch being installed: the complete
|
|
# marker records both, and a commit off that branch would make the
|
|
# next plain rerun "update" onto a different line.
|
|
git -C "$INSTALL_DIR" merge-base --is-ancestor "$INSTALL_COMMIT" "origin/$BRANCH" 2>/dev/null \
|
|
|| fail "commit $INSTALL_COMMIT is not on branch $BRANCH"
|
|
run_logged "Pinning $INSTALL_COMMIT" git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" \
|
|
|| fail "could not pin commit $INSTALL_COMMIT"
|
|
fi
|
|
}
|
|
|
|
stage_venv() {
|
|
# Keep the installer stage protocol; PM alone creates dependency environments.
|
|
local boot_py
|
|
bootstrap_python
|
|
log_success "bootstrap Python ready; PM prepares the dependency environment"
|
|
}
|
|
|
|
# Tool-only bootstrap: acquire uv and Python before PM's own dependencies exist.
|
|
# The application dependency graph is never installed in this interpreter.
|
|
bootstrap_python() {
|
|
ensure_uv
|
|
local _py
|
|
# Read packages.python.version by following object names and braces, not
|
|
# indentation — same pre-Python reader contract as setup-hermes.sh's pin().
|
|
_py="$(awk -F '"' '
|
|
/^[[:space:]]*("[^"]+"[[:space:]]*:[[:space:]]*)?\{/ { path[++depth] = $2; next }
|
|
/^[[:space:]]*\}[[:space:]]*,?[[:space:]]*$/ { delete path[depth--]; next }
|
|
path[2] == "packages" && path[3] == "python" && $2 == "version" && depth == 3 { print $4; exit }
|
|
' "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)"
|
|
[ -n "$_py" ] || _py="3.14"
|
|
# Only base interpreters qualify: an activated app venv must not become
|
|
# PM's bootstrap parent. Prefer the existing managed Python, then a host
|
|
# Python of the same supported minor before attempting a download (#10778).
|
|
# This interpreter only boots PM; PM still owns the exact runtime pin.
|
|
if ! boot_py="$(UV_SYSTEM_PYTHON=1 UV_NO_PROJECT=1 "$UV_CMD" python find --managed-python "$_py" 2>/dev/null)" \
|
|
&& ! boot_py="$("$UV_CMD" python find --system --no-project "$_py" 2>/dev/null)"; then
|
|
run_logged "Downloading Python $_py" "$UV_CMD" python install --no-bin --no-registry "$_py" \
|
|
|| fail "bootstrap Python installation failed"
|
|
boot_py="$(UV_SYSTEM_PYTHON=1 UV_NO_PROJECT=1 "$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed"
|
|
fi
|
|
boot_py="${boot_py%$'\r'}"
|
|
[ -x "$boot_py" ] && "$boot_py" --version >/dev/null 2>&1 || fail "bootstrap Python is not executable: $boot_py"
|
|
}
|
|
|
|
# uv exits before PM can replace its tool entry. pm.cli then prepares and
|
|
# enters its independently locked runtime before mutating application deps.
|
|
bootstrap_pm() {
|
|
local boot_py
|
|
local pm_args=(install)
|
|
# PM records the opt-out, so later installs and `hermes update` keep the
|
|
# browser tools off until `hermes pm install agent-browser` opts back in.
|
|
[ "$SKIP_BROWSER" = true ] && pm_args+=(--without agent-browser)
|
|
bootstrap_python
|
|
(cd "$INSTALL_DIR" && run_logged "Installing dependencies (hash-verified via uv.lock)" \
|
|
"$boot_py" -m pm.cli "${pm_args[@]}") \
|
|
|| fail "pm install failed"
|
|
log_success "dependencies installed"
|
|
}
|
|
|
|
stage_python_deps() {
|
|
bootstrap_pm
|
|
}
|
|
|
|
desktop_product_present() {
|
|
# Does this checkout already carry a built desktop app? A plain repair or
|
|
# upgrade rerun on a desktop install must REBUILD it rather than leave a
|
|
# bundle built from the previous code: the app is part of that install, and
|
|
# the artifacts live inside the tree (gitignored), so an update makes them
|
|
# stale instead of removing them.
|
|
# electron-builder suffixes the output dir with the arch on every non-x64
|
|
# target (linux-arm64-unpacked, mac-arm64, win-arm64-unpacked), so the x64
|
|
# names alone miss a desktop build on ARM64 Linux/Windows (#94703).
|
|
local release="$INSTALL_DIR/apps/desktop/release" dir
|
|
for dir in linux-unpacked linux-arm64-unpacked mac mac-arm64 \
|
|
win-unpacked win-ia32-unpacked win-arm64-unpacked; do
|
|
[ -d "$release/$dir" ] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Guarded: a login shell whose ~/.bash_profile sources ~/.bashrc (Fedora, RHEL)
|
|
# reads both files, so an unconditional prepend would put ~/.local/bin on PATH twice.
|
|
SHELL_PATH_LINE='case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; esac'
|
|
SHELL_PATH_SETUP_RE='^[[:space:]]*([^#[:space:]].*)?PATH=.*\.local/bin'
|
|
|
|
append_shell_path() {
|
|
local rc="$1" line="$2" pattern="$3"
|
|
# Existing user PATH setup wins; never append another line on a repair run.
|
|
if [ -f "$rc" ] && grep -E "$pattern" "$rc" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
mkdir -p "$(dirname "$rc")"
|
|
printf '\n# Hermes Agent command\n%s\n' "$line" >> "$rc" || fail "cannot update PATH in $rc"
|
|
log_success "added ~/.local/bin to PATH in $rc"
|
|
}
|
|
|
|
wire_shell_path() {
|
|
# The launcher is published by source_completion; shell rc files belong to
|
|
# the installer, not to PM (updates must not modify a user's shell setup).
|
|
# Never use the installer's inherited PATH as a proxy for a *new* shell.
|
|
local login_shell="${SHELL:-/bin/bash}"
|
|
case "${login_shell##*/}" in
|
|
zsh)
|
|
append_shell_path "$HOME/.zshrc" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
|
|
append_shell_path "$HOME/.zprofile" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
|
|
;;
|
|
fish)
|
|
append_shell_path "$HOME/.config/fish/config.fish" 'fish_add_path "$HOME/.local/bin"' '^[[:space:]]*fish_add_path.*\.local/bin'
|
|
;;
|
|
*)
|
|
append_shell_path "$HOME/.bashrc" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
|
|
append_shell_path "$HOME/.profile" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
|
|
# Bash prefers .bash_profile over .profile if both exist.
|
|
if [ -f "$HOME/.bash_profile" ]; then
|
|
append_shell_path "$HOME/.bash_profile" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
stage_products() {
|
|
# The whole tail in one place, by calling the completion an update calls:
|
|
# publish the commands, build the products (tui/web, plus the desktop app),
|
|
# then run the post-build maintenance that syncs bundled skills and migrates
|
|
# config. Node, browsers and the frontend build tools arrive through pm as
|
|
# the build asks for them; the bootstrap interpreter itself only re-enters
|
|
# the tree on PM's selected Python.
|
|
local boot_py
|
|
local args=(--source "$INSTALL_DIR")
|
|
bootstrap_python
|
|
if [ "$INCLUDE_DESKTOP" = true ] || desktop_product_present; then
|
|
args+=(--desktop)
|
|
fi
|
|
(cd "$INSTALL_DIR" && run_logged "Building the hermes command and apps" \
|
|
"$boot_py" -I -B -X utf8 hermes_cli/source_completion.py "${args[@]}") \
|
|
|| fail "app products or command publication failed"
|
|
wire_shell_path
|
|
log_success "app products and hermes command ready"
|
|
}
|
|
|
|
stage_desktop() {
|
|
# External-caller contract: `--stage desktop` stays dispatchable on its own
|
|
# (the manifest never lists it now -- --include-desktop selects the desktop
|
|
# product inside `products`). Same completion call, desktop selected.
|
|
INCLUDE_DESKTOP=true
|
|
stage_products
|
|
}
|
|
|
|
stage_config() {
|
|
mkdir -p "$HERMES_HOME"/cron "$HERMES_HOME"/sessions "$HERMES_HOME"/logs \
|
|
"$HERMES_HOME"/pairing "$HERMES_HOME"/hooks "$HERMES_HOME"/image_cache \
|
|
"$HERMES_HOME"/audio_cache "$HERMES_HOME"/memories "$HERMES_HOME"/skills
|
|
if [ ! -f "$HERMES_HOME/.env" ]; then
|
|
cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env" 2>/dev/null || touch "$HERMES_HOME/.env"
|
|
fi
|
|
chmod 600 "$HERMES_HOME/.env"
|
|
if [ ! -f "$HERMES_HOME/config.yaml" ] && [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then
|
|
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
|
fi
|
|
log_success "config prepared in $HERMES_HOME"
|
|
}
|
|
|
|
# Interactive stages read the terminal, not stdin: under `curl | bash` stdin
|
|
# IS the script. Probe by opening /dev/tty -- a Docker build has the device
|
|
# node in its mount namespace but opening it fails (ENXIO).
|
|
has_terminal() { (: </dev/tty) 2>/dev/null; }
|
|
|
|
stage_setup() {
|
|
if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
|
|
if ! has_terminal; then
|
|
log "setup skipped (no terminal); run 'hermes setup' after install"
|
|
return 0
|
|
fi
|
|
"$INSTALL_DIR/.hermes/bin/hermes" setup </dev/tty || fail "setup failed"
|
|
}
|
|
|
|
stage_gateway() {
|
|
if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
|
|
if ! has_terminal; then
|
|
log "gateway setup skipped (no terminal); run 'hermes gateway install' after install"
|
|
return 0
|
|
fi
|
|
# Setup installs the service when it handles the gateway; ask only if it did not.
|
|
"$INSTALL_DIR/.hermes/bin/hermes" gateway install --if-missing </dev/tty || fail "gateway installation failed"
|
|
}
|
|
|
|
stage_complete() {
|
|
local commit
|
|
commit="$INSTALL_COMMIT"
|
|
[ -n "$commit" ] || commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || commit=""
|
|
if [ -n "$commit" ]; then
|
|
printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \
|
|
"$commit" "$BRANCH" "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$INSTALL_DIR/.hermes-bootstrap-complete.tmp"
|
|
mv -f "$INSTALL_DIR/.hermes-bootstrap-complete.tmp" "$INSTALL_DIR/.hermes-bootstrap-complete"
|
|
fi
|
|
log_success "Hermes Agent install complete. Run: hermes"
|
|
}
|
|
|
|
print_path_reload_hint() {
|
|
# The rc files only reach shells started later, and this installer is
|
|
# always a child (`curl | bash`, `bash install.sh`) that cannot change its
|
|
# parent's PATH. The inherited PATH is the parent's, so it says whether
|
|
# the user can run `hermes` right away.
|
|
case ":$PATH:" in *":$HOME/.local/bin:"*|*":$HOME/.local/bin/:"*) return 0 ;; esac
|
|
local rc
|
|
local login_shell="${SHELL:-}"
|
|
case "${login_shell##*/}" in
|
|
zsh) rc="source ~/.zshrc" ;;
|
|
fish) rc="source ~/.config/fish/config.fish" ;;
|
|
bash|"") rc="source ~/.bashrc" ;;
|
|
*) rc=". ~/.profile" ;;
|
|
esac
|
|
log "Reload your shell to use hermes: open a new terminal, or run: $rc"
|
|
}
|
|
|
|
run_stage() (
|
|
# Keep failure handling out of conditional calls, which disable errexit.
|
|
set -e
|
|
STAGE="$1"
|
|
STAGE_REASON=""
|
|
STAGE_SKIPPED=false
|
|
trap 'stage_result "$?"' EXIT
|
|
if [ "$NON_INTERACTIVE" = true ] && { [ "$STAGE" = setup ] || [ "$STAGE" = gateway ]; }; then
|
|
STAGE_SKIPPED=true
|
|
STAGE_REASON="needs user input"
|
|
exit 0
|
|
fi
|
|
case "$1" in
|
|
prerequisites) stage_prerequisites ;;
|
|
repository) stage_repository ;;
|
|
venv) stage_venv ;;
|
|
python-deps) stage_python_deps ;;
|
|
config) stage_config ;;
|
|
products) stage_products ;;
|
|
setup) stage_setup ;;
|
|
gateway) stage_gateway ;;
|
|
desktop) stage_desktop ;;
|
|
complete) stage_complete ;;
|
|
*) STAGE_REASON="unknown stage: $1"; printf '%s\n' "$STAGE_REASON" >&2; exit 2 ;;
|
|
esac
|
|
)
|
|
|
|
# Main. Guarded so the script can be SOURCED for its functions (the
|
|
# installer-test harness sources it with --manifest, which must define
|
|
# the functions and stop before main). Under `curl | bash` BASH_SOURCE is
|
|
# empty, and `set -u` would abort on the bare expansion.
|
|
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
|
|
if [ "$WANT_MANIFEST" = true ]; then
|
|
emit_manifest
|
|
exit 0
|
|
fi
|
|
|
|
if [ -n "$STAGE" ] && [ "$JSON" = true ]; then
|
|
trap 'stage_result "$?"' EXIT
|
|
fi
|
|
check_platform
|
|
trap - EXIT
|
|
|
|
if [ -n "$STAGE" ]; then
|
|
run_stage "$STAGE"
|
|
exit "$?"
|
|
fi
|
|
|
|
# No --stage: run the whole ladder — the same authoritative list the
|
|
# manifest prints, so --include-desktop inserts desktop here too.
|
|
print_banner
|
|
for s in $(stage_names); do
|
|
run_stage "$s"
|
|
rc=$?
|
|
[ "$rc" -eq 0 ] || exit "$rc"
|
|
done
|
|
print_path_reload_hint
|
|
fi
|