#!/usr/bin/env bash # Homebrew 智能升级脚本(失败隔离、选择性重试与性能优化版) set -euo pipefail prepend_path_once() { local dir="$1" [[ -d "$dir" ]] || return 0 case ":$PATH:" in *":$dir:"*) ;; *) PATH="$dir:$PATH" ;; esac } prepend_path_once "/usr/local/sbin" prepend_path_once "/opt/homebrew/sbin" export PATH if [[ -t 1 ]]; then GREEN='\033[1;32m' YELLOW='\033[1;33m' BLUE='\033[1;34m' CYAN='\033[1;36m' NC='\033[0m' export HOMEBREW_COLOR=1 else GREEN='' YELLOW='' BLUE='' CYAN='' NC='' fi TERMINAL_WIDTH="" ACTIVE_LOG_FILE="" LOCK_ACQUIRED=0 LOCK_DIR="${HB_LOCK_DIR:-${TMPDIR:-/tmp}/brewup-$(id -u).lock}" FAILED_FORMULAE=() FAILED_CASKS=() usage() { cat <<'EOF' Usage: brew-upgrade-manager.sh [--width N] Options: --width N, --width=N Set output width (40-500). -h, --help Show this help. EOF } parse_args() { while (($# > 0)); do case "$1" in --width) [[ $# -ge 2 ]] || { echo "Error: --width requires a value." >&2; exit 2; } TERMINAL_WIDTH="$2" shift 2 ;; --width=*) TERMINAL_WIDTH="${1#*=}" shift ;; -h|--help) usage exit 0 ;; *) echo "Error: Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac done } resolve_terminal_width() { if [[ -z "$TERMINAL_WIDTH" ]]; then TERMINAL_WIDTH="${HB_TERMINAL_WIDTH:-}" fi if [[ -z "$TERMINAL_WIDTH" ]] && [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then TERMINAL_WIDTH="$(tput cols 2>/dev/null || true)" fi [[ -n "$TERMINAL_WIDTH" ]] || TERMINAL_WIDTH=130 if [[ ! "$TERMINAL_WIDTH" =~ ^[0-9]+$ ]] || ((TERMINAL_WIDTH < 40 || TERMINAL_WIDTH > 500)); then echo "Error: terminal width must be an integer between 40 and 500." >&2 exit 2 fi export COLUMNS="$TERMINAL_WIDTH" } separator() { local i for ((i = 0; i < TERMINAL_WIDTH; i++)); do printf '=' done printf '\n' } print_header() { echo -e "${BLUE}$1${NC}" } is_enabled() { case "${1:-}" in 1|true|TRUE|yes|YES|on|ON) return 0 ;; *) return 1 ;; esac } # shellcheck disable=SC2329 # Invoked by EXIT trap. cleanup_runtime() { if [[ -n "${ACTIVE_LOG_FILE:-}" && -f "$ACTIVE_LOG_FILE" ]]; then rm -f "$ACTIVE_LOG_FILE" fi if ((LOCK_ACQUIRED)); then rm -f "$LOCK_DIR/pid" rmdir "$LOCK_DIR" 2>/dev/null || true fi } # shellcheck disable=SC2329 # Invoked by INT trap. on_interrupt() { exit 130 } # shellcheck disable=SC2329 # Invoked by TERM trap. on_terminate() { exit 143 } trap cleanup_runtime EXIT trap on_interrupt INT trap on_terminate TERM acquire_lock() { local existing_pid="" if mkdir "$LOCK_DIR" 2>/dev/null; then LOCK_ACQUIRED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" return 0 fi if [[ -r "$LOCK_DIR/pid" ]]; then IFS= read -r existing_pid < "$LOCK_DIR/pid" || true fi if [[ "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2>/dev/null; then echo "Error: another brewup process is already running (PID ${existing_pid})." >&2 exit 75 fi rm -f "$LOCK_DIR/pid" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || { echo "Error: unable to remove stale lock directory: $LOCK_DIR" >&2 exit 75 } if ! mkdir "$LOCK_DIR" 2>/dev/null; then echo "Error: another brewup process acquired the lock." >&2 exit 75 fi LOCK_ACQUIRED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" } retry_attempts() { local attempts="${HB_RETRY_ATTEMPTS:-3}" if [[ ! "$attempts" =~ ^[1-9][0-9]*$ ]] || ((attempts > 10)); then attempts=3 fi printf '%s' "$attempts" } retry_delay_seconds() { local delay="${HB_RETRY_DELAY_SECONDS:-5}" if [[ ! "$delay" =~ ^[0-9]+$ ]] || ((delay > 300)); then delay=5 fi printf '%s' "$delay" } is_non_retryable_brew_error() { local log_file="$1" grep -Eiq \ 'Running Homebrew as root|Permission denied|Operation not permitted|No space left on device|checksum mismatch|SHA-?256 mismatch|certificate verify failed|SSL certificate problem|curl: \((3|23|26|60)\)|unknown (option|argument)|invalid (option|argument)' \ "$log_file" } is_retryable_brew_error() { local log_file="$1" grep -Eiq \ 'curl: \((5|6|7|18|28|35|52|55|56|92)\)|Transferred a partial file|Could not resolve host|timed out|timeout|connection (reset|refused|closed)|HTTP[^0-9]*(429|5[0-9]{2})|returned error: (429|5[0-9]{2})|temporarily unavailable|failed to download|download.*failed|RPC failed|early EOF|remote end hung up|unexpected disconnect' \ "$log_file" } run_brew_with_retries() { local label="$1" shift local attempts delay attempt status next_delay local pipeline_status=() attempts="$(retry_attempts)" delay="$(retry_delay_seconds)" ACTIVE_LOG_FILE="$(mktemp "${TMPDIR:-/tmp}/brewup-retry.XXXXXX.log")" for ((attempt = 1; attempt <= attempts; attempt++)); do if ((attempt > 1)); then echo -e "${YELLOW}Retrying ${label} (${attempt}/${attempts}) after ${delay}s...${NC}" sleep "$delay" next_delay=$((delay * 2)) ((next_delay > 300)) && next_delay=300 delay="$next_delay" fi : > "$ACTIVE_LOG_FILE" if brew "$@" 2>&1 | tee "$ACTIVE_LOG_FILE"; then status=0 else pipeline_status=("${PIPESTATUS[@]}") status="${pipeline_status[0]}" if ((${pipeline_status[1]:-0} != 0)); then echo "Error: failed to write retry log." >&2 status="${pipeline_status[1]}" fi fi if ((status == 0)); then rm -f "$ACTIVE_LOG_FILE" ACTIVE_LOG_FILE="" return 0 fi if is_non_retryable_brew_error "$ACTIVE_LOG_FILE"; then rm -f "$ACTIVE_LOG_FILE" ACTIVE_LOG_FILE="" return "$status" fi if ((attempt == attempts)) || ! is_retryable_brew_error "$ACTIVE_LOG_FILE"; then rm -f "$ACTIVE_LOG_FILE" ACTIVE_LOG_FILE="" return "$status" fi echo -e "${YELLOW}${label} failed with a retryable error.${NC}" done } get_outdated_formulae() { brew outdated --formula 2>/dev/null } get_outdated_casks() { brew outdated --cask --greedy 2>/dev/null } run_formula_upgrade() { local bulk_status=0 outdated_formulae remaining_formulae formula local failed_formulae=() brew upgrade --formula || bulk_status=$? if ((bulk_status == 0)); then return 0 fi echo -e "${YELLOW}Warning: Formula upgrade returned ${bulk_status}; checking remaining Formulae...${NC}" if ! outdated_formulae="$(get_outdated_formulae)"; then echo -e "${YELLOW}Warning: unable to verify outdated Formulae.${NC}" return "$bulk_status" fi if [[ -z "$outdated_formulae" ]]; then echo -e "${YELLOW}No outdated Formulae remain; treating the bulk error as recovered.${NC}" return 0 fi printf '%s\n' "$outdated_formulae" while IFS= read -r formula; do [[ -n "$formula" ]] || continue echo -e "${CYAN}Retrying Formula: ${formula}${NC}" if run_brew_with_retries "Formula ${formula}" upgrade --formula "$formula"; then : else failed_formulae+=("$formula") fi done <<< "$outdated_formulae" if ! remaining_formulae="$(get_outdated_formulae)"; then echo -e "${YELLOW}Warning: unable to verify Formulae after individual retries.${NC}" FAILED_FORMULAE=("${failed_formulae[@]}") return 1 fi if [[ -z "$remaining_formulae" ]]; then echo -e "${GREEN}All outdated Formulae were upgraded after isolated retries.${NC}" return 0 fi while IFS= read -r formula; do [[ -n "$formula" ]] && FAILED_FORMULAE+=("$formula") done <<< "$remaining_formulae" echo -e "${YELLOW}Formulae still outdated after retries:${NC}" printf '%s\n' "$remaining_formulae" return 1 } run_cask_upgrade() { local bulk_status=0 outdated_casks remaining_casks cask local bulk_args=(upgrade --cask --greedy) local failed_casks=() if is_enabled "${HB_CASK_FORCE:-0}"; then bulk_args+=(--force) echo -e "${YELLOW}HB_CASK_FORCE is enabled; existing Cask files may be overwritten.${NC}" fi brew "${bulk_args[@]}" || bulk_status=$? if ((bulk_status == 0)); then return 0 fi echo -e "${YELLOW}Warning: Cask upgrade returned ${bulk_status}; checking remaining Casks...${NC}" if ! outdated_casks="$(get_outdated_casks)"; then echo -e "${YELLOW}Warning: unable to verify outdated Casks.${NC}" return "$bulk_status" fi if [[ -z "$outdated_casks" ]]; then echo -e "${YELLOW}No outdated Casks remain; treating the bulk error as recovered.${NC}" return 0 fi printf '%s\n' "$outdated_casks" while IFS= read -r cask; do [[ -n "$cask" ]] || continue echo -e "${CYAN}Retrying Cask: ${cask} (without --force)${NC}" if run_brew_with_retries "Cask ${cask}" upgrade --cask --greedy "$cask"; then : else failed_casks+=("$cask") fi done <<< "$outdated_casks" if ! remaining_casks="$(get_outdated_casks)"; then echo -e "${YELLOW}Warning: unable to verify Casks after individual retries.${NC}" FAILED_CASKS=("${failed_casks[@]}") return 1 fi if [[ -z "$remaining_casks" ]]; then echo -e "${GREEN}All outdated Casks were upgraded after isolated retries.${NC}" return 0 fi while IFS= read -r cask; do [[ -n "$cask" ]] && FAILED_CASKS+=("$cask") done <<< "$remaining_casks" echo -e "${YELLOW}Casks still outdated after retries:${NC}" printf '%s\n' "$remaining_casks" return 1 } run_cleanup() { local cleanup_days="${HB_CLEANUP_DAYS:-}" if is_enabled "${HB_SKIP_CLEANUP:-0}"; then echo -e "${YELLOW}Cleanup skipped because HB_SKIP_CLEANUP is enabled.${NC}" return 0 fi if is_enabled "${HB_CLEANUP_ALL:-0}"; then brew cleanup --prune=all return fi if [[ -n "$cleanup_days" ]]; then if [[ ! "$cleanup_days" =~ ^[0-9]+$ ]] || ((cleanup_days > 3650)); then echo "Error: HB_CLEANUP_DAYS must be an integer between 0 and 3650." >&2 return 2 fi brew cleanup --prune="$cleanup_days" return fi brew cleanup } print_summary() { local overall_status="$1" separator if ((overall_status == 0)); then echo -e "${GREEN}All operations completed successfully.${NC}" else echo -e "${YELLOW}Homebrew upgrade completed with unresolved failures.${NC}" if ((${#FAILED_FORMULAE[@]} > 0)); then printf 'Failed Formulae: %s\n' "${FAILED_FORMULAE[*]}" fi if ((${#FAILED_CASKS[@]} > 0)); then printf 'Failed Casks: %s\n' "${FAILED_CASKS[*]}" fi fi separator } parse_args "$@" resolve_terminal_width command -v brew >/dev/null 2>&1 || { echo "Error: Homebrew is not installed or not in PATH." >&2; exit 127; } command -v tee >/dev/null 2>&1 || { echo "Error: tee is required." >&2; exit 127; } acquire_lock separator print_header "Step 1: Updating Homebrew repositories (brew update -v)" run_brew_with_retries "Homebrew repository update" update -v separator printf '\n' separator print_header "Step 2: Performing health check (brew doctor)" if is_enabled "${HB_SKIP_DOCTOR:-0}"; then echo -e "${YELLOW}brew doctor skipped because HB_SKIP_DOCTOR is enabled.${NC}" elif ! brew doctor; then echo -e "${YELLOW}Warning: 'brew doctor' detected issues. Manual review is recommended.${NC}" else echo "Homebrew environment is in good health." fi separator printf '\n' separator print_header "Step 3: Upgrading Formulae and Casks with failure isolation" overall_status=0 echo -e "\n${CYAN}>>> [1/2] Upgrading CLI Formulae...${NC}" if ! run_formula_upgrade; then overall_status=1 fi echo -e "\n${CYAN}>>> [2/2] Upgrading GUI Casks...${NC}" if ! run_cask_upgrade; then overall_status=1 fi separator printf '\n' separator print_header "Step 4: Cleaning up old files and caches" if ((overall_status != 0)) && ! is_enabled "${HB_CLEANUP_ON_FAILURE:-0}"; then echo -e "${YELLOW}Cleanup skipped because upgrades failed; cached downloads are preserved for the next retry.${NC}" elif ! run_cleanup; then overall_status=1 fi separator printf '\n' print_summary "$overall_status" exit "$overall_status"