#!/usr/bin/env bash # aibox one-line install (bootstrap) # Usage: # curl -fsSL https://raw.githubusercontent.com/lichengwu/aibox/main/install.sh | bash # Env vars: # AIBOX_BRANCH (default main) branch to install from # AIBOX_BIN_DIR main CLI install dir (default: ~/.local/bin # when it is in PATH, else an in-PATH writable # system dir like /usr/local/bin, else ~/.local/bin) # AIBOX_SHA256 (optional) verify the downloaded bin/aibox against this checksum # AIBOX_VERIFY (default 0) if 1, fetch+check the release SHA256SUMS sidecar (graceful if absent) # AIBOX_GH_POOL (default shipped) GitHub-family download pool override ("direct" = no pool) # AIBOX_GH_MIRROR / CLASH_MIRROR user mirror, joins the pool as a candidate # Idempotent: safe to re-run; also used by `aibox self update`. set -euo pipefail REPO="lichengwu/aibox" BRANCH="${AIBOX_BRANCH:-main}" RAW="${AIBOX_RAW:-https://raw.githubusercontent.com/${REPO}/${BRANCH}}" # ---------- bin-dir selection ---------- # Precedence (spec §Install paths): # 1. explicit AIBOX_BIN_DIR — always wins # 2. `~/.local/bin` when it is ALREADY in PATH — no churn for existing setups # 3. an in-PATH writable system dir (the deploy-host/root case: `~/.local/bin` # is not in root's PATH — installing to /usr/local/bin makes the command # work IMMEDIATELY, zero shell setup; live-caught annoyance: " is not # in your PATH" right after the one-line install) # 4. `~/.local/bin` — last resort; the PATH block further down then persists # it to the shell rc files and prints the apply-now line # AIBOX_SYSTEM_BIN_DIRS overrides the system-dir candidates (space-separated). _in_path() { case ":${PATH}:" in *":$1:"*) return 0 ;; esac; return 1; } if [ -n "${AIBOX_BIN_DIR:-}" ]; then BIN_DIR="$AIBOX_BIN_DIR" elif _in_path "$HOME/.local/bin"; then BIN_DIR="$HOME/.local/bin" else BIN_DIR="" for _bd in ${AIBOX_SYSTEM_BIN_DIRS:-/usr/local/bin /opt/homebrew/bin}; do if _in_path "${_bd}" && [ -d "${_bd}" ] && [ -w "${_bd}" ]; then BIN_DIR="${_bd}" break fi done [ -n "${BIN_DIR}" ] || BIN_DIR="$HOME/.local/bin" fi HOME_DIR="${AIBOX_HOME:-$HOME/.aibox}" # Output helpers aligned with the manager's symbol system (bin/aibox): # log = plain; warn = ⚠ (stderr); die = ✗ (stderr, exit 1). Raw ANSI here (the # bootstrap runs before the CLI's color system exists; NO_COLOR honored). _C_RST="$( [ "${NO_COLOR:-}" = "1" ] && printf '' || printf '\033[0m' )" _C_DIM="$( [ "${NO_COLOR:-}" = "1" ] && printf '' || printf '\033[2m' )" _C_YEL="$( [ "${NO_COLOR:-}" = "1" ] && printf '' || printf '\033[33m' )" _C_RED="$( [ "${NO_COLOR:-}" = "1" ] && printf '' || printf '\033[31m' )" log() { printf '%s\n' "$*"; } warn() { printf '%s⚠%s %s\n' "${_C_YEL}" "${_C_RST}" "$*" >&2; } die() { printf '%s✗%s %s\n' "${_C_RED}" "${_C_RST}" "$*" >&2 exit 1 } log "Installing aibox from ${REPO}@${BRANCH} ..." command -v curl >/dev/null 2>&1 || die "curl is required (ships with macOS)" # Proxy: read the existing config so the bootstrap's first mile can go through the proxy # (on first install the config doesn't exist yet, so direct-only — expected). If the env # already specifies a proxy, don't override it. CONFIG="$HOME_DIR/config" if [ -f "$CONFIG" ]; then # shellcheck disable=SC1090 . "$CONFIG" 2>/dev/null || true fi if [ "${AIBOX_PROXY_ENABLED:-1}" = "1" ] && [ -n "${AIBOX_PROXY_URL:-}" ]; then if [ -n "${http_proxy:-}${https_proxy:-}${all_proxy:-}" ]; then : else export http_proxy="$AIBOX_PROXY_URL" https_proxy="$AIBOX_PROXY_URL" all_proxy="$AIBOX_PROXY_URL" export HTTP_PROXY="$AIBOX_PROXY_URL" HTTPS_PROXY="$AIBOX_PROXY_URL" _np="${AIBOX_NO_PROXY:-localhost,127.0.0.1,::1}" export no_proxy="$_np" NO_PROXY="$_np" _pd=$(printf '%s' "$AIBOX_PROXY_URL" | sed -E 's#(://[^:/@]+):[^@]*@#\1:***@#') log "Using proxy ${_pd}" fi fi # ---------- download source pool (GitHub family; bootstrap-local) ---------- # Same pattern as the manager's pool (bin/aibox, gh_pool_fetch) — inlined because # install.sh cannot source the manager (it downloads it). Live-verified candidates # (measured on a CN mac AND an Aliyun deploy host): gh-proxy.com proxies raw + # github releases; ghproxy.net raw; ghproxy.link / ghproxy.cn served CORRUPT # content (truncated / wrong size) — excluded. RACE: direct + mirrors fetch # concurrently, the FIRST success serves (a dead direct costs nothing). Non-GitHub # URLs (file://, a pinned AIBOX_RAW mirror base) fetch direct — the user pinned # their route. Checksum verification (AIBOX_SHA256 / AIBOX_VERIFY) still applies # to whatever source wins. fetch_pool() { # $1=url, $2=outfile → 0 on success local url="$1" out="$2" case "$url" in https://raw.githubusercontent.com/* | https://github.com/* | https://api.github.com/*) : ;; *) curl -fsSL --max-time 60 "$url" -o "$out" return $? ;; esac if [ "${AIBOX_GH_POOL:-}" = "direct" ]; then curl -fsSL --max-time 60 "$url" -o "$out" return $? fi local mirrors="" m tmpd i pid pids="" body="" deadline alive j pv v cu cu_urls m="${AIBOX_GH_MIRROR:-${CLASH_MIRROR:-}}" if [ -n "$m" ]; then mirrors="${m%/} "; fi mirrors="${mirrors}${AIBOX_GH_POOL:-https://gh-proxy.com https://ghproxy.net}" tmpd="$(mktemp -d "${TMPDIR:-/tmp}/aiboot.XXXXXX")" || return 1 # fire: d0 = direct, d1..dN = mirrors (concurrent; first success wins) ( curl -fsSL --max-time 30 "$url" -o "$tmpd/d0" 2>/dev/null && : >"$tmpd/d0.ok" ) & pids="$pids $!" i=0 # shellcheck disable=SC2086 for m in $mirrors; do i=$((i + 1)) ( curl -fsSL --max-time 30 "${m%/}/$url" -o "$tmpd/d$i" 2>/dev/null && : >"$tmpd/d$i.ok" ) & pids="$pids $!" done # poll until the first .ok appears, every worker exits, or the deadline hits deadline=$(( $(date +%s) + 30 )) while [ -z "$body" ]; do for ((j = 0; j <= i; j++)); do if [ -f "$tmpd/d$j.ok" ]; then body="$tmpd/d$j" break fi done if [ -n "$body" ]; then break; fi alive=0 # shellcheck disable=SC2086 for pid in $pids; do if kill -0 "$pid" 2>/dev/null; then alive=1; fi done if [ "$alive" = 0 ]; then break; fi if [ "$(date +%s)" -ge "$deadline" ]; then break; fi sleep 0.25 done # SIGTERM to workers FIRST, then orphaned curl children (the kill-order lesson). # The loop-level stderr redirect also silences bash's "Terminated: 15" job # notices (printed at reap time — cosmetic noise during the bootstrap). # shellcheck disable=SC2086 for pid in $pids; do kill "$pid" 2>/dev/null || true pkill -P "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true done 2>/dev/null if [ -n "$body" ] && [ -s "$body" ]; then cp "$body" "$out" rm -rf "$tmpd" return 0 fi # Dead-proxy diagnosis (twin of the manager's pool logic — bootstrap-local, # install.sh cannot source the manager): a shell-exported ALL_PROXY etc. # drags every worker above through it, so ONE dead local proxy fails the # whole pool while the network is fine. One control fetch with the proxy env # removed separates the two (live-caught on a deploy host: hysteria up, # tunnel dead — direct + both mirrors "failed" only behind it). The control # walks the SAME route set the workers raced: direct + the mirrors var. pv="" for v in all_proxy ALL_PROXY http_proxy https_proxy HTTPS_PROXY; do if printenv "${v}" >/dev/null 2>&1; then pv="${pv:+${pv} }${v}" fi done if [ -n "${pv}" ]; then cu_urls="${url}" # shellcheck disable=SC2086 for m in ${mirrors}; do cu_urls="${cu_urls} ${m%/}/${url}" done # shellcheck disable=SC2086 for cu in ${cu_urls}; do if ( unset all_proxy ALL_PROXY http_proxy https_proxy HTTPS_PROXY no_proxy NO_PROXY curl -fsSL --max-time 10 "${cu}" -o /dev/null 2>/dev/null ); then warn "every source failed BEHIND your shell proxy (${pv} set — each worker ran through it); unset it and retry" break fi done fi rm -rf "$tmpd" return 1 } mkdir -p "$BIN_DIR" "$HOME_DIR" # Download to a TEMP file in $BIN_DIR (same filesystem) and atomically mv into place. # NEVER overwrite a running bin/aibox in-place: the old bash process holds an open fd at a # byte offset into the file; truncating the same inode (curl -o) makes it execute fragments # of the NEW file as garbage when it next reads (observed during self-update: # "line 1442: ugh: command not found" — a shard of e.g. "thro*ugh*"). rename(2) leaves the # running process on the old inode, safely. # First install or re-install/self-update? The tail copy differs — an update # used to end with "Install your first module" (nonsense on a host that has # modules already; live-caught on the deploy host). _prev_ver="" if [ -x "$BIN_DIR/aibox" ]; then # `|| true`: the previous binary may be un-runnable (truncated/corrupt) and # install.sh runs under pipefail — a bare capture would abort the re-install # with no message (caught by tests/install-sh.bats, the wrong-pin case). _prev_ver="$("$BIN_DIR/aibox" version 2>/dev/null | awk '{print $2}' || true)" if [ "$_prev_ver" = "unknown" ] || [ "$_prev_ver" = "aibox" ]; then _prev_ver=""; fi fi _TMP_BIN="$(mktemp "$BIN_DIR/.aibox.download.XXXXXX")" trap 'rm -f "$_TMP_BIN"' EXIT log "Downloading bin/aibox -> $BIN_DIR/aibox" fetch_pool "$RAW/bin/aibox" "$_TMP_BIN" || die "Download failed: $RAW/bin/aibox (source pool tried: direct + mirrors; pin AIBOX_RAW, or AIBOX_GH_POOL=direct to bypass)" # ---------- checksum verification (defense in depth) ---------- # Two modes, both optional and graceful: # AIBOX_SHA256= — pin: the downloaded file MUST match exactly. # AIBOX_VERIFY=1 — best-effort: fetch the release SHA256SUMS sidecar and check it; # older releases without the sidecar just warn and proceed. # NOTE: this verifies the payload bin/aibox, NOT install.sh itself — a curl|bash MITM # can serve a malicious install.sh that skips the check. Inherent to curl|bash; for # full assurance pin AIBOX_SHA256 from a trusted channel or use AIBOX_RAW=file://. # Verified BEFORE the file ever lands at the final path; on failure the temp is removed # (trap) and the existing install is left untouched. # When AIBOX_VERIFY=1 fetches from releases/latest but AIBOX_RAW points at raw `main` # (ahead of the latest release), a mismatch may occur — reliable right after a release. verify_sha256() { local file="$1" want="${2:-}" got if ! command -v shasum >/dev/null 2>&1 && ! command -v sha256sum >/dev/null 2>&1; then warn "shasum/sha256sum not available; cannot verify checksum" return 1 fi if command -v shasum >/dev/null 2>&1; then got=$(shasum -a 256 "$file" | awk '{print $1}') else got=$(sha256sum "$file" | awk '{print $1}') fi [ -n "$got" ] || { warn "checksum computation failed" return 1 } if [ -n "$want" ]; then if [ "$got" = "$want" ]; then log "Checksum OK (pinned, ${got})" return 0 fi die "Checksum mismatch: expected ${want}, got ${got}" fi printf '%s' "$got" } if [ -n "${AIBOX_SHA256:-}" ]; then verify_sha256 "$_TMP_BIN" "$AIBOX_SHA256" || die "Checksum verification failed" elif [ "${AIBOX_VERIFY:-0}" = "1" ]; then # Best-effort: fetch the release's SHA256SUMS sidecar and check bin/aibox against it. _sums_url="https://github.com/${REPO}/releases/latest/download/SHA256SUMS" _sums_tmp="$(mktemp 2>/dev/null || echo "/tmp/aibox-sums.$$")" if fetch_pool "$_sums_url" "$_sums_tmp" 2>/dev/null; then _want=$(awk '$2=="bin/aibox"{print $1}' "$_sums_tmp" 2>/dev/null) if [ -n "$_want" ]; then verify_sha256 "$_TMP_BIN" "$_want" || { rm -f "$_sums_tmp" die "Checksum verification failed" } else warn "SHA256SUMS found but no bin/aibox entry; skipping verification" fi else warn "No SHA256SUMS sidecar at latest release; skipping verification (set AIBOX_SHA256 to pin)" fi rm -f "$_sums_tmp" fi chmod 0755 "$_TMP_BIN" mv -f "$_TMP_BIN" "$BIN_DIR/aibox" trap - EXIT # PATH check & auto-write if _in_path "$BIN_DIR"; then log "$BIN_DIR already in PATH — aibox is immediately available" else warn "$BIN_DIR is not in your PATH" # Persist to the shell rc files (the same marked "# aibox" block that # `aibox purge self` strips). bash users need BOTH: ~/.bashrc is read by # interactive shells, ~/.profile by LOGIN shells (the root/deploy-host case — # Debian's root login never reads ~/.bashrc). One block per file, idempotent. shell_rc="" case "${SHELL##*/}" in zsh) shell_rc="$HOME/.zshrc" ;; bash) shell_rc="$HOME/.bashrc $HOME/.profile" ;; *) shell_rc="$HOME/.profile" ;; esac written="" already="" for rc in ${shell_rc}; do if [ -f "$rc" ] && grep -qF "$BIN_DIR" "$rc"; then already="${already}${already:+ }${rc}" else printf '\n# aibox\nexport PATH="%s:$PATH"\n' "$BIN_DIR" >>"$rc" written="${written}${written:+ }${rc}" fi done [ -n "${written}" ] && log "Appended PATH to: ${written} (new shells pick it up)" [ -n "${already}" ] && log "Already in: ${already}" # Immediate effect can't cross the process boundary (the one-liner runs this # script in a CHILD shell — the parent's PATH is untouchable); print the # one-line apply for THIS shell instead. log "apply now: export PATH=\"${BIN_DIR}:\$PATH\" (or: exec \$SHELL -l)" fi _new_ver="$("$BIN_DIR/aibox" version 2>/dev/null | awk '{print $2}' || true)" if [ -n "$_prev_ver" ]; then if [ -n "$_new_ver" ] && [ "$_prev_ver" != "$_new_ver" ]; then log "Done. aibox updated: ${_prev_ver} → ${_new_ver}" else log "Done. aibox re-installed (${_new_ver:-${_prev_ver}}) — already up to date" fi log "Next: aibox dashboard · refresh modules: aibox update --all" else log "Done. Now run: aibox help" log "Install your first module: aibox install pi-web" fi