fix(install): make install.sh actually zero-friction on a fresh laptop (#444)

PR A of the post-cluster install-hardening pair. A multi-agent audit (4 install paths × 2 agents each) showed the README's "copy this one-liner and chat" claim broke on every fresh laptop because of missing git, missing Python 3.11, a racing Ollama daemon, and silently-swallowed model-pull failures. This PR closes the macOS / Linux / WSL2 gaps.

Changes in scripts/install/install.sh:

1. Auto-install missing tools (was: hard refuse):
   - macOS: xcode-select --install + 10-min poll. Refuses fast under SSH (no display for the dialog).
   - Linux: detects apt-get / dnf / yum / pacman / zypper / apk. Pre-checks `sudo -n true` and refuses fast with actionable guidance if sudo would prompt (stdin is the curl pipe — any prompt silently hangs under `set -euo pipefail`). Uses `;` not `&&` between apt-get update and install. Skips sudo entirely if already root.

2. Auto-install Python 3.11 via uv when missing. Captures real errors to $STATE_DIR/venv-create.err so disk-full / permission-denied isn't hidden behind "downloading managed Python".

3. Replace `sleep 1` after `ollama serve` with a 60-second poll on `ollama list`. Caller guards with `|| true` so timeout surfaces as a warning in the final banner instead of aborting under `set -e`.

4. Track MODEL_PULL_OK + PATH_MODIFIED. The completion banner now tells the truth: if the model pull failed, point at `jarvis doctor` (not bare `jarvis` which would crash). If PATH was just written to ~/.bashrc / ~/.zshrc, print the exact `source <rc> && jarvis` so it works in the same shell.

Adversarial review caught 5 real bugs before commit:
- SSH/headless macOS xcode-select hang → pre-detect SSH session
- sudo no-TTY silent abort → `sudo -n true` precheck
- wait_for_ollama timeout tripping ERR trap → `|| true` at call sites
- swallowed venv stderr hiding diagnostics → capture to log + surface on fallback failure
- contradictory "PATH new + model missing" banner → conditional NEXT_CMD

All 26 install bats tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-05-29 20:06:15 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d8a8cd8df7
commit 0591a80448
+225 -16
View File
@@ -73,20 +73,127 @@ EOF
fi
# ---- prereq probe ----
#
# `git` and `curl` are the only host tools we require. On the supported
# platforms we can auto-install both; if that fails we fall back to a
# clear "here's the exact command" error rather than a generic refusal.
need() {
if ! command -v "$1" >/dev/null 2>&1; then
cat >&2 <<EOF
install.sh: '$1' is required but not found.
if command -v "$1" >/dev/null 2>&1; then
return 0
fi
case "$(uname -s)" in
Darwin) bootstrap_macos_tool "$1" ;;
Linux) bootstrap_linux_tool "$1" ;;
*) fail_missing_tool "$1" ;;
esac
}
Install hints:
macOS: xcode-select --install (provides git, curl)
Debian/Ubuntu: sudo apt install git curl
Fedora/RHEL: sudo dnf install git curl
Arch: sudo pacman -S git curl
bootstrap_macos_tool() {
local tool="$1"
# On macOS, git AND curl ship as part of the Xcode Command Line Tools.
# `xcode-select --install` opens a system dialog — useless in a
# headless SSH session, so refuse fast there rather than polling for
# 20 minutes against something that will never arrive.
if [[ -z "${SSH_TTY:-}${SSH_CONNECTION:-}" ]]; then
: # local GUI session — proceed
else
cat >&2 <<EOF
install.sh: '$tool' not found, and this looks like a headless SSH session
(SSH_CONNECTION is set). The xcode-select GUI installer can't run here.
Install '$tool' over SSH with one of:
- From a GUI login: 'xcode-select --install' (then re-run this script)
- Or: a third-party manager — Homebrew / MacPorts / nix
Re-run this script once '$tool' is on PATH.
EOF
exit 1
fi
echo "install.sh: '$tool' not found — installing Xcode Command Line Tools (provides git + curl)..."
echo " A system dialog will open. Click 'Install' and accept the license."
xcode-select --install 2>/dev/null || true
local waited=0
while ! command -v "$tool" >/dev/null 2>&1; do
if (( waited >= 600 )); then # 10 minutes
echo "install.sh: timed out waiting for Xcode Command Line Tools."
echo " Finish the install via the dialog, then re-run this command."
exit 1
fi
sleep 5
waited=$((waited + 5))
if (( waited % 60 == 0 )); then
echo " …still waiting for '$tool' to appear (${waited}s)"
fi
done
echo " '$tool' installed."
}
bootstrap_linux_tool() {
local tool="$1"
# We need passwordless sudo (or already-root) — stdin is the curl
# pipe, so an interactive password prompt will either hang or fail
# silently under `set -euo pipefail`. Check up-front and fail with a
# clear message rather than a confusing hang.
if [[ "$(id -u)" -ne 0 ]] && ! sudo -n true 2>/dev/null; then
cat >&2 <<EOF
install.sh: '$tool' is not installed, and we need sudo to install it via
the system package manager — but sudo would prompt for a password and
stdin is occupied by the curl pipe.
Two ways forward:
1. Install '$tool' yourself, then re-run this installer:
Debian/Ubuntu: sudo apt install -y $tool
Fedora/RHEL: sudo dnf install -y $tool
Arch: sudo pacman -S $tool
2. Pre-authenticate sudo before piping (caches credentials for 5 min):
sudo -v && curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
EOF
exit 1
fi
local sudo=""
[[ "$(id -u)" -ne 0 ]] && sudo="sudo"
echo "install.sh: '$tool' not found — installing via the system package manager..."
# Use `;` not `&&` between update and install so a transient apt
# update failure (mirror flake, expired cache) doesn't block install
# from a still-usable local index.
if command -v apt-get >/dev/null 2>&1; then
$sudo apt-get update -q || true
$sudo apt-get install -y "$tool"
elif command -v dnf >/dev/null 2>&1; then
$sudo dnf install -y "$tool"
elif command -v yum >/dev/null 2>&1; then
$sudo yum install -y "$tool"
elif command -v pacman >/dev/null 2>&1; then
$sudo pacman -S --noconfirm "$tool"
elif command -v zypper >/dev/null 2>&1; then
$sudo zypper install -y "$tool"
elif command -v apk >/dev/null 2>&1; then
$sudo apk add --no-cache "$tool"
else
fail_missing_tool "$tool"
fi
if ! command -v "$tool" >/dev/null 2>&1; then
fail_missing_tool "$tool"
fi
}
fail_missing_tool() {
cat >&2 <<EOF
install.sh: '$1' is required but not found and we couldn't install it automatically.
Install hints:
macOS: xcode-select --install (provides git, curl)
Debian/Ubuntu: sudo apt install $1
Fedora/RHEL: sudo dnf install $1
Arch: sudo pacman -S $1
Re-run this script once '$1' is on PATH.
EOF
exit 1
}
need git
need curl
@@ -318,7 +425,26 @@ copy_scripts() {
}
create_venv() {
uv venv --python 3.11 "$VENV_DIR"
# `uv venv --python 3.11` fails on hosts that don't have Python 3.11
# installed at all (fresh macOS without xcode-select, fresh Ubuntu
# 24.04 which only ships 3.12, etc). uv can bootstrap its own
# interpreter — use that path so the user never has to pre-install
# Python.
#
# We capture stderr to a log file instead of /dev/null so a genuine
# failure (disk full, broken venv dir, permission denied) is still
# surfaceable when the bootstrap fallback ultimately fails.
local err_log="$STATE_DIR/venv-create.err"
if ! uv venv --python 3.11 "$VENV_DIR" 2>"$err_log"; then
echo " No system Python 3.11 — uv will download a managed one..."
uv python install 3.11
if ! uv venv --python 3.11 "$VENV_DIR"; then
echo " venv creation failed. First attempt's stderr:"
sed 's/^/ /' "$err_log" >&2
return 1
fi
fi
rm -f "$err_log"
}
editable_install() {
@@ -337,23 +463,54 @@ install_ollama() {
start_ollama() {
if pgrep -f "ollama serve" >/dev/null 2>&1; then
echo " ollama serve already running"
wait_for_ollama || true
return 0
fi
if [[ "$WSL" -eq 1 ]] || ! command -v systemctl >/dev/null 2>&1; then
nohup ollama serve > "$STATE_DIR/ollama.log" 2>&1 &
sleep 1
else
systemctl --user start ollama 2>/dev/null \
|| (nohup ollama serve > "$STATE_DIR/ollama.log" 2>&1 & sleep 1)
|| nohup ollama serve > "$STATE_DIR/ollama.log" 2>&1 &
fi
# `|| true` is load-bearing — wait_for_ollama returns 1 on timeout
# and we're under `set -euo pipefail` via the `step` wrapper. We want
# the warning to surface in the final banner, not abort the install.
wait_for_ollama || true
}
# Poll `ollama list` until the daemon responds, up to 60 seconds. Replaces
# the old fixed `sleep 1` which raced the model pull on slower hosts.
# Cold-start latency on low-spec ARM boards or under heavy load can run
# past 30s; 60 is the conservative ceiling. Returns 1 on timeout — caller
# MUST guard with `|| true` (see start_ollama).
wait_for_ollama() {
local waited=0
while ! ollama list >/dev/null 2>&1; do
if (( waited >= 60 )); then
echo " warning: ollama daemon not responding after 60s — check $STATE_DIR/ollama.log"
return 1
fi
sleep 1
waited=$((waited + 1))
done
}
# Tracks whether the foreground model pull actually succeeded. If it
# didn't, the final completion banner needs to say so loudly rather than
# claiming chat is ready.
MODEL_PULL_OK=0
pull_default_model() {
if [[ "$MINIMAL" -eq 1 ]]; then
echo " --minimal set; skipping model pull"
MODEL_PULL_OK=1 # nothing to pull → not a failure
return 0
fi
ollama pull qwen3.5:2b || echo " warning: ollama pull failed; bg-orchestrator will retry"
if ollama pull qwen3.5:2b; then
MODEL_PULL_OK=1
else
echo " warning: ollama pull failed; bg-orchestrator will retry in the background"
fi
}
write_config() {
@@ -368,6 +525,11 @@ install_symlinks() {
ln -sf "$SCRIPTS_DIR/jarvis-uninstall.sh" "$HOME/.local/bin/jarvis-uninstall"
}
# Tracks whether the user needs to source ~/.bashrc / ~/.zshrc / open a
# new terminal before `jarvis` will resolve. Set only when ensure_path
# actually modified the user's rc file.
PATH_MODIFIED=0
ensure_path() {
case ":$PATH:" in
*":$HOME/.local/bin:"*) return 0 ;;
@@ -379,6 +541,9 @@ ensure_path() {
rc="$HOME/.bashrc"
fi
if grep -q "OpenJarvis" "$rc" 2>/dev/null; then
# rc already has our PATH line from a prior install; just remind.
PATH_MODIFIED=1
PATH_MODIFIED_RC="$rc"
return 0
fi
{
@@ -386,7 +551,8 @@ ensure_path() {
echo '# OpenJarvis'
echo 'export PATH="$HOME/.local/bin:$PATH"'
} >> "$rc"
echo " Added ~/.local/bin to PATH in $rc — run: source $rc"
PATH_MODIFIED=1
PATH_MODIFIED_RC="$rc"
}
detach_bg_orchestrator() {
@@ -443,10 +609,53 @@ beacon "install_completed" "" "$INSTALL_TOTAL_MS"
# Clear ERR trap — we succeeded; any later non-zero exit shouldn't beacon a failure.
trap - ERR
echo
echo "Done."
echo
# Tell the truth about what the user has to do next, given (a) whether
# the foreground model pull actually succeeded and (b) whether the PATH
# update needs a shell refresh. The four combinations:
#
# PATH ok + model ok -> "type jarvis"
# PATH new + model ok -> "source rc && jarvis (or open new terminal)"
# PATH ok + model bad -> "model still downloading; jarvis doctor"
# PATH new + model bad -> "source rc && jarvis doctor; chat works once download finishes"
#
# `jarvis` and `jarvis doctor` need PATH equally, so the source/restart
# guidance goes first when PATH was modified.
NEXT_CMD="jarvis"
if [[ "$MODEL_PULL_OK" -ne 1 ]]; then
NEXT_CMD="jarvis doctor"
fi
if [[ "$PATH_MODIFIED" -eq 1 ]]; then
cat <<EOF
A PATH update was written to ${PATH_MODIFIED_RC:-your shell rc file}. To
pick it up in THIS terminal:
source ${PATH_MODIFIED_RC:-~/.bashrc} && $NEXT_CMD
Or open a new terminal and run: $NEXT_CMD
EOF
else
cat <<EOF
Run: $NEXT_CMD
EOF
fi
if [[ "$MODEL_PULL_OK" -ne 1 ]]; then
cat <<EOF
NOTE: the qwen3.5:2b model didn't finish downloading. 'jarvis doctor'
shows the retry progress; chat will work once the download completes
in the background.
EOF
fi
cat <<EOF
Done. Type 'jarvis' to start chatting.
Background work continues silently:
- Rust toolchain + maturin extension build
- Bigger model downloads