#!/bin/sh # puq install script — downloads and installs the binary from the puq-ai/code release feed, # verifying the SHA-256 against the release manifest. Places the binary under # ~/.local/share/puq/versions/ and points ~/.local/bin/puq at it — the same layout # `puq update` and the background updater manage (packages/coding-agent/src/cli/update-cli.ts), # so a curl install and a later self-update interoperate without migration. A prior plain-file # install (e.g. a hand-copied binary) is migrated into this layout automatically the next time # it self-updates. # # curl -fsSL https://github.com/puq-ai/code/releases/download/channels/install.sh | sh # curl -fsSL .../channels/install.sh | sh -s -- latest # curl -fsSL .../channels/install.sh | sh -s -- 1.2.3 # ./install.sh latest # # Output: one "[n/7] ✓ …" line per finished stage (platform, version, manifest, download, SHA-256, # install, smoke test); a failure names the stage it stopped at. When stderr is a terminal (and # TERM isn't "dumb"), the running stage is animated in place on stderr — a spinner, and for the # binary download a bar with percent, MB and current speed measured against the manifest's byte # size, so curl and wget look the same. Anything else (CI logs, `2>file`) gets only the # finished-stage lines: no \r frames end up in log files. # # Environment variables: # PUQ_CODE_INSTALL_DIR launcher directory (default: ~/.local/bin) # PUQ_CODE_CODING_AGENT_DIR settings directory (default: ~/.puq/agent) # PUQ_CODE_RELEASES_URL feed base URL — for test/mirror purposes only # (default: https://github.com/puq-ai/code/releases/download) set -eu BASE="${PUQ_CODE_RELEASES_URL:-https://github.com/puq-ai/code/releases/download}" BASE="${BASE%/}" INSTALL_DIR="${PUQ_CODE_INSTALL_DIR:-$HOME/.local/bin}" AGENT_DIR="${PUQ_CODE_CODING_AGENT_DIR:-$HOME/.puq/agent}" VERSIONS_DIR="$HOME/.local/share/puq/versions" REQUEST="stable" STEPS=7 usage() { cat <<'EOF' Usage: install.sh [stable|latest|X.Y.Z] [--help] stable (default) Install the stable channel (trails latest by roughly a week) latest Install the latest channel X.Y.Z Install a specific version --help Show this help Environment variables: PUQ_CODE_INSTALL_DIR, PUQ_CODE_CODING_AGENT_DIR, PUQ_CODE_RELEASES_URL EOF } EXPLICIT_CHANNEL="" case "${1:-}" in --help | -h) usage exit 0 ;; "") ;; latest | stable) REQUEST="$1" EXPLICIT_CHANNEL="$1" ;; *) REQUEST="$1" ;; esac # --- progress output ------------------------------------------------------------ step_no=0 step_label="" spin=0 frame_text="" bg_pid="" live_err="" tmp="" link="" # Live frames go to fd 3 (a copy of the terminal stderr), so a caller's `2>&1` capture — the # smoke test reads `puq --version 2>&1` — never swallows them. live=0 if [ -t 2 ] && [ "${TERM:-dumb}" != "dumb" ]; then live=1 exec 3>&2 fi clear_line() { if [ "$live" = 1 ]; then printf '\r\033[K' >&3 fi } fail() { clear_line if [ "$step_no" -gt 0 ]; then echo "✗ [$step_no/$STEPS] $1" >&2 else echo "✗ $1" >&2 fi exit 1 } # `rm -f` stays the single unconditional last command, never a guarded `[ -n "$x" ] && rm -f "$x"`: # if that guard is false (nothing to clean up) it's *also* the trap's own exit status, and /bin/sh # replaces a successful script's exit code with it — a real install would report failure. `rm -f` # is always safe (empty string or an already-moved-away path is a no-op). The background download # ignores SIGINT (POSIX: async lists in a non-interactive shell do), so it is killed explicitly. trap 'clear_line; if [ -n "$bg_pid" ]; then kill "$bg_pid" 2>/dev/null || :; fi; rm -f "$tmp" "$link" "$live_err"' EXIT # dash dies from an untrapped signal without running the EXIT trap; route Ctrl-C/TERM through # `exit` so the partial download is still killed and removed. trap 'clear_line; echo "✗ interrupted" >&2; exit 130' INT trap 'exit 143' TERM HUP if [ "$live" = 1 ]; then # run_live replays a backgrounded command's stderr from here once its frame is cleared. live_err=$(mktemp "${TMPDIR:-/tmp}/puq-code-install.XXXXXX" 2>/dev/null) || live=0 fi if [ "$live" = 1 ]; then if sleep 0.1 2>/dev/null; then tick=0.1; else tick=1; fi # Keep the download frame on one row: a wrapped row can't be erased with \r. cols=$(stty size <&3 2>/dev/null | awk '{print $2}') || cols="" case "$cols" in "" | 0 | *[!0-9]*) cols=80 ;; esac bar_width=$((cols - 60)) if [ "$bar_width" -gt 30 ]; then bar_width=30; fi if [ "$bar_width" -lt 10 ]; then bar_width=10; fi fi # frame — redraws the current stage row with the next spinner glyph. frame() { case $((spin % 10)) in 0) g='⠋' ;; 1) g='⠙' ;; 2) g='⠹' ;; 3) g='⠸' ;; 4) g='⠼' ;; 5) g='⠴' ;; 6) g='⠦' ;; 7) g='⠧' ;; 8) g='⠇' ;; *) g='⠏' ;; esac spin=$((spin + 1)) printf '\r\033[K[%d/%d] %s %s' "$step_no" "$STEPS" "$g" "$1" >&3 } # step