#!/usr/bin/env bash # # br (beads_rust) installer - Ultra-robust multi-platform installer with beautiful output # # One-liner install: # curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/beads_rust/main/install.sh?$(date +%s)" | bash # # Options: # --version vX.Y.Z Install specific version (default: latest) # --dest DIR Install to DIR (default: ~/.local/bin) # --system Install to /usr/local/bin (requires sudo) # --easy-mode Auto-update PATH in shell rc files # --verify Run self-test after install # --artifact-url URL Use a custom release artifact URL # --checksum SHA Provide expected SHA256 checksum # --checksum-url URL Provide a custom checksum URL # --insecure-skip-checksum # Allow installation without checksum verification # --from-source Build from source instead of downloading binary # --quiet Suppress non-error output # --no-gum Disable gum formatting even if available # --skip-skills Don't install any Claude Code / Codex skills # --with-migration-skill Install the bd-to-br-migration skill (opt-in; default is to skip it) # --uninstall Remove br and clean up # --help Show this help # set -euo pipefail umask 022 shopt -s lastpipe 2>/dev/null || true # ============================================================================ # Curl|bash self-protection: re-download and re-exec from an on-disk copy # (issue #250) # ============================================================================ # When invoked via `curl … | bash`, bash reads the script progressively from # its own stdin. Bugs cascade from that: # # 1. Any later `read -r` (interactive prompts), heredoc, or command that # the script itself tries to consume from stdin will steal bytes bash # still needs to parse — producing confusing errors like # "line 32: syntax error near unexpected token '1334'" # that cite a line nowhere near the actual text they reference # (issue #250 bug 2). # 2. If the TCP connection stalls or is truncated, bash may parse a # partial script and run half of it. # 3. macOS Homebrew bash 5.3+ has tightened its piped-stdin parser, making # patterns that worked on older bash fail on current Apple Silicon # hardware. # # The fix is a two-step bootstrap: when we detect that this script is # running from a pipe (no file path AND stdin is not a terminal), we # download a fresh copy of install.sh to a temp file with curl/wget and # re-exec bash against that file. From that point on `$0` is a real path, # `BASH_SOURCE[0]` is populated, interactive `read` can route to the # controlling tty, and parsing errors disappear. # # Re-exec is guarded by BR_INSTALLER_SELF_REEXEC=1 to prevent infinite # recursion if for some reason the on-disk copy still looks piped (e.g. # `exec` with no tty on an exotic runtime). if [[ -z "${BR_INSTALLER_SELF_REEXEC:-}" ]] \ && [[ -z "${BASH_SOURCE[0]:-}" || ! -r "${BASH_SOURCE[0]:-}" ]]; then __br_self_owner="${OWNER:-Dicklesworthstone}" __br_self_repo="${REPO:-beads_rust}" __br_self_branch="${BR_INSTALLER_BRANCH:-main}" __br_self_url="${BR_INSTALLER_URL:-https://raw.githubusercontent.com/${__br_self_owner}/${__br_self_repo}/${__br_self_branch}/install.sh}" __br_self_tmp="$(mktemp -t br-installer.XXXXXX 2>/dev/null || mktemp 2>/dev/null || echo "/tmp/br-installer.$$.sh")" __br_self_fetched=0 if command -v curl >/dev/null 2>&1; then # Cache-bust with a query param to sidestep stale CDN copies; the # server ignores unknown query strings on raw.githubusercontent.com. if curl -fsSL --retry 3 --max-time 60 \ "${__br_self_url}?$(date +%s 2>/dev/null || echo self)" \ -o "$__br_self_tmp" 2>/dev/null; then __br_self_fetched=1 fi fi if [[ "$__br_self_fetched" -eq 0 ]] && command -v wget >/dev/null 2>&1; then if wget -qO "$__br_self_tmp" \ "${__br_self_url}?$(date +%s 2>/dev/null || echo self)" 2>/dev/null; then __br_self_fetched=1 fi fi if [[ "$__br_self_fetched" -eq 1 ]] && [[ -s "$__br_self_tmp" ]]; then chmod 0700 "$__br_self_tmp" 2>/dev/null || true export BR_INSTALLER_SELF_REEXEC=1 # Route interactive input to the controlling tty if one is usable. # `[[ -r /dev/tty ]]` returns true in some CI harnesses where # opening /dev/tty actually fails with "No such device or address", # so probe by opening it in a subshell first. if ( : /dev/null; then exec bash "$__br_self_tmp" "$@" /dev/null || true unset __br_self_owner __br_self_repo __br_self_branch __br_self_url __br_self_tmp __br_self_fetched fi # ============================================================================ # Configuration # ============================================================================ VERSION="${VERSION:-}" OWNER="${OWNER:-Dicklesworthstone}" REPO="${REPO:-beads_rust}" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) BINARY_NAME="br.exe" ;; *) BINARY_NAME="br" ;; esac DEST_DEFAULT="$HOME/.local/bin" DEST="${DEST:-$DEST_DEFAULT}" EASY=0 QUIET=0 VERIFY=0 FROM_SOURCE=0 UNINSTALL=0 CHECKSUM="${CHECKSUM:-}" CHECKSUM_URL="${CHECKSUM_URL:-}" ARTIFACT_URL="${ARTIFACT_URL:-}" INSECURE_SKIP_CHECKSUM=0 LOCK_FILE="/tmp/br-install.lock" NO_GUM=0 SKIP_SKILLS=0 # Per-skill opt-ins. Default is to NOT install the bd-to-br-migration skill # (see #261 — once a user has migrated, the skill just pollutes context; # it's a one-time tool, not a steady-state surface). Pass # --with-migration-skill to include it. --skip-skills still wins and # suppresses every skill regardless of this flag. INSTALL_MIGRATION_SKILL=0 MAX_RETRIES=3 DOWNLOAD_TIMEOUT=120 INSTALLER_VERSION="2.0.0" # Colors for fallback output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' BOLD='\033[1m' DIM='\033[2m' ITALIC='\033[3m' NC='\033[0m' # Gum availability flag GUM_AVAILABLE=false # ============================================================================ # Gum auto-installation (from giil) # ============================================================================ try_install_gum() { # Skip if in CI or non-interactive [[ -z "${CI:-}" ]] || return 1 [[ -t 1 ]] || return 1 printf >&2 '%s\n' "Note: installing 'gum' (charmbracelet/gum) for styled output. Pass --no-gum to skip." # Inline OS detection local os="unknown" case "$(uname -s)" in Darwin*) os="macos" ;; Linux*) os="linux" ;; esac # Try to install gum quietly case "$os" in macos) if command -v brew &> /dev/null; then brew install gum &>/dev/null && return 0 fi ;; linux) # Try common package managers if command -v apt-get &> /dev/null; then ( sudo mkdir -p /etc/apt/keyrings 2>/dev/null curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg 2>/dev/null echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null sudo apt-get update -qq && sudo apt-get install -y -qq gum ) &>/dev/null && return 0 elif command -v dnf &> /dev/null; then ( echo '[charm] name=Charm baseurl=https://repo.charm.sh/yum/ enabled=1 gpgcheck=1 gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo >/dev/null sudo dnf install -y gum ) &>/dev/null && return 0 elif command -v pacman &> /dev/null; then sudo pacman -S --noconfirm gum &>/dev/null && return 0 fi # Fallback: download from GitHub releases local arch arch=$(uname -m) case "$arch" in x86_64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; *) return 1 ;; esac local tmp_dir tmp_dir=$(mktemp -d) local gum_version="0.14.5" local gum_url="https://github.com/charmbracelet/gum/releases/download/v${gum_version}/gum_${gum_version}_Linux_${arch}.tar.gz" ( cd "$tmp_dir" curl -fsSL "$gum_url" -o gum.tar.gz tar -xzf gum.tar.gz if sudo mv gum /usr/local/bin/gum 2>/dev/null; then : else mkdir -p ~/.local/bin mv gum ~/.local/bin/gum fi ) &>/dev/null && rm -rf "$tmp_dir" && return 0 rm -rf "$tmp_dir" ;; esac return 1 } check_gum() { # Respect NO_GUM flag if [[ "$NO_GUM" -eq 1 ]]; then GUM_AVAILABLE=false return 1 fi if command -v gum &> /dev/null; then GUM_AVAILABLE=true return 0 fi # Only try to install gum if interactive and not disabled if [[ -t 1 && -z "${CI:-}" ]]; then if try_install_gum; then if [[ -x "${HOME}/.local/bin/gum" && ":$PATH:" != *":${HOME}/.local/bin:"* ]]; then export PATH="${HOME}/.local/bin:${PATH}" fi if command -v gum &> /dev/null; then GUM_AVAILABLE=true return 0 fi fi fi return 1 } # ============================================================================ # Styled output functions (gum with ANSI fallback) # ============================================================================ # Print styled banner print_banner() { [ "$QUIET" -eq 1 ] && return 0 if [[ "$GUM_AVAILABLE" == "true" ]]; then gum style \ --border double \ --border-foreground 39 \ --padding "0 2" \ --margin "1 0" \ --bold \ "$(gum style --foreground 42 '🔗 br installer')" \ "$(gum style --foreground 245 'Agent-first issue tracker (beads_rust)')" else echo "" echo -e "${BOLD}${BLUE}╔════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}${BLUE}║${NC} ${BOLD}${GREEN}🔗 br installer${NC} ${BOLD}${BLUE}║${NC}" echo -e "${BOLD}${BLUE}║${NC} ${DIM}Agent-first issue tracker (beads_rust)${NC} ${BOLD}${BLUE}║${NC}" echo -e "${BOLD}${BLUE}╚════════════════════════════════════════════════╝${NC}" echo "" fi } # Log functions log_info() { [ "$QUIET" -eq 1 ] && return 0 if [[ "$GUM_AVAILABLE" == "true" ]]; then gum log --level info "$1" >&2 else echo -e "${GREEN}[br]${NC} $1" >&2 fi } log_warn() { if [[ "$GUM_AVAILABLE" == "true" ]]; then gum log --level warn "$1" >&2 else echo -e "${YELLOW}[br]${NC} $1" >&2 fi } log_error() { if [[ "$GUM_AVAILABLE" == "true" ]]; then gum log --level error "$1" >&2 else echo -e "${RED}[br]${NC} $1" >&2 fi } log_step() { [ "$QUIET" -eq 1 ] && return 0 if [[ "$GUM_AVAILABLE" == "true" ]]; then gum style --foreground 39 "→ $1" >&2 else echo -e "${BLUE}→${NC} $1" >&2 fi } log_success() { [ "$QUIET" -eq 1 ] && return 0 if [[ "$GUM_AVAILABLE" == "true" ]]; then gum style --foreground 82 "✓ $1" >&2 else echo -e "${GREEN}✓${NC} $1" >&2 fi } log_debug() { [[ "${DEBUG:-0}" -eq 1 ]] || return 0 if [[ "$GUM_AVAILABLE" == "true" ]]; then gum log --level debug "$1" >&2 else echo -e "${CYAN}[br:debug]${NC} $1" >&2 fi } # Spinner wrapper for long operations # Note: gum spin can only execute external binaries, not shell functions. # We work around this by checking if the command is a function and using bash -c. run_with_spinner() { local title="$1" shift if [[ "$GUM_AVAILABLE" == "true" && "$QUIET" -eq 0 ]]; then # Check if first argument is a shell function if declare -f "$1" >/dev/null 2>&1; then # Export the function and run via bash -c local func_name="$1" shift # Can't easily export functions to gum subshell, so fall back to no-spinner log_step "$title" "$func_name" "$@" else gum spin --spinner dot --title "$title" -- "$@" fi else log_step "$title" "$@" fi } # Die with error die() { log_error "$@" exit 1 } # ============================================================================ # Usage / Help (gum-styled) # ============================================================================ usage() { check_gum || true if [[ "$GUM_AVAILABLE" == "true" ]]; then gum style \ --border double \ --border-foreground 39 \ --padding "1 2" \ --margin "1" \ --bold \ "$(gum style --foreground 42 '🔗 br installer v'${INSTALLER_VERSION})" \ "$(gum style --foreground 245 'Agent-first issue tracker')" echo "" gum style --foreground 214 --bold "SYNOPSIS" echo " curl -fsSL .../install.sh | bash" echo " curl -fsSL .../install.sh | bash -s -- [OPTIONS]" echo "" gum style --foreground 214 --bold "OPTIONS" gum style --foreground 39 " Installation" gum style --faint " --version vX.Y.Z Install specific version (default: latest)" gum style --faint " --dest DIR Install to DIR (default: ~/.local/bin)" gum style --faint " --system Install to /usr/local/bin (requires sudo)" gum style --faint " --artifact-url URL Use a custom release artifact URL" gum style --faint " --checksum SHA Provide expected SHA256 checksum" gum style --faint " --checksum-url URL Provide a custom checksum URL" gum style --faint " --insecure-skip-checksum Allow unverified binary install" gum style --faint " --from-source Build from source instead of binary" echo "" gum style --foreground 39 " Behavior" gum style --faint " --easy-mode Auto-update PATH in shell rc files" gum style --faint " --verify Run self-test after install" gum style --faint " --quiet Suppress progress messages" gum style --faint " --no-gum Disable gum formatting" gum style --faint " --skip-skills Don't install any Claude/Codex skills" gum style --faint " --with-migration-skill Install the bd-to-br-migration skill (opt-in)" echo "" gum style --foreground 39 " Maintenance" gum style --faint " --uninstall Remove br and clean up" gum style --faint " --help Show this help" echo "" gum style --foreground 214 --bold "ENVIRONMENT" gum style --faint " HTTPS_PROXY Use HTTPS proxy for downloads" gum style --faint " HTTP_PROXY Use HTTP proxy for downloads" gum style --faint " BR_INSTALL_DIR Override default install directory" gum style --faint " VERSION Override version to install" echo "" gum style --foreground 214 --bold "EXAMPLES" gum style --foreground 39 " # Default install" echo " curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/beads_rust/main/install.sh | bash" echo "" gum style --foreground 39 " # System install with auto PATH" echo " curl -fsSL .../install.sh | sudo bash -s -- --system --easy-mode" echo "" gum style --foreground 39 " # Force source build" echo " curl -fsSL .../install.sh | bash -s -- --from-source" echo "" gum style --foreground 39 " # Uninstall" echo " curl -fsSL .../install.sh | bash -s -- --uninstall" echo "" gum style --foreground 214 --bold "PLATFORMS" echo " $(gum style --foreground 82 '✓ Linux x86_64')" gum style --foreground 82 " ✓ Linux ARM64" gum style --foreground 82 " ✓ macOS Intel" gum style --foreground 82 " ✓ macOS Apple Silicon" echo " $(gum style --foreground 82 '✓ Windows x64') $(gum style --foreground 245 --faint '(via WSL or manual)')" echo "" gum style --foreground 245 --italic "Installer will auto-install gum for beautiful output if not present" else cat <<'EOF' br installer - Install beads_rust (br) CLI tool Usage: curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/beads_rust/main/install.sh | bash curl -fsSL .../install.sh | bash -s -- [OPTIONS] Options: --version vX.Y.Z Install specific version (default: latest) --dest DIR Install to DIR (default: ~/.local/bin) --system Install to /usr/local/bin (requires sudo) --artifact-url URL Use a custom release artifact URL --checksum SHA Provide expected SHA256 checksum --checksum-url URL Provide a custom checksum URL --insecure-skip-checksum Allow installation without checksum verification --easy-mode Auto-update PATH in shell rc files --verify Run self-test after install --from-source Build from source instead of downloading binary --quiet Suppress non-error output --no-gum Disable gum formatting even if available --skip-skills Don't install any Claude Code / Codex skills --with-migration-skill Install the bd-to-br-migration skill (opt-in) --uninstall Remove br and clean up Environment Variables: HTTPS_PROXY Use HTTPS proxy for downloads HTTP_PROXY Use HTTP proxy for downloads BR_INSTALL_DIR Override default install directory VERSION Override version to install Platforms: ✓ Linux x86_64 ✓ Linux ARM64 ✓ macOS Intel ✓ macOS Apple Silicon ✓ Windows x64 (via WSL or manual) Examples: # Default install curl -fsSL .../install.sh | bash # Custom prefix with easy mode curl -fsSL .../install.sh | bash -s -- --dest=/usr/local/bin --easy-mode # Force source build curl -fsSL .../install.sh | bash -s -- --from-source # Uninstall curl -fsSL .../install.sh | bash -s -- --uninstall EOF fi exit 0 } # ============================================================================ # Argument Parsing # ============================================================================ while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2;; --version=*) VERSION="${1#*=}"; shift;; --dest) DEST="$2"; shift 2;; --dest=*) DEST="${1#*=}"; shift;; --system) DEST="/usr/local/bin"; shift;; --easy-mode) EASY=1; shift;; --verify) VERIFY=1; shift;; --artifact-url) ARTIFACT_URL="$2"; shift 2;; --checksum) CHECKSUM="$2"; shift 2;; --checksum-url) CHECKSUM_URL="$2"; shift 2;; --insecure-skip-checksum) INSECURE_SKIP_CHECKSUM=1; shift;; --from-source) FROM_SOURCE=1; shift;; --quiet|-q) QUIET=1; shift;; --no-gum) NO_GUM=1; shift;; --skip-skills) SKIP_SKILLS=1; shift;; --with-migration-skill) INSTALL_MIGRATION_SKILL=1; shift;; --uninstall) UNINSTALL=1; shift;; -h|--help) usage;; *) shift;; esac done # Environment variable overrides [ -n "${BR_INSTALL_DIR:-}" ] && DEST="$BR_INSTALL_DIR" # Initialize gum early for beautiful output check_gum || true # ============================================================================ # Uninstall # ============================================================================ do_uninstall() { print_banner log_step "Uninstalling br..." if [ -f "$DEST/$BINARY_NAME" ]; then rm -f "$DEST/$BINARY_NAME" log_success "Removed $DEST/$BINARY_NAME" else log_warn "Binary not found at $DEST/$BINARY_NAME" fi # Remove PATH modifications from shell rc files for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile" "$HOME/.config/fish/config.fish"; do if [ -f "$rc" ] && grep -q "# br installer" "$rc" 2>/dev/null; then if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' '/# br installer/d' "$rc" 2>/dev/null || true else sed -i '/# br installer/d' "$rc" 2>/dev/null || true fi log_step "Cleaned $rc" fi done log_success "br uninstalled successfully" exit 0 } [ "$UNINSTALL" -eq 1 ] && do_uninstall # ============================================================================ # Platform Detection # ============================================================================ detect_platform() { local os arch libc case "$(uname -s)" in Linux*) os="linux" ;; Darwin*) os="darwin" ;; MINGW*|MSYS*|CYGWIN*) os="windows" ;; *) die "Unsupported OS: $(uname -s)" ;; esac case "$(uname -m)" in x86_64|amd64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; armv7*) arch="armv7" ;; *) die "Unsupported architecture: $(uname -m)" ;; esac # Distinguish glibc vs musl on Linux. Alpine and other musl-based distros # need the statically linked musl binary; the gnu artifact references # libgcc_s/_Unwind_* symbols that musl's libc-compat shim does not provide # (see #284). libc="" if [ "$os" = "linux" ]; then # Detection order, cheapest and most reliable first: # 1. /etc/alpine-release — Alpine fast path (cheap stat). # 2. /proc/self/maps — what *this running bash* is linked # against. Bulletproof: it survives systems that have the # musl cross-toolchain installed alongside glibc (which # makes /lib/ld-musl-*.so* present even on glibc hosts), and # side-steps the `set -o pipefail` interaction with `ldd`. # 3. `ldd --version` output sniff — last resort for exotic # systems with no /proc (e.g. heavily restricted containers). # # Note on the ldd path: musl's `ldd` exits non-zero even when it # prints "musl libc" to stderr, so `if … | grep -q …` is never # taken under `pipefail`. We capture combined output first and # match with `case` to avoid the pipeline entirely. if [ -f /etc/alpine-release ]; then libc="musl" elif grep -q 'ld-musl' /proc/self/maps 2>/dev/null; then libc="musl" elif command -v ldd >/dev/null 2>&1; then ldd_output=$(ldd --version 2>&1 || true) case "$ldd_output" in *[Mm]usl*) libc="musl" ;; esac fi # Only musl_arm64 and musl_amd64 are published; armv7 keeps gnu (no musl # artifact yet). If we somehow detected musl on armv7, fall back to gnu # rather than fabricating an artifact name that does not exist. if [ "$libc" = "musl" ] && [ "$arch" != "amd64" ] && [ "$arch" != "arm64" ]; then libc="" fi fi if [ -n "$libc" ]; then echo "${os}_${libc}_${arch}" else echo "${os}_${arch}" fi } # ============================================================================ # Version Resolution (with robust fallbacks) # ============================================================================ resolve_version() { if [ -n "$VERSION" ]; then return 0; fi log_step "Resolving latest version..." local latest_url="https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" local tag="" local attempts=0 # Try GitHub API with retries while [ $attempts -lt $MAX_RETRIES ] && [ -z "$tag" ]; do attempts=$((attempts + 1)) if command -v curl &>/dev/null; then tag=$(curl -fsSL \ --connect-timeout 10 \ --max-time 30 \ -H "Accept: application/vnd.github.v3+json" \ "$latest_url" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || echo "") elif command -v wget &>/dev/null; then tag=$(wget -qO- --timeout=30 "$latest_url" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || echo "") fi [ -z "$tag" ] && [ $attempts -lt $MAX_RETRIES ] && sleep 2 done if [ -n "$tag" ] && [[ "$tag" =~ ^v[0-9] ]]; then VERSION="$tag" log_success "Latest version: $VERSION" return 0 fi # Fallback: try redirect-based resolution log_step "Trying redirect-based version resolution..." local redirect_url="https://github.com/${OWNER}/${REPO}/releases/latest" if command -v curl &>/dev/null; then tag=$(curl -fsSL -o /dev/null -w '%{url_effective}' "$redirect_url" 2>/dev/null | sed -E 's|.*/tag/||' || echo "") fi if [ -n "$tag" ] && [[ "$tag" =~ ^v[0-9] ]] && [[ "$tag" != *"/"* ]]; then VERSION="$tag" log_success "Latest version (via redirect): $VERSION" return 0 fi log_warn "Could not resolve latest version; will try building from source" VERSION="" } # ============================================================================ # Cross-platform locking using mkdir (atomic on all POSIX systems) # ============================================================================ LOCK_DIR="${LOCK_FILE}.d" LOCKED=0 acquire_lock() { if mkdir "$LOCK_DIR" 2>/dev/null; then LOCKED=1 echo $$ > "$LOCK_DIR/pid" return 0 fi # Check if existing lock is stale if [ -f "$LOCK_DIR/pid" ]; then local old_pid old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo "") # Check if process is still running if [ -n "$old_pid" ] && ! kill -0 "$old_pid" 2>/dev/null; then log_warn "Removing stale lock (PID $old_pid not running)" rm -rf "$LOCK_DIR" if mkdir "$LOCK_DIR" 2>/dev/null; then LOCKED=1 echo $$ > "$LOCK_DIR/pid" return 0 fi fi # Check lock age (5 minute timeout) local lock_age=0 if [[ "$OSTYPE" == "darwin"* ]]; then lock_age=$(( $(date +%s) - $(stat -f %m "$LOCK_DIR/pid" 2>/dev/null || echo 0) )) else lock_age=$(( $(date +%s) - $(stat -c %Y "$LOCK_DIR/pid" 2>/dev/null || echo 0) )) fi if [ "$lock_age" -gt 300 ]; then log_warn "Removing stale lock (age: ${lock_age}s)" rm -rf "$LOCK_DIR" if mkdir "$LOCK_DIR" 2>/dev/null; then LOCKED=1 echo $$ > "$LOCK_DIR/pid" return 0 fi fi fi if [ "$LOCKED" -eq 0 ]; then die "Another installation is running. If incorrect, run: rm -rf $LOCK_DIR" fi } # ============================================================================ # Cleanup # ============================================================================ TMP="" cleanup() { [ -n "$TMP" ] && rm -rf "$TMP" [ "$LOCKED" -eq 1 ] && rm -rf "$LOCK_DIR" } trap cleanup EXIT # ============================================================================ # PATH modification # ============================================================================ maybe_add_path() { case ":$PATH:" in *:"$DEST":*) return 0;; *) if [ "$EASY" -eq 1 ]; then local updated=0 for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do if [ -f "$rc" ] && [ -w "$rc" ]; then if ! grep -qF "$DEST" "$rc" 2>/dev/null; then echo "" >> "$rc" echo "export PATH=\"$DEST:\$PATH\" # br installer" >> "$rc" fi updated=1 fi done # Handle fish shell local fish_config="$HOME/.config/fish/config.fish" if [ -f "$fish_config" ] && [ -w "$fish_config" ]; then if ! grep -qF "$DEST" "$fish_config" 2>/dev/null; then echo "" >> "$fish_config" echo "set -gx PATH $DEST \$PATH # br installer" >> "$fish_config" fi updated=1 fi if [ "$updated" -eq 1 ]; then log_warn "PATH updated; restart shell or run: export PATH=\"$DEST:\$PATH\"" else log_warn "Add $DEST to PATH to use br" fi else log_warn "Add $DEST to PATH to use br" fi ;; esac } # ============================================================================ # Fix shell alias conflicts # ============================================================================ fix_alias_conflicts() { # Check if 'br' is aliased to something else (common: bun run) for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do if [ -f "$rc" ]; then # Add unalias after any potential alias definitions if ! grep -q "unalias br.*# br installer" "$rc" 2>/dev/null; then if grep -q "alias br=" "$rc" 2>/dev/null; then echo "" >> "$rc" echo "unalias br 2>/dev/null # br installer - remove conflicting alias" >> "$rc" log_step "Added unalias to $rc to prevent conflicts" fi fi fi done } # ============================================================================ # Install Claude Code / Codex skills # ============================================================================ install_skills() { if [ "$SKIP_SKILLS" -eq 1 ]; then log_step "Skipping skills installation (--skip-skills)" return 0 fi log_step "Installing Claude Code / Codex skills..." local skills_base_url="https://raw.githubusercontent.com/${OWNER}/${REPO}/main/skills" local claude_skills_dir="$HOME/.claude/skills" local codex_skills_dir="${CODEX_HOME:-$HOME/.codex}/skills" # List of skills to install (skill_name:files separated by commas) local skills=( "bd-to-br-migration:SKILL.md,SELF-TEST.md,references/TRANSFORMS.md,references/BULK.md,references/PITFALLS.md,scripts/find-bd-refs.sh,scripts/verify-migration.sh,subagents/batch-migrator.md" ) local skill local _skills_installed_count=0 for skill in "${skills[@]}"; do local skill_name="${skill%%:*}" local files_str="${skill#*:}" # Per-skill opt-ins: map skill_name to its flag var. case "$skill_name" in bd-to-br-migration) if [ "$INSTALL_MIGRATION_SKILL" -ne 1 ]; then log_step "Skipping skill: $skill_name (opt-in via --with-migration-skill)" continue fi ;; esac log_step "Installing skill: $skill_name" # Create skill directories mkdir -p "$claude_skills_dir/$skill_name/references" 2>/dev/null || true mkdir -p "$claude_skills_dir/$skill_name/scripts" 2>/dev/null || true mkdir -p "$claude_skills_dir/$skill_name/subagents" 2>/dev/null || true mkdir -p "$codex_skills_dir/$skill_name/references" 2>/dev/null || true mkdir -p "$codex_skills_dir/$skill_name/scripts" 2>/dev/null || true mkdir -p "$codex_skills_dir/$skill_name/subagents" 2>/dev/null || true # Download each file IFS=',' read -ra files <<< "$files_str" local file local files_installed=0 for file in "${files[@]}"; do local url="$skills_base_url/$skill_name/$file" local claude_dest="$claude_skills_dir/$skill_name/$file" local codex_dest="$codex_skills_dir/$skill_name/$file" local tmp_file="${claude_dest}.tmp.$$" # Download to temp file first to avoid leaving empty files on failure if download_file "$url" "$tmp_file"; then mv "$tmp_file" "$claude_dest" # Make scripts executable if [[ "$file" == scripts/* ]]; then chmod +x "$claude_dest" 2>/dev/null || true fi log_debug "Downloaded $file to Claude skills" files_installed=$((files_installed + 1)) # Copy to Codex skills cp "$claude_dest" "$codex_dest" 2>/dev/null || true if [[ "$file" == scripts/* ]]; then chmod +x "$codex_dest" 2>/dev/null || true fi else rm -f "$tmp_file" 2>/dev/null || true log_debug "Could not download $file (may not exist)" fi done if [ "$files_installed" -gt 0 ]; then log_success "Installed skill: $skill_name ($files_installed files)" _skills_installed_count=$((_skills_installed_count + 1)) else log_warn "Skill $skill_name: no files could be downloaded" fi done # Only show the fancy skills summary when at least one skill actually # landed files on disk. Don't advertise skills that were skipped (e.g. # bd-to-br-migration is opt-in per #261) or whose downloads all failed. if [ "$_skills_installed_count" -gt 0 ]; then print_skills_summary "$claude_skills_dir" "$codex_skills_dir" fi } # Print beautiful skills installation summary print_skills_summary() { local claude_dir="$1" local codex_dir="$2" [ "$QUIET" -eq 1 ] && return 0 echo "" if [[ "$GUM_AVAILABLE" == "true" ]]; then gum style \ --border rounded \ --border-foreground 213 \ --padding "1 2" \ --margin "1 0" \ "$(gum style --foreground 213 --bold '🎯 AI Coding Skills Installed!')" \ "" \ "$(gum style --foreground 245 'Skills help AI agents migrate from bd → br')" echo "" gum style --foreground 214 --bold "📍 Installed Locations" gum style --foreground 39 " Claude Code: $(gum style --foreground 82 "$claude_dir")" gum style --foreground 39 " Codex: $(gum style --foreground 82 "$codex_dir")" echo "" gum style \ --border rounded \ --border-foreground 39 \ --padding "1 2" \ "$(gum style --foreground 39 --bold '⚡ How to Use Skills')" \ "" \ "$(gum style --foreground 214 'Claude Code') $(gum style --faint '(slash command):')" \ " $(gum style --foreground 82 '/bd-to-br-migration')" \ "" \ "$(gum style --foreground 214 'Codex') $(gum style --faint '(dollar command):')" \ " $(gum style --foreground 82 "\$bd-to-br-migration")" echo "" gum style --foreground 245 --italic "Skills auto-trigger when agents detect bd→br migration needs" else echo "" echo -e "${MAGENTA}${BOLD}╔════════════════════════════════════════════════════════════╗${NC}" echo -e "${MAGENTA}${BOLD}║${NC} ${BOLD}🎯 AI Coding Skills Installed!${NC} ${MAGENTA}${BOLD}║${NC}" echo -e "${MAGENTA}${BOLD}║${NC} ${DIM}Skills help AI agents migrate from bd → br${NC} ${MAGENTA}${BOLD}║${NC}" echo -e "${MAGENTA}${BOLD}╚════════════════════════════════════════════════════════════╝${NC}" echo "" echo -e "${YELLOW}${BOLD}📍 Installed Locations${NC}" echo -e " ${CYAN}Claude Code:${NC} ${GREEN}$claude_dir${NC}" echo -e " ${CYAN}Codex:${NC} ${GREEN}$codex_dir${NC}" echo "" echo -e "${BLUE}${BOLD}╭────────────────────────────────────────────────────────────╮${NC}" echo -e "${BLUE}${BOLD}│${NC} ${BOLD}⚡ How to Use Skills${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}│${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}│${NC} ${YELLOW}Claude Code${NC} ${DIM}(slash command):${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}│${NC} ${GREEN}/bd-to-br-migration${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}│${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}│${NC} ${YELLOW}Codex${NC} ${DIM}(dollar command):${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}│${NC} ${GREEN}\$bd-to-br-migration${NC} ${BLUE}${BOLD}│${NC}" echo -e "${BLUE}${BOLD}╰────────────────────────────────────────────────────────────╯${NC}" echo "" echo -e "${DIM}${ITALIC}Skills auto-trigger when agents detect bd→br migration needs${NC}" fi } # ============================================================================ # Rust installation for source builds # ============================================================================ ensure_rust() { if [ "${RUSTUP_INIT_SKIP:-0}" != "0" ]; then log_step "Skipping rustup (RUSTUP_INIT_SKIP set)" return 0 fi if command -v cargo >/dev/null 2>&1; then return 0 fi if [ "$EASY" -ne 1 ] && [ -t 0 ]; then if [[ "$GUM_AVAILABLE" == "true" ]]; then if ! gum confirm "Rust not found. Install via rustup?"; then log_warn "Skipping rustup" return 1 fi else echo -n "Rust not found. Install via rustup? (Y/n): " read -r ans case "$ans" in n|N) log_warn "Skipping rustup"; return 1;; esac fi fi log_step "Installing Rust via rustup..." run_with_spinner "Installing Rust toolchain..." \ curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal export PATH="$HOME/.cargo/bin:$PATH" # Source cargo env # shellcheck source=/dev/null [ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env" } # ============================================================================ # Pre-build cleanup for source builds # ============================================================================ prepare_for_build() { # Source builds use TMP-scoped clone and target directories, so preflight # must not disturb unrelated Cargo processes or shared Cargo caches. local avail_kb if [[ "$OSTYPE" == "darwin"* ]]; then avail_kb=$(df -k /tmp | tail -1 | awk '{print $4}') else avail_kb=$(df -k /tmp | tail -1 | awk '{print $4}') fi if [ "$avail_kb" -lt 1048576 ]; then log_warn "Low disk space in /tmp ($(( avail_kb / 1024 ))MB). Source build may fail; set TMPDIR to a larger filesystem and retry if needed." fi } # ============================================================================ # Download with retry and progress # ============================================================================ download_file() { local url="$1" local dest="$2" local attempt=0 local partial="${dest}.part" local proxy_env=() local proxy_http="${HTTP_PROXY:-${http_proxy:-}}" local proxy_https="${HTTPS_PROXY:-${https_proxy:-}}" [ -n "$proxy_http" ] && proxy_env+=(HTTP_PROXY="$proxy_http" http_proxy="$proxy_http") [ -n "$proxy_https" ] && proxy_env+=(HTTPS_PROXY="$proxy_https" https_proxy="$proxy_https") local show_progress=0 if [ "$QUIET" -eq 0 ] && [ -t 2 ]; then show_progress=1 fi while [ $attempt -lt $MAX_RETRIES ]; do attempt=$((attempt + 1)) log_debug "Download attempt $attempt for $url" local use_resume=0 if [ -s "$partial" ]; then use_resume=1 fi if command -v curl &>/dev/null; then local curl_args=( -fL --connect-timeout 30 --max-time "$DOWNLOAD_TIMEOUT" --retry 2 -o "$partial" "$url" ) if [ "$use_resume" -eq 1 ]; then curl_args=(--continue-at - "${curl_args[@]}") fi if [ "$show_progress" -eq 1 ]; then curl_args=(--progress-bar "${curl_args[@]}") else curl_args=(-sS "${curl_args[@]}") fi if env ${proxy_env[@]+"${proxy_env[@]}"} curl "${curl_args[@]}"; then mv -f "$partial" "$dest" return 0 fi elif command -v wget &>/dev/null; then local wget_args=( --timeout="$DOWNLOAD_TIMEOUT" -O "$partial" "$url" ) if [ "$use_resume" -eq 1 ]; then wget_args=(--continue "${wget_args[@]}") fi if [ "$show_progress" -eq 1 ]; then wget_args=(--show-progress "${wget_args[@]}") else wget_args=(--quiet "${wget_args[@]}") fi if env ${proxy_env[@]+"${proxy_env[@]}"} wget "${wget_args[@]}"; then mv -f "$partial" "$dest" return 0 fi else die "Neither curl nor wget found" fi [ $attempt -lt $MAX_RETRIES ] && { log_warn "Download failed, retrying in 3s..." sleep 3 } done return 1 } # ============================================================================ # Atomic binary install # ============================================================================ install_binary_atomic() { local src="$1" local dest="$2" local tmp_dest="${dest}.tmp.$$" install -m 0755 "$src" "$tmp_dest" if ! mv -f "$tmp_dest" "$dest"; then rm -f "$tmp_dest" 2>/dev/null || true die "Failed to move binary into place" fi } is_windows_binary() { case "$1" in *.exe) return 0 ;; *) return 1 ;; esac } is_installable_binary() { local path="$1" if [ ! -f "$path" ]; then return 1 fi if [ -x "$path" ] || is_windows_binary "$path"; then return 0 fi return 1 } find_binary_candidate() { local search_root="$1" local binary_name="$2" local bin="$search_root/$binary_name" if is_installable_binary "$bin"; then printf '%s\n' "$bin" return 0 fi if is_windows_binary "$binary_name"; then bin=$(find "$search_root" -name "$binary_name" -type f 2>/dev/null | head -1) else bin=$(find "$search_root" -name "$binary_name" -type f -perm -111 2>/dev/null | head -1) fi if [ -n "$bin" ] && is_installable_binary "$bin"; then printf '%s\n' "$bin" return 0 fi return 1 } # ============================================================================ # Build from source # ============================================================================ build_from_source() { log_step "Building from source..." if ! ensure_rust; then die "Rust is required for source builds" fi prepare_for_build local build_dir="$TMP/src" run_with_spinner "Cloning repository..." \ git clone --depth 1 "https://github.com/${OWNER}/${REPO}.git" "$build_dir" if [ ! -d "$build_dir" ]; then die "Failed to clone repository" fi # Clone frankensqlite next to beads_rust — Cargo.toml [patch.crates-io] # overrides fsqlite crates with path deps at ../frankensqlite/crates/* local franken_dir="$TMP/frankensqlite" run_with_spinner "Cloning frankensqlite dependency..." \ git clone --depth 1 "https://github.com/${OWNER}/frankensqlite.git" "$franken_dir" if [ ! -d "$franken_dir" ]; then die "Failed to clone frankensqlite (required path dependency)" fi log_step "Building with Cargo (this may take a few minutes)..." # Build with explicit target dir to avoid conflicts local target_dir="$TMP/target" if [[ "$GUM_AVAILABLE" == "true" && "$QUIET" -eq 0 ]]; then if ! gum spin --spinner dot --title "Compiling br (release mode)..." -- \ bash -c "cd '$build_dir' && CARGO_TARGET_DIR='$target_dir' cargo build --release"; then die "Build failed" fi else (cd "$build_dir" && CARGO_TARGET_DIR="$target_dir" cargo build --release) || die "Build failed" fi # Find the binary local bin if ! bin=$(find_binary_candidate "$target_dir" "$BINARY_NAME"); then die "Binary not found after build" fi install_binary_atomic "$bin" "$DEST/$BINARY_NAME" log_success "Installed to $DEST/$BINARY_NAME (source build)" } # ============================================================================ # Download release binary # ============================================================================ is_valid_sha256() { [[ "${1:-}" =~ ^[[:xdigit:]]{64}$ ]] } verify_archive_checksum() { local archive_path="$1" local archive_name="$2" local expected="$3" if [ -z "$expected" ]; then if [ "$INSECURE_SKIP_CHECKSUM" -eq 1 ]; then log_warn "Checksum not available for $archive_name; continuing because --insecure-skip-checksum was provided" return 0 fi log_error "Checksum not available for $archive_name; refusing to install an unverified binary" log_error "Provide --checksum/--checksum-url, publish ${archive_name}.sha256, or pass --insecure-skip-checksum explicitly" return 1 fi if ! is_valid_sha256 "$expected"; then log_error "Invalid SHA256 checksum format for $archive_name" return 1 fi log_step "Verifying checksum..." local actual="" if command -v sha256sum &>/dev/null; then actual=$(sha256sum "$archive_path" | awk '{print $1}') elif command -v shasum &>/dev/null; then actual=$(shasum -a 256 "$archive_path" | awk '{print $1}') else if [ "$INSECURE_SKIP_CHECKSUM" -eq 1 ]; then log_warn "No SHA256 tool found; continuing because --insecure-skip-checksum was provided" return 0 fi log_error "No SHA256 verification tool found (need sha256sum or shasum)" return 1 fi if [ "$expected" != "$actual" ]; then log_error "Checksum mismatch!" log_error " Expected: $expected" log_error " Got: $actual" return 1 fi log_success "Checksum verified" } archive_member_name_is_safe() { local member="${1:-}" local normalized parts part [ -n "$member" ] || return 1 case "$member" in /*|\\*|[A-Za-z]:*) return 1 ;; esac normalized="${member//\\//}" local IFS='/' read -r -a parts <<< "$normalized" for part in "${parts[@]}"; do if [ "$part" = ".." ]; then return 1 fi done return 0 } validate_archive_members_python() { local archive_path="$1" local archive_kind="$2" python3 - "$archive_path" "$archive_kind" <<'PY' import stat import sys import tarfile import zipfile archive_path, archive_kind = sys.argv[1], sys.argv[2] def reject_reason(name): if not name: return "empty member name" normalized = name.replace("\\", "/") if normalized.startswith("/") or name.startswith("\\"): return "absolute member path" if len(name) >= 2 and name[1] == ":" and name[0].isalpha(): return "drive-qualified member path" if any(part == ".." for part in normalized.split("/")): return "parent-directory member path" return None try: if archive_kind == "tar": with tarfile.open(archive_path, "r:*") as archive: for member in archive.getmembers(): reason = reject_reason(member.name) if reason: raise ValueError(f"{member.name}: {reason}") if member.issym() or member.islnk(): raise ValueError(f"{member.name}: links are not allowed in release archives") elif archive_kind == "zip": with zipfile.ZipFile(archive_path) as archive: for member in archive.infolist(): reason = reject_reason(member.filename) if reason: raise ValueError(f"{member.filename}: {reason}") mode = (member.external_attr >> 16) & 0o170000 if stat.S_ISLNK(mode): raise ValueError(f"{member.filename}: symlinks are not allowed in release archives") else: raise ValueError(f"unsupported archive kind: {archive_kind}") except Exception as exc: print(exc, file=sys.stderr) sys.exit(1) PY } validate_tar_archive_members() { local archive_path="$1" local member line entry_type if command -v python3 >/dev/null 2>&1; then validate_archive_members_python "$archive_path" "tar" return $? fi while IFS= read -r member; do if ! archive_member_name_is_safe "$member"; then log_error "Unsafe archive member path: $member" return 1 fi done < <(tar -tzf "$archive_path") while IFS= read -r line; do entry_type="${line:0:1}" case "$entry_type" in l|h) log_error "Release archive contains link entries; refusing to extract" return 1 ;; esac done < <(tar -tzvf "$archive_path") } validate_zip_archive_members() { local archive_path="$1" if command -v python3 >/dev/null 2>&1; then validate_archive_members_python "$archive_path" "zip" return $? fi log_error "python3 is required to validate zip archive members safely" return 1 } download_release() { local platform="$1" # Map platform to release asset name local archive_name="" local url="" if [ -n "$ARTIFACT_URL" ]; then url="$ARTIFACT_URL" archive_name="$(basename "$ARTIFACT_URL")" else local archive_ext="tar.gz" case "$platform" in windows_*) archive_ext="zip" ;; esac archive_name="br-${VERSION}-${platform}.${archive_ext}" url="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${archive_name}" fi run_with_spinner "Downloading $archive_name..." \ download_file "$url" "$TMP/$archive_name" if [ ! -f "$TMP/$archive_name" ]; then return 1 fi # Download and verify checksum local expected="" if [ -n "$CHECKSUM" ]; then expected="${CHECKSUM%% *}" else local checksum_url="" if [ -n "$CHECKSUM_URL" ]; then checksum_url="$CHECKSUM_URL" else checksum_url="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${archive_name}.sha256" fi if download_file "$checksum_url" "$TMP/checksum.sha256"; then expected=$(awk '{print $1}' "$TMP/checksum.sha256") fi fi verify_archive_checksum "$TMP/$archive_name" "$archive_name" "$expected" || return 1 # Extract log_step "Extracting..." local extract_dir="$TMP/extract" mkdir -p "$extract_dir" case "$archive_name" in *.tar.gz) if ! validate_tar_archive_members "$TMP/$archive_name"; then return 1 fi if ! tar -xzf "$TMP/$archive_name" -C "$extract_dir" 2>/dev/null; then return 1 fi ;; *.zip) if ! validate_zip_archive_members "$TMP/$archive_name"; then return 1 fi if command -v unzip &>/dev/null; then if ! unzip -q "$TMP/$archive_name" -d "$extract_dir" 2>/dev/null; then return 1 fi elif command -v bsdtar &>/dev/null; then if ! bsdtar -xf "$TMP/$archive_name" -C "$extract_dir" 2>/dev/null; then return 1 fi elif command -v python3 &>/dev/null; then if ! python3 - "$TMP/$archive_name" "$extract_dir" <<'PY' import sys import zipfile archive_path, extract_dir = sys.argv[1], sys.argv[2] with zipfile.ZipFile(archive_path) as archive: archive.extractall(extract_dir) PY then return 1 fi else log_error "No zip extractor available (need unzip, bsdtar, or python3)" return 1 fi ;; *) log_error "Unsupported archive format: $archive_name" return 1 ;; esac # Find binary local bin if ! bin=$(find_binary_candidate "$extract_dir" "$BINARY_NAME"); then return 1 fi install_binary_atomic "$bin" "$DEST/$BINARY_NAME" log_success "Installed to $DEST/$BINARY_NAME" return 0 } # ============================================================================ # Check for conflicting installations # ============================================================================ check_conflicts() { local installed_path="$DEST/$BINARY_NAME" local cargo_bin="$HOME/.cargo/bin/$BINARY_NAME" local local_bin="$HOME/.local/bin/$BINARY_NAME" local conflicts=() # Check for br in other locations if [ "$DEST" != "$HOME/.cargo/bin" ] && [ -x "$cargo_bin" ]; then conflicts+=("$cargo_bin") fi if [ "$DEST" != "$HOME/.local/bin" ] && [ -x "$local_bin" ]; then conflicts+=("$local_bin") fi if [ ${#conflicts[@]} -gt 0 ]; then local active_br active_br=$(command -v "$BINARY_NAME" 2>/dev/null || echo "") if [ -n "$active_br" ] && [ "$active_br" != "$installed_path" ]; then log_warn "The active br ($active_br) differs from the newly installed version!" log_step " Installed: $installed_path" log_warn "Additional br location(s):" for conflict in "${conflicts[@]}"; do log_step " Conflict: $conflict" done log_warn "To use the new version, either adjust PATH so $DEST comes first, or move/remove the conflicting binary manually." # Offer to remove conflicts in easy mode. if [ "$EASY" -eq 1 ]; then for conflict in "${conflicts[@]}"; do if [ -t 0 ] && [[ "$GUM_AVAILABLE" == "true" ]]; then if gum confirm "Remove conflicting binary at $conflict?"; then rm -f "$conflict" log_success "Removed $conflict" fi fi done fi return 0 fi log_step "Found additional br location(s); $installed_path remains active" for conflict in "${conflicts[@]}"; do log_step " Other: $conflict" done fi } # ============================================================================ # Print installation summary # ============================================================================ print_summary() { local installed_version installed_version=$("$DEST/$BINARY_NAME" --version 2>/dev/null || echo "unknown") if [[ "$GUM_AVAILABLE" == "true" ]]; then echo "" gum style \ --border rounded \ --border-foreground 82 \ --padding "1 2" \ --margin "1 0" \ "$(gum style --foreground 82 --bold '✓ br installed successfully!')" \ "" \ "$(gum style --foreground 245 "Version: $installed_version")" \ "$(gum style --foreground 245 "Location: $DEST/$BINARY_NAME")" echo "" if [[ ":$PATH:" != *":$DEST:"* ]]; then gum style --foreground 214 "To use br, restart your shell or run:" gum style --foreground 39 " export PATH=\"$DEST:\$PATH\"" echo "" fi gum style --foreground 214 --bold "Quick Start" gum style --faint " br init Initialize a workspace" gum style --faint " br create Create an issue" gum style --faint " br list List issues" gum style --faint " br ready Show ready work" gum style --faint " br --help Full help" echo "" else echo "" log_success "br installed successfully!" echo "" echo " Version: $installed_version" echo " Location: $DEST/$BINARY_NAME" echo "" if [[ ":$PATH:" != *":$DEST:"* ]]; then echo " To use br, restart your shell or run:" echo " export PATH=\"$DEST:\$PATH\"" echo "" fi echo " Quick Start:" echo " br init Initialize a workspace" echo " br create Create an issue" echo " br list List issues" echo " br ready Show ready work" echo " br --help Full help" echo "" fi } # ============================================================================ # Main # ============================================================================ main() { acquire_lock print_banner TMP=$(mktemp -d) local platform platform=$(detect_platform) log_step "Platform: $platform" log_step "Install directory: $DEST" mkdir -p "$DEST" # Try binary download first (unless --from-source) if [ "$FROM_SOURCE" -eq 0 ]; then resolve_version if [ -n "$VERSION" ]; then local downloaded=0 if [ "$downloaded" -eq 0 ]; then if download_release "$platform"; then downloaded=1 fi fi if [ "$downloaded" -eq 0 ]; then log_warn "Binary download failed, building from source..." build_from_source fi else log_warn "No release version found, building from source..." build_from_source fi else build_from_source fi # Post-install steps maybe_add_path fix_alias_conflicts check_conflicts install_skills # Verify installation if [ "$VERIFY" -eq 1 ]; then log_step "Running self-test..." "$DEST/$BINARY_NAME" --version || true log_success "Self-test complete" fi print_summary } # Run main only when executed directly (not when sourced for tests). # When piped (curl | bash), BASH_SOURCE[0] is empty - we want to run in that case too. # The :- syntax provides a default empty string to avoid "unbound variable" with set -u. # The outer { ... } ensures bash buffers the call before executing, protecting against # truncated downloads in curl|bash pipelines (see PR #126 follow-up). if [[ "${BASH_SOURCE[0]:-}" == "${0:-}" ]] || [[ -z "${BASH_SOURCE[0]:-}" ]]; then { main "$@"; } fi