#!/usr/bin/env bash # live — one-command installer. # # curl -fsSL https://raw.githubusercontent.com/liveshortly/live-dist/main/install.sh | bash # # Downloads a prebuilt, checksum-verified binary for your platform, puts it on # your PATH. No Go toolchain, no source checkout, no build. # Idempotent — rerun it any time to update. # # Everything lives inside main(), invoked on the last line. A truncated download # therefore runs nothing at all, rather than half an installer. # Piped through plain `sh` on some systems? Everything below assumes bash. if [ -z "${BASH_VERSION:-}" ]; then echo "[live-install] please pipe to bash: curl -fsSL https://raw.githubusercontent.com/liveshortly/live-dist/main/install.sh | bash" >&2 exit 1 fi set -euo pipefail # GitHub hosts release assets and the latest-version pointer. An operator may # explicitly opt into a flat mirror with LIVE_INSTALL_BASE. GH_BASE="${LIVE_GH_BASE:-https://github.com/liveshortly/live-dist/releases/download}" LATEST_URL="${LIVE_LATEST_URL:-https://github.com/liveshortly/live-dist/releases/latest/download/latest.txt}" MIRROR_BASE="${LIVE_INSTALL_BASE:-}" say() { printf '\033[32m[live-install]\033[0m %s\n' "$*"; } warn() { printf '\033[33m[live-install]\033[0m %s\n' "$*" >&2; } fail() { printf '\033[31m[live-install]\033[0m %s\n' "$*" >&2; exit 1; } # interactive — is there a human at a terminal we can prompt? # # NOT `[ -t 0 ]`. Under the documented install (`curl … | bash`) stdin IS the # script pipe, so a stdin test is false for every user who follows the README — # which silently skipped both the /usr/local/bin install and the sign-in, and # left them to "open a new terminal, then run live login". /dev/tty is the # controlling terminal regardless of what stdin is piped from, and it is # genuinely absent in CI/cron, which is exactly when we must not prompt. # # It has to OPEN /dev/tty, not test it: the device node exists with mode rw for # everyone even in a session that has no controlling terminal, so `[ -r /dev/tty ]` # is true in CI and would hang the install on a device flow nobody can approve. # Only the open fails (ENXIO). # Redirection order matters: stderr must be silenced BEFORE the /dev/tty open, # or bash reports "Device not configured" on the real stderr as it processes # the redirections left to right. interactive() { : 2>/dev/null < /dev/tty; } # fetch [dest] — dest of "-" (default) means stdout. fetch() { local url="$1" dest="${2:--}" if command -v curl >/dev/null 2>&1; then if [ "$dest" = "-" ]; then curl -fsSL --retry 3 "$url" else curl -fsSL --retry 3 -o "$dest" "$url"; fi elif command -v wget >/dev/null 2>&1; then if [ "$dest" = "-" ]; then wget -qO- "$url" else wget -qO "$dest" "$url"; fi else fail "need curl or wget to download." fi } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else fail "need sha256sum or shasum to verify the download."; fi } # Global, not local to main(): the EXIT trap fires after main() returns, by # which point a `local` would be out of scope (and unbound under `set -u`). LIVE_TMP="" trap 'rm -rf "${LIVE_TMP:-}"' EXIT main() { local REQ_VERSION="${LIVE_VERSION:-}" local BIN_DIR="${LIVE_BIN_DIR:-}" # --no-login remains accepted for scripts written before self-hosting. local DO_LOGIN="" local NO_MODIFY_PATH="${LIVE_NO_MODIFY_PATH:-}" local FROM_SOURCE="${LIVE_FROM_SOURCE:-}" while [ $# -gt 0 ]; do case "$1" in --version) REQ_VERSION="${2:-}"; shift 2 ;; --bin-dir) BIN_DIR="${2:-}"; shift 2 ;; --no-login) DO_LOGIN=""; shift ;; --login) DO_LOGIN=1; shift ;; --no-modify-path) NO_MODIFY_PATH=1; shift ;; --from-source) FROM_SOURCE=1; shift ;; *) fail "unknown flag: $1" ;; esac done [ -n "$REQ_VERSION" ] && REQ_VERSION="v${REQ_VERSION#v}" if [ -n "$FROM_SOURCE" ]; then build_from_source "$REQ_VERSION" return fi # ── 1. Detect platform ────────────────────────────────────────────────────── local os arch case "$(uname -s)" in Darwin) os=darwin ;; Linux) os=linux ;; *) fail "unsupported OS: $(uname -s). Supported: macOS, Linux." ;; esac case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) fail "unsupported architecture: $(uname -m). Supported: amd64, arm64." ;; esac # Prefer the native build when this shell is running under Rosetta. if [ "$os" = darwin ] && [ "$arch" = amd64 ] \ && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then arch=arm64 say "Rosetta detected — installing the native arm64 build" fi # ── 2. Resolve the version ────────────────────────────────────────────────── local version="$REQ_VERSION" if [ -z "$version" ]; then local pointer="$LATEST_URL" [ -z "$MIRROR_BASE" ] || pointer="$MIRROR_BASE/latest.txt" version="$(fetch "$pointer" - 2>/dev/null | tr -d '[:space:]')" \ || fail "could not resolve the latest version — check your network." [ -n "$version" ] || fail "empty version pointer at $pointer" fi version="v${version#v}" [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid release version: $version" # ── 3. Download + verify ──────────────────────────────────────────────────── local asset="live-$version-$os-$arch" local tmp; tmp="$(mktemp -d)"; LIVE_TMP="$tmp" say "downloading live $version ($os/$arch)" local ok="" local sources=("$GH_BASE/$version") [ -z "$MIRROR_BASE" ] || sources=("$MIRROR_BASE" "${sources[@]}") for base in "${sources[@]}"; do if fetch "$base/$asset.sha256" "$tmp/sum" 2>/dev/null \ && fetch "$base/$asset.gz" "$tmp/$asset.gz" 2>/dev/null; then ok=1; break fi warn "not available at $base — trying the next source" done [ -n "$ok" ] || fail "live $version is not published for $os/$arch. See https://github.com/liveshortly/live-dist/releases for published versions." gzip -dc "$tmp/$asset.gz" > "$tmp/live" || fail "corrupt archive — aborting." local want got want="$(awk '{print $1}' "$tmp/sum")" got="$(sha256_of "$tmp/live")" [ "$got" = "$want" ] || fail "checksum mismatch (got $got, want $want) — aborting." chmod 0755 "$tmp/live" # The staged binary must actually run and report the version we asked for. local reported reported="$("$tmp/live" version 2>/dev/null || true)" case "$reported" in *"${version#v}"*) ;; *) fail "staged binary failed verification (reported: ${reported:-nothing}) — aborting." ;; esac # ── 4. Install onto PATH ──────────────────────────────────────────────────── # Upgrade in place when live is already installed, so we never leave a stale # copy shadowing the new one on PATH. if [ -z "$BIN_DIR" ]; then local existing; existing="$(command -v live 2>/dev/null || true)" if [ -n "$existing" ]; then BIN_DIR="$(dirname "$(readlink "$existing" 2>/dev/null || echo "$existing")")" elif [ -w /usr/local/bin ] || { interactive && command -v sudo >/dev/null 2>&1; }; then BIN_DIR=/usr/local/bin else BIN_DIR="$HOME/.local/bin" fi fi mkdir -p "$BIN_DIR" 2>/dev/null || true # Stage inside the destination so the final move is an atomic rename: a # running `live` is never truncated, and a failure never leaves a partial # binary on PATH. if [ -w "$BIN_DIR" ]; then mv "$tmp/live" "$BIN_DIR/.live.tmp.$$" mv "$BIN_DIR/.live.tmp.$$" "$BIN_DIR/live" else say "installing to $BIN_DIR (needs sudo)" sudo install -m 0755 "$tmp/live" "$BIN_DIR/live" fi say "installed → $BIN_DIR/live" if [ -z "$NO_MODIFY_PATH" ]; then case ":$PATH:" in *":$BIN_DIR:"*) ;; *) local rc="$HOME/.profile" case "${SHELL:-}" in */zsh) rc="$HOME/.zshrc" ;; */bash) rc="$HOME/.bashrc" ;; esac if ! grep -qs "$BIN_DIR" "$rc" 2>/dev/null; then printf '\nexport PATH="%s:$PATH"\n' "$BIN_DIR" >> "$rc" say "added $BIN_DIR to PATH in $rc (open a new terminal to pick it up)" fi export PATH="$BIN_DIR:$PATH" ;; esac fi # tmux is required for `live codex` (it drives codex's composer) and powers # the clean in-terminal permission popups for `live claude`. command -v tmux >/dev/null 2>&1 \ || say "tip: install tmux — required for 'live codex', and it gives 'live claude' clean permission popups." # Installation never contacts a session server unless explicitly requested. if [ -n "$DO_LOGIN" ]; then say "signing in" if interactive; then "$BIN_DIR/live" login < /dev/tty else "$BIN_DIR/live" login; fi fi say "done. Next: live login (signs in to https://liveshortly.com), then: live claude" say "Self-hosting? Point ~/.liveshortly/host.json at your server first: https://github.com/liveshortly/live#choose-your-server" } # Build from the public source checkout (requires Git and Go). build_from_source() { local req="$1" command -v git >/dev/null 2>&1 || fail "git is required — install it and rerun." command -v go >/dev/null 2>&1 || fail "Go is required to build from source (https://go.dev/dl/)." local src="${LIVE_SRC_DIR:-$HOME/.liveshortly/src/live}" local script_dir; script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)" if [ -n "$script_dir" ] && grep -qs '^module github.com/liveshortly/live$' "$script_dir/go.mod" 2>/dev/null; then src="$script_dir" say "using existing checkout: $src" elif [ -d "$src/.git" ]; then say "updating $src" git -C "$src" fetch --tags --quiet 2>/dev/null || true if [ -z "$req" ]; then git -C "$src" pull --ff-only --quiet 2>/dev/null || true; fi else mkdir -p "$(dirname "$src")" say "cloning liveshortly/live → $src" git clone --quiet https://github.com/liveshortly/live.git "$src" \ || fail "clone failed — check repository availability and your network." fi [ -n "$req" ] && git -C "$src" checkout --quiet "$req" local v; v="$(git -C "$src" describe --tags --always --dirty 2>/dev/null || echo dev)" say "building live $v" (cd "$src" && GOTOOLCHAIN=auto go build -trimpath -ldflags "-s -w -X main.version=${v#v}" -o bin/live ./cmd/live) local bin_dir="${LIVE_BIN_DIR:-$HOME/.local/bin}" mkdir -p "$bin_dir" install -m 0755 "$src/bin/live" "$bin_dir/live" say "installed → $bin_dir/live" } main "$@"