Files
hermes-agent/tests/install/macos-desktop-e2e.sh
ethernet 3b6bafefdf test(install/macos): give a just-launched historical app longer to Quit
The v2026.7.1 app launched by the latest DMG was still starting its backend
and did not finish a normal Quit within 30s. Wait up to 120s; the quit must
still be the normal one (no force-kill).
2026-09-24 15:10:13 -04:00

442 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# Prove a macOS user who installed OLD via the published desktop installer
# (Hermes-Setup.dmg from the website) can reach HEAD.
#
# The macOS sibling of tests/install/windows-e2e.ps1's desktop-installer
# arm, sharing the staging trick: every git process is pointed at a local
# bare clone via url.<file://serve.git>.insteadOf in a driver-owned
# GIT_CONFIG_GLOBAL. The published dmg carries no commit pin - it installs
# whatever `main` serves - so parking serve.git's main at OLD stages the
# "user on the current release" start, and advancing it to HEAD makes an
# update available exactly the way it does for a real user. Its separately
# resolved install.sh must come from OLD too: git's redirect does not cover
# raw.githubusercontent.com. The published GUI still owns every install stage.
#
# Phases (state shared via the workroot, mirroring the windows driver):
# stage bare-clone this checkout to serve.git, park main at OLD
# install download the dmg, hdiutil attach, run the installer app's
# binary DIRECTLY (env inheritance: an `open`-launched app sees
# none of our redirect env), wait for the install to land
# update advance served main to HEAD, apply ONE update method:
# open-app-update launch the installed app binary
# under Playwright, click Update now
# hermes-desktop-app-update capture `hermes desktop`'s spawn,
# launch the spec under Playwright,
# click Update now
# hermes-update CLI update from the installed command
# installer-script[+desktop] re-run the current install one-liner
#
# Usage:
# tests/install/macos-desktop-e2e.sh --phase stage|install|update|all
# --update-method open-app-update|hermes-desktop-app-update
# [--install-ref REF] [--dmg-url URL]
# [--update-ref REF] update target, default HEAD; pass the next
# release tag for a stable-to-stable leg (label the
# leg stable-to-stable only when both refs are tags);
# NEXT mints a synthetic child of --install-ref
#
# Requires a clean full-history checkout with release tags fetched, on a
# macOS host with a window server (the GitHub macos runners qualify).
set -euo pipefail
# One time base for every transcript in this leg: ts_prefix stamps lines
# relative to TS_BASE, so all logs share the driver's clock and a single
# playback.html offset slider aligns every file with the recording.
export TS_BASE=$SECONDS
PHASE="all"
UPDATE_METHOD=""
INSTALL_REF=""
UPDATE_REF=""
DMG_URL="https://hermes-assets.nousresearch.com/Hermes-Setup.dmg"
while [ "$#" -gt 0 ]; do
case "$1" in
--phase)
[ "$#" -ge 2 ] || { echo 'error: --phase needs a value' >&2; exit 1; }
PHASE="$2"; shift 2 ;;
--update-method)
[ "$#" -ge 2 ] || { echo 'error: --update-method needs a value' >&2; exit 1; }
UPDATE_METHOD="$2"; shift 2 ;;
--install-ref)
[ "$#" -ge 2 ] || { echo 'error: --install-ref needs a value' >&2; exit 1; }
INSTALL_REF="$2"; shift 2 ;;
--update-ref)
[ "$#" -ge 2 ] || { echo 'error: --update-ref needs a value' >&2; exit 1; }
UPDATE_REF="$2"; shift 2 ;;
--dmg-url)
[ "$#" -ge 2 ] || { echo 'error: --dmg-url needs a value' >&2; exit 1; }
DMG_URL="$2"; shift 2 ;;
-h|--help) sed -n '2,32p' "$0"; exit 0 ;;
*) echo "error: unknown argument: $1" >&2; exit 1 ;;
esac
done
case "$UPDATE_METHOD" in
open-app-update|hermes-desktop-app-update|hermes-update|installer-script|installer-script+desktop) ;;
*) echo "error: unsupported --update-method '$UPDATE_METHOD'" >&2; exit 1 ;;
esac
[ "$(uname -s)" = "Darwin" ] || { echo "error: this driver runs on macOS only" >&2; exit 1; }
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
ASSETS="$REPO_ROOT/tests/install/e2e-assets"
export HERMES_E2E_NODE="${HERMES_E2E_NODE:-$(command -v node)}"
WORK_ROOT="${HERMES_E2E_WORKROOT:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/hermes-macos-desktop-e2e}"
LOG_DIR="${HERMES_E2E_LOG_DIR:-$WORK_ROOT/logs}"
SERVE_REPO="$WORK_ROOT/serve.git"
STATE="$WORK_ROOT/shas.env"
export HOME_SANDBOX="$WORK_ROOT/home"
step() { printf '\n=== %s ===\n' "$*"; }
ok() { printf ' OK %s\n' "$*"; }
fail() { printf 'E2E ASSERTION FAILED: %s\n' "$*" >&2; exit 1; }
# shellcheck source=../e2e-assets/ts-prefix.sh
source "$(dirname "$0")/e2e-assets/ts-prefix.sh" 2>/dev/null || ts_prefix() { cat; }
# shellcheck source=../install/e2e-assets/preserve-plugins.sh
source "$(dirname "$0")/e2e-assets/preserve-plugins.sh"
# shellcheck source=e2e-assets/source-driver.sh
source "$(dirname "$0")/e2e-assets/source-driver.sh"
# shellcheck source=e2e-assets/source-update-command.sh
source "$ASSETS/source-update-command.sh"
# shellcheck source=e2e-assets/installer-common.sh
source "$(dirname "$0")/e2e-assets/installer-common.sh"
# shellcheck source=e2e-assets/source-build-env.sh
source "$ASSETS/source-build-env.sh"
log_group() {
printf '::group::%s\n' "$1"
cat "$2"
printf '::endgroup::\n'
}
# Every phase runs in its own process (separate CI steps), so the redirect
# env is re-established here, not inherited.
arm_redirect() {
arm_source_redirect "$REPO_ROOT" "$WORK_ROOT" "$SERVE_REPO"
export HOME="$HOME_SANDBOX"
export PATH="$HOME/.local/bin:$PATH"
export HERMES_HOME="$HOME/.hermes"
export INSTALL_DIR="$HERMES_HOME/hermes-agent"
export HERMES_DESKTOP_USER_DATA_DIR="$WORK_ROOT/electron-user-data"
}
desktop_checkpoint() { # phase, expected commit, selected method
source_build_env "$HERMES_E2E_NODE" "$ASSETS/source-desktop-smoke.mjs" \
--root "$INSTALL_DIR" --home "$HERMES_HOME" --user-data "$HERMES_DESKTOP_USER_DATA_DIR" \
--out "$LOG_DIR" --phase "$1" --expect-commit "$2" --desktop present --method "$3"
}
phase_stage() {
step "staging serve.git (main -> OLD)"
[ -z "$(git -C "$REPO_ROOT" status --porcelain -uno)" ] \
|| fail "checkout has uncommitted tracked changes; the staged clone must be a reviewable commit"
rm -rf "$WORK_ROOT"
mkdir -p "$WORK_ROOT" "$LOG_DIR" "$HOME_SANDBOX/.local/bin"
local old_ref="$INSTALL_REF"
if [ -z "$old_ref" ] || [ "$old_ref" = "auto" ]; then
old_ref="$(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-creatordate | head -1)"
[ -n "$old_ref" ] || fail "no release tags in the checkout to use as OLD"
fi
local old_sha head_sha target_sha target_label
old_sha="$(git -C "$REPO_ROOT" rev-parse "${old_ref}^{commit}")"
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)"
# The update target defaults to HEAD; --update-ref selects any other ref
# so a stable-to-stable leg can target the next release tag instead of
# the tip. Only call this leg stable-to-stable when BOTH refs are tags.
# NEXT (the HEAD -> NEXT leg) is minted before the clone so it rides along.
target_label="${UPDATE_REF:-HEAD}"
target_sha="$(resolve_update_ref "$REPO_ROOT" "$old_sha" "$target_label")" \
|| fail "cannot resolve update ref '$target_label'"
[ "$old_sha" != "$target_sha" ] || fail "OLD ($old_ref) IS the update target ($target_label); no update would be available"
git clone --bare --quiet "$REPO_ROOT" "$SERVE_REPO"
git -C "$SERVE_REPO" cat-file -e "$target_sha^{commit}" \
|| fail "update target $target_sha ($target_label) did not reach serve.git"
git -C "$SERVE_REPO" update-ref refs/heads/main "$old_sha"
git -C "$SERVE_REPO" symbolic-ref HEAD refs/heads/main
git -C "$SERVE_REPO" config uploadpack.allowAnySHA1InWant true
arm_redirect
mkdir -p "$HERMES_HOME"
touch "$HERMES_HOME/.skip_upstream_prompt"
printf 'OLD_SHA=%s\nOLD_REF=%s\nHEAD_SHA=%s\nTARGET_SHA=%s\nTARGET_LABEL=%s\n' \
"$old_sha" "$old_ref" "$head_sha" "$target_sha" "$target_label" > "$STATE"
ok "serve.git main = $old_sha ($old_ref), update target $target_sha ($target_label)"
}
find_installed_app() {
# Require this installation's app, never an unrelated /Applications copy.
local cand
for cand in \
"$INSTALL_DIR/apps/desktop/release/mac-arm64/Hermes.app" \
"$INSTALL_DIR/apps/desktop/release/mac/Hermes.app"; do
[ -d "$cand" ] && { printf '%s' "$cand"; return 0; }
done
return 1
}
phase_install() {
# shellcheck disable=SC1090
. "$STATE"
arm_redirect
step "installing OLD ($OLD_REF) via the published Hermes-Setup.dmg"
# Pair both historical inputs. Today's downloaded install.sh can call helpers
# absent from OLD (e.g. ensure-rolldown-binding.mjs). Use the published
# bootstrap's script-source override, not a patched script or prebuilt app.
local bootstrap_root="$WORK_ROOT/bootstrap-source"
mkdir -p "$bootstrap_root/scripts"
git -C "$SERVE_REPO" show "$OLD_SHA:scripts/install.sh" > "$bootstrap_root/scripts/install.sh"
cp "$bootstrap_root/scripts/install.sh" "$LOG_DIR/bootstrap-install-script.sh"
printf 'source_commit=%s\nscript_blob=%s\n' "$OLD_SHA" \
"$(git -C "$SERVE_REPO" rev-parse "$OLD_SHA:scripts/install.sh")" \
> "$LOG_DIR/bootstrap-install-script.txt"
ok "bootstrap script is unmodified scripts/install.sh from $OLD_REF ($OLD_SHA)"
local dmg="$WORK_ROOT/Hermes-Setup.dmg"
[ -f "$dmg" ] || curl -fsSL -o "$dmg" "$DMG_URL"
[ "$(stat -f%z "$dmg")" -gt 1000000 ] || fail "dmg download too small: $(stat -f%z "$dmg") bytes"
# curl'd files carry no quarantine attr, but belt and braces on a runner.
xattr -dr com.apple.quarantine "$dmg" 2>/dev/null || true
local mount
mount="$(hdiutil attach -nobrowse -readonly "$dmg" | awk -F'\t' '/\/Volumes\//{print $NF; exit}')"
[ -n "$mount" ] || fail "hdiutil attach produced no mount point"
ok "dmg mounted at $mount"
local app_bin=""
local app
app="$(find "$mount" -maxdepth 1 -name '*.app' | head -1)"
[ -n "$app" ] || { hdiutil detach "$mount" >/dev/null 2>&1 || true; fail "no .app inside the dmg"; }
app_bin="$(find "$app/Contents/MacOS" -type f -perm +111 | head -1)"
[ -n "$app_bin" ] || fail "no executable inside $app/Contents/MacOS"
# The Setup app is Tauri (Rust + system webview): Playwright/Electron
# attach never works, and run bare it waits forever on its setup-choice
# screen. Launch it in the background with our env (direct exec, not
# `open`: launchd inherits NONE of the redirect env) and drive the
# "Install Hermes" button with native input.
local rc=0
HERMES_SETUP_DEV_REPO_ROOT="$bootstrap_root" source_build_env bash "$ASSETS/drive-dmg-install.sh" \
--app-bin "$app_bin" \
--install-dir "$INSTALL_DIR" \
--proof-dir "$LOG_DIR" 2>&1 \
| ts_prefix > "$LOG_DIR/bootstrap-install.log" || rc=$?
log_group "Hermes-Setup (dmg bootstrap) transcript" "$LOG_DIR/bootstrap-install.log"
local bootstrap_log="$LOG_DIR/bootstrap-logs/bootstrap-installer.log"
if [ -f "$bootstrap_log" ]; then
log_group "Hermes-Setup inner installer log" "$bootstrap_log"
fi
hdiutil detach "$mount" >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || fail "dmg bootstrap exited $rc; transcript above"
grep -Fq "[bootstrap] script $bootstrap_root/scripts/install.sh via dev checkout" "$bootstrap_log" \
|| fail "bootstrap did not confirm using the historical install script"
[ -d "$INSTALL_DIR/.git" ] || fail "no checkout landed at $INSTALL_DIR"
local got
got="$(git -C "$INSTALL_DIR" rev-parse HEAD)"
[ "$got" = "$OLD_SHA" ] || fail "installed checkout is $got, expected OLD ($OLD_SHA)"
ok "checkout is OLD ($OLD_SHA)"
local hermes
hermes="$(source_hermes "$INSTALL_DIR")" || fail "no installed command after install"
python3 -B "$ASSETS/source_driver.py" --root "$INSTALL_DIR" --launcher "$hermes" --desktop present \
|| fail "read-only verification failed after install"
HERMES_DISABLE_LAZY_INSTALLS=1 PYTHONDONTWRITEBYTECODE=1 source_build_env "$hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-old.log" || fail "hermes --version failed after install"
ok "hermes --version works: $(head -c 120 "$LOG_DIR/version-old.log" | tr -d '\n')"
find_installed_app >/dev/null || fail "no installed Hermes.app after the dmg bootstrap"
ok "installed app: $(find_installed_app)"
# The bootstrap can leave its launched app running. Preserve that handoff,
# then request normal Quit of only this installed binary before smoke owns it.
local installed_bin
installed_bin="$(find_installed_app)/Contents/MacOS/Hermes"
osascript -l JavaScript -e 'ObjC.import("AppKit"); function run(args) {
const apps = $.NSWorkspace.sharedWorkspace.runningApplications;
for (let i = 0; i < apps.count; i++) {
const app = apps.objectAtIndex(i);
if (app.executableURL && ObjC.unwrap(app.executableURL.path) === args[0]) {
if (!app.terminate) throw new Error("normal Quit refused");
// A historical app (v2026.7.1) that the bootstrap launched moments ago
// was seen not to finish quitting within 30s while its backend was
// still starting. Allow longer, but the quit must stay the normal one.
const deadline = Date.now() + 120000;
while (!app.terminated && Date.now() < deadline) delay(0.2);
if (!app.terminated) throw new Error("installed app did not quit normally");
}
}
}' "$installed_bin" || fail "installed app did not close normally; no smoke launch attempted"
desktop_checkpoint old "$OLD_SHA" desktop-installer@latest
}
run_playwright_update() {
# $1: spec file to launch from.
local spec="$1"
local rc=0
accept_installer_marker "$INSTALL_DIR" \
|| fail "installed source has changes other than the generated install marker"
(cd "$WORK_ROOT" && "$HERMES_E2E_NODE" "$ASSETS/launch-from-spec.mjs" \
--spec "$spec" \
--old-sha "$OLD_SHA" --chat-out "$LOG_DIR/update-window" --mock-url "$HERMES_E2E_MOCK_URL" \
--result "$HERMES_HOME/.hermes-update-result.json" \
--expect-sha "$TARGET_SHA" \
--repo-dir "$INSTALL_DIR" 2>&1 \
| ts_prefix > "$LOG_DIR/app-update.log") || rc=$?
log_group "app update (Playwright) transcript" "$LOG_DIR/app-update.log"
[ "$rc" -eq 0 ] || fail "app-driven update exited $rc; transcript above"
}
phase_update() {
# shellcheck disable=SC1090
. "$STATE"
arm_redirect
# Snapshot every plugin tree BEFORE the upgrade moves anything: fixtures
# seeded here must survive through the verify after the update lands.
preserve_before_upgrade
step "advancing served main to $TARGET_LABEL ($TARGET_SHA)"
git -C "$SERVE_REPO" update-ref refs/heads/main "$TARGET_SHA"
ok "serve.git main = $TARGET_SHA"
step "updating via $UPDATE_METHOD"
# The app must boot configured or the onboarding overlay (a fullscreen
# div) eats every click: configure the mock inference server exactly like
# the dev:mock flow does, so the app is genuinely configured.
# shellcheck source=../install/e2e-assets/mock-provider.sh
source "$ASSETS/mock-provider.sh"
PATH="$(dirname "$HERMES_E2E_NODE"):$PATH" mock_start "$WORK_ROOT"
trap mock_stop EXIT
case "$UPDATE_METHOD" in
hermes-update)
# The CLI route a dmg user takes from a terminal. Probe the installed
# help for both flags: this fixture stages unpublished main in serve.git,
# so newer updaters need explicit --branch main (not the channel object).
local hermes help
hermes="$(source_hermes "$INSTALL_DIR")" || fail "no installed update command"
help="$(source_build_env "$hermes" update --help 2>&1)" || fail "installed update --help failed: $help"
build_source_update_command "$hermes" "$help"
printf ' CLI update invocation:'
printf ' %q' "${update_cmd[@]}"
printf '\n'
local rc=0
(cd "$INSTALL_DIR" && source_build_env "${update_cmd[@]}" < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/update.log") || rc=$?
log_group "hermes update transcript" "$LOG_DIR/update.log"
[ "$rc" -eq 0 ] || fail "hermes update exited $rc; transcript above"
;;
installer-script)
# A dmg user re-running today's install one-liner.
source_build_env run_source_installer "$REPO_ROOT" "$WORK_ROOT" "$LOG_DIR" "$TARGET_SHA" head
;;
installer-script+desktop)
source_build_env run_source_installer "$REPO_ROOT" "$WORK_ROOT" "$LOG_DIR" "$TARGET_SHA" head desktop
# The desktop stage is this leg's claim: the rebuilt app must exist.
head_app=""
for cand in \
"$INSTALL_DIR/apps/desktop/release/mac-arm64/Hermes.app" \
"$INSTALL_DIR/apps/desktop/release/mac/Hermes.app"; do
[ -d "$cand" ] && { head_app="$cand"; break; }
done
[ -n "$head_app" ] || fail "no built Hermes.app under the checkout after the +desktop update"
ok "rebuilt app present: $head_app"
;;
open-app-update)
# The installed app IS the user surface here (double-click the .app);
# hand-build the spec Playwright launches from. Env: the redirect set,
# which is exactly what the app's children (git, hermes update) need.
local app app_bin
app="$(find_installed_app)" || fail "no installed app to launch"
app_bin="$(find "$app/Contents/MacOS" -type f -perm +111 | head -1)"
python3 - "$app_bin" "$WORK_ROOT/launch-spec.json" <<'PYEOF'
import json, os, sys
spec = {
"argv": [sys.argv[1]],
"cwd": os.path.dirname(sys.argv[1]),
"env": dict(os.environ),
"matchedShape": "packaged",
}
with open(sys.argv[2], "w") as fh:
json.dump(spec, fh, indent=2)
PYEOF
run_playwright_update "$WORK_ROOT/launch-spec.json"
;;
hermes-desktop-app-update)
# The product's own launch, captured at its spawn site.
local hermes
hermes="$(source_hermes "$INSTALL_DIR")" || fail "no installed desktop command"
local spec="$WORK_ROOT/launch-spec.json"
local rc=0
if [ "$hermes" = "$INSTALL_DIR/.hermes/bin/hermes" ]; then
# PM launchers use -I, which ignores PYTHONPATH/sitecustomize. Inject
# the capture hook into the installed launcher's isolated command.
(cd "$INSTALL_DIR" && source_build_env python3 -I "$ASSETS/launch-capture/pm-launch.py" \
"$hermes" "$spec" < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/desktop-launch-capture.log") || rc=$?
else
(cd "$INSTALL_DIR" && \
PYTHONPATH="$ASSETS/launch-capture${PYTHONPATH:+:$PYTHONPATH}" \
HERMES_E2E_CAPTURE_LAUNCH="$spec" \
source_build_env "$hermes" desktop < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/desktop-launch-capture.log") || rc=$?
fi
log_group "hermes desktop (launch capture) transcript" "$LOG_DIR/desktop-launch-capture.log"
[ "$rc" -eq 0 ] || fail "hermes desktop exited $rc during launch capture"
[ -f "$spec.captured" ] || fail "hermes desktop exited 0 but no launch was captured"
ok "captured $(cat "$spec.captured") launch spec"
run_playwright_update "$spec"
;;
esac
local got
got="$(git -C "$INSTALL_DIR" rev-parse HEAD)"
[ "$got" = "$TARGET_SHA" ] || fail "checkout is $got, expected $TARGET_LABEL ($TARGET_SHA)"
ok "checkout landed on $TARGET_LABEL ($TARGET_SHA)"
# Install-side state BEFORE the post-update smoke: on app-update legs the
# updater's own transcript is streamed into the app UI and otherwise lost,
# so snapshot every place it also lands (product logs, update hand-off
# files, the venv's entry-point dir) while the install is still there to
# inspect — the smoke assertion below can `fail` out of the driver, and the
# evidence must already be on disk when it does.
local ildest="$LOG_DIR/install-logs"
mkdir -p "$ildest"
cp -R "$HOME_SANDBOX/.hermes/logs" "$ildest/hermes-logs" 2>/dev/null || true
local ud="$HOME_SANDBOX/Library/Application Support/Hermes"
[ -d "$ud" ] && cp -R "$ud" "$ildest/desktop-userdata" 2>/dev/null || true
cp "$HERMES_HOME/.hermes-update-result.json" "$ildest" 2>/dev/null || true
ls -la "$HERMES_HOME" > "$ildest/hermes-home-ls.txt" 2>/dev/null || true
ls -la "$INSTALL_DIR/venv/bin" > "$ildest/venv-bin-ls.txt" 2>/dev/null || true
ls -la "$INSTALL_DIR/venv" > "$ildest/venv-ls.txt" 2>/dev/null || true
ok "collected install-side logs to $ildest"
# The update may publish a launcher before its installed dependency inputs
# are current. The next non-metadata startup then owns source completion,
# including rebuilding the packaged desktop app. Launching that app directly
# first lets its backend replace the live bundle and kills Playwright's
# renderer target. Always drive the ordinary CLI startup before inspecting or
# launching the app; launcher presence alone does not prove completion.
step "next ordinary startup after the update (completes deferred source-update work)"
local startup_hermes startup_rc=0
startup_hermes="$(source_hermes_for_startup "$INSTALL_DIR")" \
|| fail "no installed command to start after the update"
source_build_env "$startup_hermes" status > "$LOG_DIR/post-update-startup.log" 2>&1 || startup_rc=$?
log_group "post-update startup" "$LOG_DIR/post-update-startup.log"
ok "post-update startup ran (exit $startup_rc); the read-only checks below assert completion"
local command
command="$(source_hermes "$INSTALL_DIR")" || fail "no installed command after update"
python3 -B "$ASSETS/source_driver.py" --root "$INSTALL_DIR" --launcher "$command" --desktop present \
|| fail "read-only verification failed after update; no repair was attempted"
HERMES_DISABLE_LAZY_INSTALLS=1 PYTHONDONTWRITEBYTECODE=1 source_build_env "$command" --version 2>&1 | ts_prefix > "$LOG_DIR/version-head.log" \
|| fail "hermes --version failed after update"
ok "hermes --version works post-update"
preserve_after_upgrade
desktop_checkpoint new "$TARGET_SHA" "$UPDATE_METHOD"
step "PASS: $OLD_REF -> $TARGET_LABEL via $UPDATE_METHOD"
}
case "$PHASE" in
stage) phase_stage ;;
install) phase_install ;;
update) phase_update ;;
all) phase_stage; phase_install; phase_update ;;
*) echo "error: --phase must be stage, install, update or all" >&2; exit 1 ;;
esac