#!/usr/bin/env bash # lib/brew.sh — Homebrew install + Brewfile dump/restore. ssr::brew::ensure_installed() { if command -v brew >/dev/null 2>&1; then return 0 fi # Homebrew's install script requires sudo on macOS to write to /opt/homebrew # (Apple Silicon) or /usr/local (Intel). Check before running it so we can # give a clear error instead of a cryptic failure. if ! sudo -n true 2>/dev/null; then ssr::warn "Homebrew is not installed and sudo access is required to install it." ssr::warn "Please run the following as an admin user and re-run ssr restore:" ssr::warn " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" ssr::die "Cannot install Homebrew without sudo access." fi ssr::log "Homebrew not found — installing" # NONINTERACTIVE=1 skips the confirmation prompt; sudo is available (checked above). NONINTERACTIVE=1 /bin/bash -c \ "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Ensure brew is on PATH for both Apple Silicon and Intel layouts. if [[ -x /opt/homebrew/bin/brew ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [[ -x /usr/local/bin/brew ]]; then eval "$(/usr/local/bin/brew shellenv)" fi command -v brew >/dev/null 2>&1 || ssr::die "brew still not on PATH after install" } ssr::brew::update() { ssr::log "brew update" brew update --quiet } ssr::brew::upgrade() { [[ "${SSR_BREW_UPGRADE:-true}" == "true" ]] || return 0 ssr::log "brew upgrade" brew upgrade --quiet || ssr::warn "brew upgrade reported errors (continuing)" } ssr::brew::cleanup() { [[ "${SSR_BREW_CLEANUP:-true}" == "true" ]] || return 0 ssr::log "brew cleanup" brew cleanup --quiet || true } # Homebrew taps that have been deprecated and emptied upstream. Keeping them # in the Brewfile causes "Tapping X has failed!" errors on every restore. # brew bundle dump still emits them if they are locally present, so we strip # them from the file after dumping. _SSR_DEPRECATED_TAPS=( homebrew/core homebrew/cask homebrew/services homebrew/test-bot homebrew/bundle homebrew/cask-fonts homebrew/cask-drivers homebrew/cask-versions ) _ssr_brew_strip_deprecated_taps() { local file="$1" local pattern # Build an alternation pattern: homebrew/core|homebrew/cask|… pattern="$(printf '%s|' "${_SSR_DEPRECATED_TAPS[@]}")" pattern="${pattern%|}" # strip trailing | local tmp; tmp="$(mktemp)" # Match lines like: tap "homebrew/services" grep -vE "^tap \"(${pattern})\"" "$file" > "$tmp" || true mv -f "$tmp" "$file" } # Dumps Brewfile to the cloud backup dir with a timestamped rotation copy. ssr::brew::dump() { local backup_dir="$1" brewfile="$1/Brewfile" ssr::ensure_dir "$backup_dir" if [[ -f "$brewfile" ]]; then cp -f "$brewfile" "$brewfile.bak" fi ssr::log "brew bundle dump → $brewfile" ( cd "$backup_dir" brew bundle dump --describe --quiet --force --file=Brewfile ) # Remove deprecated taps that would fail on restore. local removed removed="$(grep -cE "^tap \"($(printf '%s|' "${_SSR_DEPRECATED_TAPS[@]}" | sed 's/|$//'))\"" \ "$brewfile" 2>/dev/null || echo 0)" _ssr_brew_strip_deprecated_taps "$brewfile" [[ "$removed" -gt 0 ]] && ssr::log "Stripped $removed deprecated tap(s) from Brewfile." ssr::ok "Brewfile written ($(wc -l < "$brewfile" | tr -d ' ') lines)" } # Pre-authenticate sudo once and keep credentials alive in the background. # macOS caches sudo credentials for 5 minutes (300s); the keepalive loop # refreshes every 60s so Homebrew cask installers never hit a timeout mid-run. # # Usage: # _ssr_sudo_keepalive_start # prompts once, starts background loop # … long-running commands … # _ssr_sudo_keepalive_stop # kills the loop _SSR_SUDO_KEEPALIVE_PID="" _ssr_sudo_keepalive_start() { # Check whether sudo is available at all (may already be cached). if ! sudo -v 2>/dev/null; then ssr::warn "sudo authentication failed — cask installs may prompt per-package." return 0 fi ssr::ok "sudo credentials cached — Homebrew casks will not prompt during install." # Background loop: re-validate every 60s to prevent 5-minute timeout. ( while true; do sleep 60 sudo -n true 2>/dev/null || break done ) & _SSR_SUDO_KEEPALIVE_PID=$! } _ssr_sudo_keepalive_stop() { if [[ -n "$_SSR_SUDO_KEEPALIVE_PID" ]]; then kill "$_SSR_SUDO_KEEPALIVE_PID" 2>/dev/null || true wait "$_SSR_SUDO_KEEPALIVE_PID" 2>/dev/null || true _SSR_SUDO_KEEPALIVE_PID="" fi } ssr::brew::restore() { local brewfile="$1" [[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile" ssr::log "brew bundle install ← $brewfile" # Authenticate sudo once up front so Homebrew cask installers don't # prompt individually for each package that needs admin privileges. _ssr_sudo_keepalive_start # HOMEBREW_NO_QUARANTINE=1: skip the post-install `xattr -d com.apple.quarantine` # pass that Homebrew runs on every cask. That xattr walk can block for # several minutes on large apps (WhatsApp, Electron apps, …) because macOS # Gatekeeper verifies every binary in the bundle. Apps will show a standard # one-time Gatekeeper dialog on first launch instead — normal macOS behaviour. # # HOMEBREW_NO_INSTALL_CLEANUP=1: skip per-package cache cleanup after each # install. A single `brew cleanup` runs in ssr-sync after the full bundle. # # MAS_NO_AUTO_INDEX=1: suppress per-app mdimport after every App Store app. # One batch mdimport runs in the background below instead. local rc=0 HOMEBREW_NO_QUARANTINE=1 \ HOMEBREW_NO_INSTALL_CLEANUP=1 \ MAS_NO_AUTO_INDEX=1 \ brew bundle install --file="$brewfile" --verbose || rc=$? _ssr_sudo_keepalive_stop # Trigger Spotlight indexing in the background so it never blocks the restore. ssr::log "Triggering background Spotlight index of /Applications" mdimport /Applications &>/dev/null & [[ -d "$HOME/Applications" ]] && mdimport "$HOME/Applications" &>/dev/null & if [[ $rc -ne 0 ]]; then ssr::warn "brew bundle install finished with errors (rc=$rc) — some packages were not installed." ssr::warn "Rerun manually: brew bundle install --file=\"$brewfile\"" else ssr::ok "brew bundle install completed successfully." fi }