#!/bin/bash set -euo pipefail # ============================================================ # ShowPulse CLI Installer # Interactive installer for ShowPulse command-line tools. # # Usage: # sudo bash <(curl -fsSL https://install.showpulse.com/cli) # sudo bash <(curl -fsSL https://install.showpulse.com/cli) --uninstall # ============================================================ # ============================================================ # SECTION 1: Constants & Globals # ============================================================ INSTALLER_VERSION="1.0.0" BASE_URL="https://install.showpulse.com" MANIFEST_URL="${BASE_URL}/tools.json" TOOLS_URL="${BASE_URL}/tools" GUM_VERSION="0.15.2" JQ_VERSION="1.7.1" USE_GUM=true MODE="install" # Environment (populated by detect_environment) OS="" ARCH="" PLATFORM="" INSTALL_DIR="" NEED_SUDO_FOR_INSTALL=false WORK_DIR="" SHA_CMD="" REAL_USER="" REAL_HOME="" # Paths to gum/jq binaries GUM="" JQ="" # Tool state — parallel indexed arrays (Bash 3.2 compatible) TOOL_COUNT=0 TOOL_KEYS=() TOOL_NAMES=() TOOL_DESCS=() TOOL_VERSIONS=() TOOL_TYPES=() TOOL_BIN_NAMES=() TOOL_HAS_INSTALL=() TOOL_NEEDS_SUDO=() TOOL_CHECKSUMS=() TOOL_INSTALLED_VERSIONS=() TOOL_STATUSES=() TOOL_INSTALL_PATHS=() # Selection state SELECTED_INDICES=() # Results tracking RESULTS_INSTALLED=() RESULTS_UPGRADED=() RESULTS_SKIPPED=() RESULTS_FAILED=() # ============================================================ # SECTION 2: Utility Functions # ============================================================ die() { echo "ERROR: $1" >&2 exit 1 } warn() { echo "WARNING: $1" >&2 } log() { echo "$1" >&2 } cleanup() { if [[ -n "${WORK_DIR:-}" && -d "${WORK_DIR:-}" ]]; then rm -rf "$WORK_DIR" fi } detect_sha_cmd() { if [[ "$OS" == "Darwin" ]]; then SHA_CMD="shasum -a 256" else SHA_CMD="sha256sum" fi } compute_sha256() { local filepath="$1" $SHA_CMD "$filepath" | awk '{print $1}' } # Numeric semver comparison. # Returns via exit code: 0 = equal, 1 = v1 > v2, 2 = v1 < v2 semver_compare() { local v1="$1" local v2="$2" if [[ "$v1" == "$v2" ]]; then return 0 fi local v1_parts v2_parts IFS='.' read -r -a v1_parts <<< "$v1" IFS='.' read -r -a v2_parts <<< "$v2" local i for i in 0 1 2; do local a="${v1_parts[$i]:-0}" local b="${v2_parts[$i]:-0}" if (( a > b )); then return 1 elif (( a < b )); then return 2 fi done return 0 } tool_index_by_key() { local key="$1" local i for (( i = 0; i < TOOL_COUNT; i++ )); do if [[ "${TOOL_KEYS[$i]}" == "$key" ]]; then echo "$i" return 0 fi done return 1 } # Run a command. If it exits with code 2, retry with sudo. # Returns the final exit code. run_with_sudo_retry() { local exit_code=0 "$@" 2>"$WORK_DIR/lifecycle_stderr" || exit_code=$? if [[ $exit_code -eq 2 ]]; then log "Retrying with sudo..." exit_code=0 sudo "$@" 2>"$WORK_DIR/lifecycle_stderr" || exit_code=$? fi return $exit_code } maybe_sudo() { if [[ "$NEED_SUDO_FOR_INSTALL" == true ]]; then sudo "$@" else "$@" fi } download() { local url="$1" local dest="$2" curl -fsSL "$url" -o "$dest" } # ============================================================ # SECTION 3: UI Wrapper Functions (gum / bash fallback) # ============================================================ ui_header() { local text="$1" if [[ "$USE_GUM" == true ]]; then "$GUM" style --bold --foreground 212 "$text" >&2 else echo "" >&2 echo "=== $text ===" >&2 fi } ui_info() { local text="$1" if [[ "$USE_GUM" == true ]]; then "$GUM" style --foreground 10 "$text" >&2 else echo "$text" >&2 fi } ui_warn() { local text="$1" if [[ "$USE_GUM" == true ]]; then "$GUM" style --foreground 11 "WARNING: $text" >&2 else echo "WARNING: $text" >&2 fi } ui_error() { local text="$1" if [[ "$USE_GUM" == true ]]; then "$GUM" style --foreground 9 --bold "ERROR: $text" >&2 else echo "ERROR: $text" >&2 fi } ui_success() { local text="$1" if [[ "$USE_GUM" == true ]]; then "$GUM" style --foreground 10 "✓ $text" >&2 else echo "OK: $text" >&2 fi } # Run a command with a spinner or simple status message. # Usage: ui_spinner "Downloading..." command arg1 arg2 ui_spinner() { local title="$1" shift if [[ "$USE_GUM" == true ]]; then "$GUM" spin --spinner dot --title "$title" -- "$@" >&2 else log "$title" "$@" fi } # Interactive multi-select. Sets SELECTED_INDICES=() with chosen indices. # Arguments: # $1 — header text # Reads SELECTION_LABELS[] and SELECTION_PRESELECTED[] globals ui_choose_multi() { local header="$1" SELECTED_INDICES=() if [[ "$USE_GUM" == true ]]; then # Build the selected items string for --selected flag local preselected_labels="" local i for i in "${SELECTION_PRESELECTED[@]}"; do if [[ -n "$preselected_labels" ]]; then preselected_labels="${preselected_labels},${SELECTION_LABELS[$i]}" else preselected_labels="${SELECTION_LABELS[$i]}" fi done # Build gum choose command local gum_output if [[ -n "$preselected_labels" ]]; then gum_output=$("$GUM" choose --no-limit \ --header "$header" \ --selected "$preselected_labels" \ "${SELECTION_LABELS[@]}") || true else gum_output=$("$GUM" choose --no-limit \ --header "$header" \ "${SELECTION_LABELS[@]}") || true fi # Map selected labels back to indices if [[ -n "$gum_output" ]]; then while IFS= read -r selected_line; do for (( i = 0; i < ${#SELECTION_LABELS[@]}; i++ )); do if [[ "${SELECTION_LABELS[$i]}" == "$selected_line" ]]; then SELECTED_INDICES+=("$i") break fi done done <<< "$gum_output" fi else # Bash fallback: numbered list with toggleable selection echo "" >&2 echo "$header" >&2 echo "" >&2 local i for (( i = 0; i < ${#SELECTION_LABELS[@]}; i++ )); do local marker="[ ]" local j for j in "${SELECTION_PRESELECTED[@]}"; do if [[ "$j" -eq "$i" ]]; then marker="[*]" break fi done echo " $((i + 1)). $marker ${SELECTION_LABELS[$i]}" >&2 done echo "" >&2 echo "Enter numbers to toggle (comma-separated), 'all', or press Enter for defaults:" >&2 local input read -r input < /dev/tty if [[ -z "$input" ]]; then # Accept defaults (pre-selected items) SELECTED_INDICES=("${SELECTION_PRESELECTED[@]}") elif [[ "$input" == "all" ]]; then for (( i = 0; i < ${#SELECTION_LABELS[@]}; i++ )); do SELECTED_INDICES+=("$i") done else # Parse comma-separated numbers local IFS=',' local nums IFS=',' read -r -a nums <<< "$input" local n for n in "${nums[@]}"; do # Trim whitespace n=$(echo "$n" | tr -d ' ') if [[ "$n" =~ ^[0-9]+$ ]] && (( n >= 1 && n <= ${#SELECTION_LABELS[@]} )); then SELECTED_INDICES+=("$((n - 1))") fi done fi fi } # Confirmation prompt. Returns 0 for yes, 1 for no. ui_confirm() { local prompt="$1" if [[ "$USE_GUM" == true ]]; then "$GUM" confirm "$prompt" < /dev/tty else local response echo "" >&2 read -r -p "$prompt [y/N] " response < /dev/tty [[ "$response" =~ ^[Yy] ]] fi } # ============================================================ # SECTION 4: Environment Detection # ============================================================ detect_environment() { # Detect OS OS="$(uname -s)" if [[ "$OS" != "Darwin" && "$OS" != "Linux" ]]; then die "Unsupported operating system: $OS (expected Darwin or Linux)" fi # Detect architecture ARCH="$(uname -m)" if [[ "$ARCH" == "aarch64" ]]; then ARCH="arm64" fi if [[ "$ARCH" != "arm64" && "$ARCH" != "x86_64" ]]; then die "Unsupported architecture: $ARCH (expected arm64 or x86_64)" fi # Compose platform string PLATFORM="$(echo "$OS" | tr '[:upper:]' '[:lower:]')-${ARCH}" # Determine the real user (important when running under sudo) if [[ -n "${SUDO_USER:-}" ]]; then REAL_USER="$SUDO_USER" if [[ "$OS" == "Darwin" ]]; then REAL_HOME=$(dscl . -read "/Users/$REAL_USER" NFSHomeDirectory | awk '{print $2}') else REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) fi else REAL_USER="${USER:-$(whoami)}" REAL_HOME="${HOME}" fi # Detect install directory if touch /usr/local/bin/.showpulse-test 2>/dev/null; then rm -f /usr/local/bin/.showpulse-test INSTALL_DIR="/usr/local/bin" else INSTALL_DIR="${REAL_HOME}/.showpulse/bin" mkdir -p "$INSTALL_DIR" NEED_SUDO_FOR_INSTALL=false fi # Create temp working directory WORK_DIR="$(mktemp -d)" trap cleanup EXIT # Detect SHA command detect_sha_cmd } # ============================================================ # SECTION 5: Bootstrap Dependencies (gum + jq) # ============================================================ bootstrap_jq() { if command -v jq &>/dev/null; then JQ="jq" return 0 fi log "Downloading jq ${JQ_VERSION}..." local jq_platform case "$PLATFORM" in darwin-arm64) jq_platform="jq-macos-arm64" ;; darwin-x86_64) jq_platform="jq-macos-amd64" ;; linux-x86_64) jq_platform="jq-linux-amd64" ;; linux-arm64) jq_platform="jq-linux-arm64" ;; *) die "No jq binary available for platform: $PLATFORM" ;; esac local jq_url="https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/${jq_platform}" if ! download "$jq_url" "$WORK_DIR/jq"; then die "Failed to download jq. Check your internet connection." fi chmod +x "$WORK_DIR/jq" JQ="$WORK_DIR/jq" if ! "$JQ" --version &>/dev/null; then die "Downloaded jq binary is not functional." fi } bootstrap_gum() { if command -v gum &>/dev/null; then GUM="gum" return 0 fi log "Downloading gum ${GUM_VERSION}..." local gum_os gum_arch gum_os="$OS" gum_arch="$ARCH" local gum_url="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_${gum_os}_${gum_arch}.tar.gz" if ! download "$gum_url" "$WORK_DIR/gum.tar.gz"; then warn "Failed to download gum. Falling back to basic prompts." USE_GUM=false return 0 fi tar -xzf "$WORK_DIR/gum.tar.gz" -C "$WORK_DIR" 2>/dev/null # gum extracts into a subdirectory like gum_0.15.2_Darwin_arm64/ GUM="$(find "$WORK_DIR" -name gum -type f -not -name '*.tar.gz' | head -1)" if [[ -z "$GUM" ]]; then warn "Failed to extract gum. Falling back to basic prompts." USE_GUM=false return 0 fi chmod +x "$GUM" if ! "$GUM" --version &>/dev/null; then warn "Downloaded gum binary is not functional. Falling back to basic prompts." USE_GUM=false return 0 fi } bootstrap_dependencies() { bootstrap_jq bootstrap_gum } # Create shared ShowPulse directories with appropriate permissions. # Runs once during install so individual tools don't each need to handle this. ensure_showpulse_directories() { if [[ "$OS" == "Darwin" ]]; then mkdir -p /Library/Logs/ShowPulse mkdir -p "/Library/Application Support/ShowPulse" mkdir -p /etc/showpulse chmod 755 /Library/Logs/ShowPulse chmod 755 "/Library/Application Support/ShowPulse" chmod 755 /etc/showpulse else mkdir -p /etc/showpulse mkdir -p /var/lib/showpulse chmod 755 /etc/showpulse chmod 755 /var/lib/showpulse fi } # ============================================================ # SECTION 6: Manifest Fetch & Parse # ============================================================ fetch_manifest() { if ! download "$MANIFEST_URL" "$WORK_DIR/tools.json"; then die "Cannot reach ${BASE_URL}. Check your internet connection." fi local mv mv="$("$JQ" -r '.manifest_version' "$WORK_DIR/tools.json")" if [[ "$mv" != "1" ]]; then die "Unsupported manifest version: $mv. Please re-download the installer from ${BASE_URL}/cli" fi } parse_manifest() { local keys keys="$("$JQ" -r '.tools | keys[]' "$WORK_DIR/tools.json")" TOOL_COUNT=0 TOOL_KEYS=() TOOL_NAMES=() TOOL_DESCS=() TOOL_VERSIONS=() TOOL_TYPES=() TOOL_BIN_NAMES=() TOOL_HAS_INSTALL=() TOOL_NEEDS_SUDO=() TOOL_CHECKSUMS=() TOOL_INSTALLED_VERSIONS=() TOOL_STATUSES=() TOOL_INSTALL_PATHS=() while IFS= read -r key; do [[ -z "$key" ]] && continue # Extract all fields in one jq call local fields fields="$("$JQ" -r --arg k "$key" \ '.tools[$k] | [.name, .description, .version, .type, .bin_name, (.has_install|tostring), ((.install_needs_sudo // false)|tostring)] | @tsv' \ "$WORK_DIR/tools.json")" local name desc version type bin_name has_install needs_sudo IFS=$'\t' read -r name desc version type bin_name has_install needs_sudo <<< "$fields" # Determine checksum for this platform local checksum checksum="$("$JQ" -r --arg k "$key" --arg p "$PLATFORM" \ '.tools[$k].platforms[$p].sha256 // .tools[$k].platforms["all"].sha256 // empty' \ "$WORK_DIR/tools.json")" # Skip if no matching platform if [[ -z "$checksum" ]]; then continue fi TOOL_KEYS+=("$key") TOOL_NAMES+=("$name") TOOL_DESCS+=("$desc") TOOL_VERSIONS+=("$version") TOOL_TYPES+=("$type") TOOL_BIN_NAMES+=("$bin_name") TOOL_HAS_INSTALL+=("$has_install") TOOL_NEEDS_SUDO+=("$needs_sudo") TOOL_CHECKSUMS+=("$checksum") TOOL_INSTALLED_VERSIONS+=("") TOOL_STATUSES+=("not_installed") TOOL_INSTALL_PATHS+=("") TOOL_COUNT=$((TOOL_COUNT + 1)) done <<< "$keys" } # ============================================================ # SECTION 7: Tool Status Detection # ============================================================ detect_tool_statuses() { local i for (( i = 0; i < TOOL_COUNT; i++ )); do local bin_name="${TOOL_BIN_NAMES[$i]}" local found_path="" # Check INSTALL_DIR first if [[ -x "$INSTALL_DIR/$bin_name" ]]; then found_path="$INSTALL_DIR/$bin_name" elif [[ -d "$INSTALL_DIR/$bin_name" && -x "$INSTALL_DIR/$bin_name/$bin_name" ]]; then # Binary tool installed as directory found_path="$INSTALL_DIR/$bin_name/$bin_name" fi # Also check the other common location if [[ -z "$found_path" ]]; then if [[ "$INSTALL_DIR" != "/usr/local/bin" && -x "/usr/local/bin/$bin_name" ]]; then found_path="/usr/local/bin/$bin_name" elif [[ "$INSTALL_DIR" != "${REAL_HOME}/.showpulse/bin" ]]; then if [[ -x "${REAL_HOME}/.showpulse/bin/$bin_name" ]]; then found_path="${REAL_HOME}/.showpulse/bin/$bin_name" fi fi fi # Fall back to PATH lookup if [[ -z "$found_path" ]]; then found_path="$(command -v "$bin_name" 2>/dev/null || true)" fi if [[ -n "$found_path" ]]; then TOOL_INSTALL_PATHS[i]="$found_path" local installed_version installed_version="$("$found_path" --version 2>/dev/null || echo "")" if [[ -n "$installed_version" ]]; then TOOL_INSTALLED_VERSIONS[i]="$installed_version" local cmp_result=0 semver_compare "$installed_version" "${TOOL_VERSIONS[$i]}" || cmp_result=$? if [[ $cmp_result -eq 0 ]]; then TOOL_STATUSES[i]="up_to_date" elif [[ $cmp_result -eq 2 ]]; then TOOL_STATUSES[i]="outdated" else # Installed version is newer than manifest — treat as up to date TOOL_STATUSES[i]="up_to_date" fi else # Can't determine version, treat as needing install TOOL_STATUSES[i]="not_installed" fi else TOOL_STATUSES[i]="not_installed" TOOL_INSTALLED_VERSIONS[i]="" TOOL_INSTALL_PATHS[i]="" fi done } # Scan filesystem for installed showpulse-* tools, including orphans. # Used by uninstall mode. Adds orphan entries to the parallel arrays. scan_installed_tools() { detect_tool_statuses # Scan both install locations for showpulse-* binaries not already tracked local search_dirs=("/usr/local/bin" "${REAL_HOME}/.showpulse/bin") local dir for dir in "${search_dirs[@]}"; do [[ -d "$dir" ]] || continue local entry for entry in "$dir"/showpulse-*; do [[ -e "$entry" ]] || continue local bin_name bin_name="$(basename "$entry")" # Skip if already tracked in manifest local already_tracked=false local i for (( i = 0; i < TOOL_COUNT; i++ )); do if [[ "${TOOL_BIN_NAMES[$i]}" == "$bin_name" ]]; then already_tracked=true break fi done if [[ "$already_tracked" == false ]]; then # Orphan tool — add to arrays local orphan_version if [[ -x "$entry" ]]; then orphan_version="$("$entry" --version 2>/dev/null || echo "unknown")" elif [[ -d "$entry" && -x "$entry/$bin_name" ]]; then orphan_version="$("$entry/$bin_name" --version 2>/dev/null || echo "unknown")" else orphan_version="unknown" fi TOOL_KEYS+=("${bin_name#showpulse-}") TOOL_NAMES+=("$bin_name") TOOL_DESCS+=("orphan — not in current manifest") TOOL_VERSIONS+=("") TOOL_TYPES+=("unknown") TOOL_BIN_NAMES+=("$bin_name") TOOL_HAS_INSTALL+=("unknown") TOOL_NEEDS_SUDO+=("false") TOOL_CHECKSUMS+=("") TOOL_INSTALLED_VERSIONS+=("$orphan_version") TOOL_STATUSES+=("orphan") TOOL_INSTALL_PATHS+=("$entry") TOOL_COUNT=$((TOOL_COUNT + 1)) fi done done } # ============================================================ # SECTION 8: Install Operations # ============================================================ SELECTION_LABELS=() SELECTION_PRESELECTED=() build_install_selection_labels() { SELECTION_LABELS=() SELECTION_PRESELECTED=() local i for (( i = 0; i < TOOL_COUNT; i++ )); do local status="${TOOL_STATUSES[$i]}" local name="${TOOL_NAMES[$i]}" local desc="${TOOL_DESCS[$i]}" local manifest_ver="${TOOL_VERSIONS[$i]}" local installed_ver="${TOOL_INSTALLED_VERSIONS[$i]}" local label case "$status" in not_installed) label="⬡ ${name} — ${desc} (not installed)" SELECTION_PRESELECTED+=("$i") ;; outdated) label="⬢ ${name} — ${desc} (${installed_ver} → ${manifest_ver})" SELECTION_PRESELECTED+=("$i") ;; up_to_date) label="✓ ${name} — ${desc} (up to date: ${manifest_ver})" ;; esac SELECTION_LABELS+=("$label") done } select_tools_for_install() { if [[ ${#SELECTION_LABELS[@]} -eq 0 ]]; then ui_info "No tools available for your platform." exit 0 fi ui_choose_multi "Select tools to install/update:" if [[ ${#SELECTED_INDICES[@]} -eq 0 ]]; then ui_info "No tools selected. Nothing to do." exit 0 fi } download_and_verify() { local i="$1" local key="${TOOL_KEYS[$i]}" local bin_name="${TOOL_BIN_NAMES[$i]}" local type="${TOOL_TYPES[$i]}" local expected_checksum="${TOOL_CHECKSUMS[$i]}" # Determine download URL and local filename local url filename if [[ "$type" == "script" ]]; then filename="${bin_name}.sh" url="${TOOLS_URL}/${key}/${filename}" else filename="${bin_name}-${PLATFORM}.tar.gz" url="${TOOLS_URL}/${key}/${filename}" fi # Download if ! download "$url" "$WORK_DIR/$filename"; then ui_error "Failed to download ${TOOL_NAMES[$i]}" return 1 fi # Verify checksum local actual_checksum actual_checksum="$(compute_sha256 "$WORK_DIR/$filename")" if [[ "$actual_checksum" != "$expected_checksum" ]]; then ui_error "Checksum mismatch for ${TOOL_NAMES[$i]} (expected: ${expected_checksum:0:12}..., got: ${actual_checksum:0:12}...)" return 1 fi # Extract or set permissions if [[ "$type" == "script" ]]; then chmod +x "$WORK_DIR/$filename" else tar -xzf "$WORK_DIR/$filename" -C "$WORK_DIR" fi return 0 } install_tool() { local i="$1" local bin_name="${TOOL_BIN_NAMES[$i]}" local type="${TOOL_TYPES[$i]}" if [[ "$type" == "script" ]]; then # Install script: copy and drop .sh extension maybe_sudo cp "$WORK_DIR/${bin_name}.sh" "$INSTALL_DIR/${bin_name}" maybe_sudo chmod +x "$INSTALL_DIR/${bin_name}" else # Install binary directory maybe_sudo cp -R "$WORK_DIR/${bin_name}-${PLATFORM}" "$INSTALL_DIR/${bin_name}" maybe_sudo chmod +x "$INSTALL_DIR/${bin_name}/${bin_name}" fi # Verify installation local check_path if [[ "$type" == "script" ]]; then check_path="$INSTALL_DIR/$bin_name" else check_path="$INSTALL_DIR/$bin_name/$bin_name" fi local installed_ver installed_ver="$("$check_path" --version 2>/dev/null || echo "")" if [[ -z "$installed_ver" ]]; then ui_error "Installed ${TOOL_NAMES[$i]} but --version check failed" return 1 fi return 0 } run_tool_lifecycle() { local i="$1" local flag="$2" if [[ "${TOOL_HAS_INSTALL[$i]}" != "true" ]]; then return 0 fi local bin_name="${TOOL_BIN_NAMES[$i]}" local type="${TOOL_TYPES[$i]}" local tool_path if [[ "$type" == "script" ]]; then tool_path="$INSTALL_DIR/$bin_name" else tool_path="$INSTALL_DIR/$bin_name/$bin_name" fi local exit_code=0 if [[ "${TOOL_NEEDS_SUDO[$i]}" == "true" ]]; then sudo "$tool_path" "$flag" 2>"$WORK_DIR/lifecycle_stderr" || exit_code=$? else run_with_sudo_retry "$tool_path" "$flag" || exit_code=$? fi if [[ $exit_code -ne 0 ]]; then local stderr_output="" if [[ -f "$WORK_DIR/lifecycle_stderr" ]]; then stderr_output="$(cat "$WORK_DIR/lifecycle_stderr")" fi ui_error "${TOOL_NAMES[$i]} ${flag} failed (exit code: $exit_code)" if [[ -n "$stderr_output" ]]; then log " $stderr_output" fi return 1 fi return 0 } do_install() { ui_header "ShowPulse CLI Installer v${INSTALLER_VERSION}" detect_tool_statuses build_install_selection_labels select_tools_for_install local i idx local download_succeeded=() # Phase 5: Download & Verify ui_header "Downloading tools..." for idx in "${SELECTED_INDICES[@]}"; do local display="${TOOL_NAMES[$idx]} v${TOOL_VERSIONS[$idx]}" if ui_spinner "Downloading $display..." download_and_verify "$idx"; then download_succeeded+=("$idx") else RESULTS_SKIPPED+=("${TOOL_NAMES[$idx]} (download/checksum failed)") fi done if [[ ${#download_succeeded[@]} -eq 0 ]]; then ui_error "All downloads failed. Nothing to install." return 1 fi # Phase 6: Install ui_header "Installing tools..." local install_succeeded=() for idx in "${download_succeeded[@]}"; do local display="${TOOL_NAMES[$idx]} v${TOOL_VERSIONS[$idx]}" if ui_spinner "Installing $display..." install_tool "$idx"; then install_succeeded+=("$idx") else RESULTS_FAILED+=("${TOOL_NAMES[$idx]} (install failed)") fi done # Ensure shared directories exist before tools run lifecycle commands ensure_showpulse_directories # Phase 7: Tool Setup for idx in "${install_succeeded[@]}"; do local status="${TOOL_STATUSES[$idx]}" if [[ "${TOOL_HAS_INSTALL[$idx]}" == "true" ]]; then local flag if [[ "$status" == "not_installed" ]]; then flag="--install" else flag="--upgrade" fi local display="${TOOL_NAMES[$idx]} ${flag}" if ui_spinner "Running $display..." run_tool_lifecycle "$idx" "$flag"; then if [[ "$status" == "not_installed" ]]; then RESULTS_INSTALLED+=("${TOOL_NAMES[$idx]} v${TOOL_VERSIONS[$idx]}") else RESULTS_UPGRADED+=("${TOOL_NAMES[$idx]} ${TOOL_INSTALLED_VERSIONS[$idx]} → ${TOOL_VERSIONS[$idx]}") fi else RESULTS_FAILED+=("${TOOL_NAMES[$idx]} (${flag} failed)") fi else if [[ "$status" == "not_installed" ]]; then RESULTS_INSTALLED+=("${TOOL_NAMES[$idx]} v${TOOL_VERSIONS[$idx]}") else RESULTS_UPGRADED+=("${TOOL_NAMES[$idx]} ${TOOL_INSTALLED_VERSIONS[$idx]} → ${TOOL_VERSIONS[$idx]}") fi fi done # Phase 8: PATH management manage_path # Phase 9: Summary print_summary } # ============================================================ # SECTION 9: Uninstall Operations # ============================================================ build_uninstall_selection_labels() { SELECTION_LABELS=() SELECTION_PRESELECTED=() local i for (( i = 0; i < TOOL_COUNT; i++ )); do local status="${TOOL_STATUSES[$i]}" local bin_name="${TOOL_BIN_NAMES[$i]}" local install_path="${TOOL_INSTALL_PATHS[$i]}" local installed_ver="${TOOL_INSTALLED_VERSIONS[$i]}" local label # Only show installed or orphan tools if [[ "$status" == "not_installed" ]]; then continue fi local install_dir install_dir="$(dirname "$install_path")" if [[ "$status" == "orphan" ]]; then label="⚠ ${bin_name} v${installed_ver} (${install_dir}) — orphan, not in current manifest" else label="${bin_name} v${installed_ver} (${install_dir})" fi SELECTION_LABELS+=("$label") # Store the actual tool index for later lookup # We use a parallel array since SELECTION_LABELS indices != TOOL_* indices UNINSTALL_LABEL_TO_TOOL_INDEX+=("$i") done } select_tools_for_uninstall() { if [[ ${#SELECTION_LABELS[@]} -eq 0 ]]; then ui_info "No ShowPulse tools found on this system." exit 0 fi ui_choose_multi "Select tools to uninstall:" if [[ ${#SELECTED_INDICES[@]} -eq 0 ]]; then ui_info "No tools selected. Nothing to do." exit 0 fi # Confirm local count="${#SELECTED_INDICES[@]}" if ! ui_confirm "Remove ${count} selected tool(s) and their configurations?"; then ui_info "Uninstall cancelled." exit 0 fi } uninstall_tool() { local i="$1" local bin_name="${TOOL_BIN_NAMES[$i]}" local install_path="${TOOL_INSTALL_PATHS[$i]}" local has_install="${TOOL_HAS_INSTALL[$i]}" local status="${TOOL_STATUSES[$i]}" # Run --uninstall lifecycle if supported if [[ "$has_install" == "true" || "$status" == "orphan" ]]; then local tool_exec if [[ -x "$install_path" ]]; then tool_exec="$install_path" elif [[ -d "$install_path" && -x "$install_path/$bin_name" ]]; then tool_exec="$install_path/$bin_name" else tool_exec="$install_path" fi local exit_code=0 run_with_sudo_retry "$tool_exec" --uninstall || exit_code=$? if [[ $exit_code -eq 1 ]]; then ui_warn "${bin_name}: partial cleanup (--uninstall exited with errors)" local stderr_output="" if [[ -f "$WORK_DIR/lifecycle_stderr" ]]; then stderr_output="$(cat "$WORK_DIR/lifecycle_stderr")" fi if [[ -n "$stderr_output" ]]; then log " $stderr_output" fi if [[ "$status" == "orphan" ]]; then ui_warn "Manual cleanup may be needed. Check:" if [[ "$OS" == "Darwin" ]]; then log " launchctl list | grep showpulse" else log " systemctl list-units 'showpulse-*'" fi fi elif [[ $exit_code -ne 0 ]]; then ui_error "${bin_name}: --uninstall failed (exit code: $exit_code)" fi fi # Remove binary/script/directory if [[ -e "$install_path" ]]; then maybe_sudo rm -rf "$install_path" fi # For binary tools, the install_path might be the executable inside the dir local parent_dir parent_dir="$(dirname "$install_path")" local parent_base parent_base="$(basename "$parent_dir")" if [[ "$parent_base" == "$bin_name" && -d "$parent_dir" ]]; then maybe_sudo rm -rf "$parent_dir" fi # Verify removal if command -v "$bin_name" &>/dev/null; then ui_warn "${bin_name} may still be accessible on PATH" fi } UNINSTALL_LABEL_TO_TOOL_INDEX=() do_uninstall() { ui_header "ShowPulse CLI Uninstaller" UNINSTALL_LABEL_TO_TOOL_INDEX=() scan_installed_tools build_uninstall_selection_labels select_tools_for_uninstall # Map selection indices back to tool indices local idx for idx in "${SELECTED_INDICES[@]}"; do local tool_idx="${UNINSTALL_LABEL_TO_TOOL_INDEX[$idx]}" local bin_name="${TOOL_BIN_NAMES[$tool_idx]}" ui_spinner "Uninstalling ${bin_name}..." uninstall_tool "$tool_idx" RESULTS_INSTALLED+=("${bin_name}") done # Environment cleanup cleanup_showpulse_dir # Summary print_uninstall_summary } # ============================================================ # SECTION 10: PATH Management # ============================================================ manage_path() { # Only relevant if using ~/.showpulse/bin if [[ "$INSTALL_DIR" != "${REAL_HOME}/.showpulse/bin" ]]; then return 0 fi # Check if already in PATH if [[ ":$PATH:" == *":${REAL_HOME}/.showpulse/bin:"* ]]; then return 0 fi local shell_config="" local user_shell user_shell="$(basename "${SHELL:-/bin/bash}")" case "$user_shell" in zsh) shell_config="${REAL_HOME}/.zshrc" ;; bash) if [[ "$OS" == "Darwin" ]]; then shell_config="${REAL_HOME}/.bash_profile" else shell_config="${REAL_HOME}/.bashrc" fi ;; *) shell_config="${REAL_HOME}/.profile" ;; esac if ui_confirm "Add ~/.showpulse/bin to your PATH in $(basename "$shell_config")?"; then local export_line='export PATH="$HOME/.showpulse/bin:$PATH" # Added by ShowPulse CLI installer' echo "" >> "$shell_config" echo "$export_line" >> "$shell_config" # Fix ownership if we're running as root if [[ -n "${SUDO_USER:-}" ]]; then chown "${REAL_USER}" "$shell_config" fi ui_success "Added to $shell_config" ui_info "Run 'source ${shell_config}' or restart your shell to update PATH." else ui_info "To add manually, run:" log " echo 'export PATH=\"\$HOME/.showpulse/bin:\$PATH\"' >> $shell_config" fi } cleanup_showpulse_dir() { local showpulse_bin="${REAL_HOME}/.showpulse/bin" local showpulse_dir="${REAL_HOME}/.showpulse" # Check if the bin directory exists and is empty if [[ -d "$showpulse_bin" ]]; then local remaining remaining="$(find "$showpulse_bin" -maxdepth 1 -mindepth 1 2>/dev/null | wc -l | tr -d ' ')" if [[ "$remaining" -eq 0 ]]; then if ui_confirm "${REAL_HOME}/.showpulse/bin is empty. Remove it and clean PATH entry?"; then rmdir "$showpulse_bin" 2>/dev/null || true # Remove parent if also empty if [[ -d "$showpulse_dir" ]]; then remaining="$(find "$showpulse_dir" -maxdepth 1 -mindepth 1 2>/dev/null | wc -l | tr -d ' ')" if [[ "$remaining" -eq 0 ]]; then rmdir "$showpulse_dir" 2>/dev/null || true fi fi # Remove PATH entry from shell configs local config_files=("${REAL_HOME}/.zshrc" "${REAL_HOME}/.bashrc" "${REAL_HOME}/.bash_profile" "${REAL_HOME}/.profile") local config for config in "${config_files[@]}"; do if [[ -f "$config" ]] && grep -q "# Added by ShowPulse CLI installer" "$config"; then sed -i.bak '/# Added by ShowPulse CLI installer/d' "$config" rm -f "${config}.bak" ui_info "Removed PATH entry from $(basename "$config")" fi done fi fi fi } # ============================================================ # SECTION 11: Summary / Reporting # ============================================================ print_summary() { echo "" >&2 ui_header "Summary" if [[ ${#RESULTS_INSTALLED[@]} -gt 0 ]]; then ui_success "Installed:" local item for item in "${RESULTS_INSTALLED[@]}"; do log " $item" done fi if [[ ${#RESULTS_UPGRADED[@]} -gt 0 ]]; then ui_success "Upgraded:" local item for item in "${RESULTS_UPGRADED[@]}"; do log " $item" done fi if [[ ${#RESULTS_SKIPPED[@]} -gt 0 ]]; then ui_warn "Skipped:" local item for item in "${RESULTS_SKIPPED[@]}"; do log " $item" done fi if [[ ${#RESULTS_FAILED[@]} -gt 0 ]]; then ui_error "Failed:" local item for item in "${RESULTS_FAILED[@]}"; do log " $item" done fi echo "" >&2 ui_info "Install location: $INSTALL_DIR" } print_uninstall_summary() { echo "" >&2 ui_header "Uninstall Summary" if [[ ${#RESULTS_INSTALLED[@]} -gt 0 ]]; then ui_success "Removed:" local item for item in "${RESULTS_INSTALLED[@]}"; do log " $item" done fi if [[ ${#RESULTS_FAILED[@]} -gt 0 ]]; then ui_error "Failed:" local item for item in "${RESULTS_FAILED[@]}"; do log " $item" done fi } # ============================================================ # SECTION 12: Main Entry Point # ============================================================ main() { if [[ "${1:-}" == "--uninstall" ]]; then MODE="uninstall" elif [[ "${1:-}" == "--version" ]]; then echo "$INSTALLER_VERSION" exit 0 fi detect_environment bootstrap_dependencies fetch_manifest parse_manifest if [[ "$MODE" == "uninstall" ]]; then do_uninstall else do_install fi } main "$@"