#!/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 # 禁止 Homebrew 在每个安装步骤后自动清理;统一在最后一步彻底清理。 export HOMEBREW_NO_INSTALL_CLEANUP=1 hex_channel_to_16bit() { local hex="$1" value maximum bits [[ "$hex" =~ ^[[:xdigit:]]{1,4}$ ]] || return 1 value=$((16#$hex)) bits=$((${#hex} * 4)) maximum=$(((1 << bits) - 1)) printf '%s' "$((value * 65535 / maximum))" } query_terminal_background_rgb() { local response="" red_hex green_hex blue_hex red green blue [[ -r /dev/tty && -w /dev/tty ]] || return 1 command -v stty >/dev/null 2>&1 || return 1 command -v dd >/dev/null 2>&1 || return 1 # OSC 11 是跨终端的背景色查询协议。使用子 shell 确保任何退出路径都会 # 恢复 TTY 设置;短暂无输入超时也避免不支持该协议的终端阻塞流程。 response="$({ saved_tty="$(stty -g < /dev/tty 2>/dev/null)" || exit 1 trap 'stty "$saved_tty" < /dev/tty 2>/dev/null || true' EXIT HUP INT TERM stty -echo -icanon min 0 time 3 < /dev/tty 2>/dev/null || exit 1 printf '\033]11;?\007' > /dev/tty LC_ALL=C dd bs=1 count=128 < /dev/tty 2>/dev/null || true } 2>/dev/null)" if [[ "$response" =~ rgb:([[:xdigit:]]{1,4})/([[:xdigit:]]{1,4})/([[:xdigit:]]{1,4}) ]]; then red_hex="${BASH_REMATCH[1]}" green_hex="${BASH_REMATCH[2]}" blue_hex="${BASH_REMATCH[3]}" red="$(hex_channel_to_16bit "$red_hex")" || return 1 green="$(hex_channel_to_16bit "$green_hex")" || return 1 blue="$(hex_channel_to_16bit "$blue_hex")" || return 1 printf '%s %s %s' "$red" "$green" "$blue" return 0 fi return 1 } lowercase() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' } detect_apple_appearance_theme() { local appearance="" product_name="" [[ "$(uname -s 2>/dev/null || true)" == "Darwin" ]] || return 1 command -v defaults >/dev/null 2>&1 || return 1 product_name="$(sw_vers -productName 2>/dev/null || true)" appearance="$(defaults read -g AppleInterfaceStyle 2>/dev/null || true)" if [[ -z "$appearance" && \ ("$product_name" == *"iPhone"* || "$product_name" == *"iOS"* || "$product_name" == *"iPad"*) ]]; then appearance="$(defaults read /var/mobile/Library/Preferences/.GlobalPreferences AppleInterfaceStyle 2>/dev/null || true)" fi case "$appearance" in Dark|dark) printf 'dark' ;; Light|light) printf 'light' ;; '') # macOS 中该键不存在代表浅色;iOS/iPadOS 无权读取时交给终端检测。 if [[ "$product_name" == *"iPhone"* || "$product_name" == *"iOS"* || "$product_name" == *"iPad"* ]]; then return 1 fi printf 'light' ;; *) return 1 ;; esac } detect_windows_appearance_theme() { local powershell="" appearance="" if command -v powershell.exe >/dev/null 2>&1; then powershell="powershell.exe" elif command -v powershell >/dev/null 2>&1; then powershell="powershell" fi [[ -n "$powershell" ]] || return 1 # shellcheck disable=SC2016 # The single-quoted variables belong to PowerShell. appearance="$($powershell -NoLogo -NoProfile -NonInteractive -Command \ '$theme = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -ErrorAction SilentlyContinue; if ($null -ne $theme.SystemUsesLightTheme) { [Console]::Write($theme.SystemUsesLightTheme) } elseif ($null -ne $theme.AppsUseLightTheme) { [Console]::Write($theme.AppsUseLightTheme) }' \ 2>/dev/null || true)" appearance="${appearance//$'\r'/}" appearance="${appearance//[[:space:]]/}" case "$appearance" in 0) printf 'dark' ;; 1) printf 'light' ;; *) return 1 ;; esac } theme_from_rgb_255() { local value="$1" red green blue brightness value="${value//,/ }" read -r red green blue <<< "$value" if [[ "$red" =~ ^[0-9]+$ && "$green" =~ ^[0-9]+$ && "$blue" =~ ^[0-9]+$ ]] && \ ((red <= 255 && green <= 255 && blue <= 255)); then brightness=$(((red * 299 + green * 587 + blue * 114) / 1000)) if ((brightness >= 128)); then printf 'light' else printf 'dark' fi return 0 fi return 1 } detect_linux_appearance_theme() { local setting="" setting_lower="" theme_name="" theme_name_lower="" local kde_reader="" background="" # 无图形会话时,gsettings 可能只返回默认值而非真实主题;此时应让 # SSH/容器继续使用终端背景检测。 if [[ -z "${XDG_CURRENT_DESKTOP:-}" && -z "${DESKTOP_SESSION:-}" && \ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" && \ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then return 1 fi # freedesktop Appearance Portal:GNOME、KDE 和多数 Wayland 桌面的统一接口。 if command -v gdbus >/dev/null 2>&1; then setting="$(gdbus call --session \ --dest org.freedesktop.portal.Desktop \ --object-path /org/freedesktop/portal/desktop \ --method org.freedesktop.portal.Settings.Read \ org.freedesktop.appearance color-scheme 2>/dev/null || true)" case "$setting" in *'uint32 1'*) printf 'dark'; return ;; *'uint32 2'*) printf 'light'; return ;; esac fi # GNOME 42+ / Cinnamon 以及兼容 gsettings 的 GTK 桌面。 if command -v gsettings >/dev/null 2>&1; then setting="$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null || true)" setting_lower="$(lowercase "$setting")" case "$setting_lower" in *prefer-dark*) printf 'dark'; return ;; *prefer-light*) printf 'light'; return ;; esac theme_name="$(gsettings get org.gnome.desktop.interface gtk-theme 2>/dev/null || true)" if [[ -z "$theme_name" ]]; then theme_name="$(gsettings get org.cinnamon.desktop.interface gtk-theme 2>/dev/null || true)" fi if [[ -n "$theme_name" ]]; then theme_name_lower="$(lowercase "$theme_name")" if [[ "$theme_name_lower" == *dark* ]]; then printf 'dark' else printf 'light' fi return fi fi # KDE Plasma 6/5:先看配色方案名,再读取窗口背景色作判断。 if command -v kreadconfig6 >/dev/null 2>&1; then kde_reader="kreadconfig6" elif command -v kreadconfig5 >/dev/null 2>&1; then kde_reader="kreadconfig5" fi if [[ -n "$kde_reader" ]]; then theme_name="$($kde_reader --file kdeglobals --group General --key ColorScheme 2>/dev/null || true)" theme_name_lower="$(lowercase "$theme_name")" case "$theme_name_lower" in *dark*) printf 'dark'; return ;; *light*) printf 'light'; return ;; esac background="$($kde_reader --file kdeglobals --group 'Colors:Window' --key BackgroundNormal 2>/dev/null || true)" if [[ -n "$background" ]]; then theme_from_rgb_255 "$background" && return fi fi # XFCE 的主题名通常直接包含 Dark;存在非 Dark 主题时按浅色处理。 if command -v xfconf-query >/dev/null 2>&1; then theme_name="$(xfconf-query -c xsettings -p /Net/ThemeName 2>/dev/null || true)" if [[ -n "$theme_name" ]]; then theme_name_lower="$(lowercase "$theme_name")" if [[ "$theme_name_lower" == *dark* ]]; then printf 'dark' else printf 'light' fi return fi fi return 1 } detect_android_appearance_theme() { local night_mode="" night_mode_lower="" command -v cmd >/dev/null 2>&1 || return 1 night_mode="$(cmd uimode night 2>/dev/null || true)" night_mode_lower="$(lowercase "$night_mode")" case "$night_mode_lower" in *'night mode: yes'*) printf 'dark' ;; *'night mode: no'*) printf 'light' ;; *) return 1 ;; esac } detect_system_appearance_theme() { local kernel kernel="$(uname -s 2>/dev/null || true)" case "$kernel" in Darwin) detect_apple_appearance_theme ;; Linux) if [[ -n "${WSL_DISTRO_NAME:-}" || "$(uname -r 2>/dev/null || true)" == *[Mm]icrosoft* ]]; then detect_windows_appearance_theme && return fi if [[ -n "${ANDROID_ROOT:-}" || -n "${TERMUX_VERSION:-}" ]]; then detect_android_appearance_theme && return fi detect_linux_appearance_theme ;; MINGW*|MSYS*|CYGWIN*) detect_windows_appearance_theme ;; FreeBSD|OpenBSD|NetBSD|DragonFly|SunOS) # BSD/Unix 桌面也可能提供 freedesktop、GTK、KDE 或 XFCE 接口。 detect_linux_appearance_theme ;; *) return 1 ;; esac } detect_color_theme() { local requested="${HB_COLOR_THEME:-auto}" background_index="" rgb="" local red green blue brightness system_theme="" case "$requested" in dark|light) printf '%s' "$requested" return ;; auto|'') ;; *) requested=auto ;; esac # 本地运行优先跟随操作系统外观。SSH 中远端系统状态不代表客户端 # (例如 iOS/Android 上的 Termius),因此直接交给后面的终端背景检测。 if [[ -z "${SSH_TTY:-}" && -z "${SSH_CONNECTION:-}" && -z "${SSH_CLIENT:-}" ]]; then system_theme="$(detect_system_appearance_theme || true)" if [[ "$system_theme" == "dark" || "$system_theme" == "light" ]]; then printf '%s' "$system_theme" return fi fi # 系统状态读取失败时,再查询终端自身提供的背景信息。 # COLORFGBG 的最后一项是背景色索引,常见终端会主动提供。 if [[ -n "${COLORFGBG:-}" ]]; then background_index="${COLORFGBG##*[;:]}" if [[ "$background_index" =~ ^[0-9]+$ ]]; then if ((background_index == 0 || background_index == 8 || background_index < 7)); then printf 'dark' else printf 'light' fi return fi fi # Apple Terminal 不设置 COLORFGBG,但能返回当前标签页的实际背景色。 if [[ "${TERM_PROGRAM:-}" == "Apple_Terminal" ]] && command -v osascript >/dev/null 2>&1; then rgb="$(osascript -e 'tell application "Terminal" to get background color of current settings of selected tab of front window' 2>/dev/null || true)" rgb="${rgb//, / }" read -r red green blue <<< "$rgb" if [[ "$red" =~ ^[0-9]+$ && "$green" =~ ^[0-9]+$ && "$blue" =~ ^[0-9]+$ ]]; then brightness=$(((red * 299 + green * 587 + blue * 114) / 1000)) if ((brightness >= 32768)); then printf 'light' else printf 'dark' fi return fi fi # iTerm2、Termius、Kitty、WezTerm、Alacritty 等终端通常支持 OSC 11; # 它也可经由 SSH 返回客户端终端的真实背景色。 rgb="$(query_terminal_background_rgb || true)" read -r red green blue <<< "$rgb" if [[ "$red" =~ ^[0-9]+$ && "$green" =~ ^[0-9]+$ && "$blue" =~ ^[0-9]+$ ]]; then brightness=$(((red * 299 + green * 587 + blue * 114) / 1000)) if ((brightness >= 32768)); then printf 'light' else printf 'dark' fi return fi # 所有自动检测均不可用时默认深色,也可用 HB_COLOR_THEME 手动覆盖。 printf 'dark' } COLOR_THEME="none" if [[ -t 1 ]]; then COLOR_COUNT="$(tput colors 2>/dev/null || true)" [[ "$COLOR_COUNT" =~ ^[0-9]+$ ]] || COLOR_COUNT=8 COLOR_THEME="$(detect_color_theme)" if ((COLOR_COUNT >= 256)) && [[ "$COLOR_THEME" == "light" ]]; then # 浅色背景使用更深的色阶,尤其避免亮黄、亮青在白底上失去对比。 RED='\033[38;5;124m' GREEN='\033[38;5;28m' YELLOW='\033[38;5;130m' BLUE='\033[38;5;25m' MAGENTA='\033[38;5;90m' CYAN='\033[38;5;30m' elif ((COLOR_COUNT >= 256)); then # 深色背景使用偏亮的色阶,保持正文和状态信息清晰。 RED='\033[38;5;203m' GREEN='\033[38;5;114m' YELLOW='\033[38;5;221m' BLUE='\033[38;5;75m' MAGENTA='\033[38;5;177m' CYAN='\033[38;5;80m' elif [[ "$COLOR_THEME" == "light" ]]; then RED='\033[31m' GREEN='\033[32m' YELLOW='\033[33m' BLUE='\033[34m' MAGENTA='\033[35m' CYAN='\033[36m' else RED='\033[91m' GREEN='\033[92m' YELLOW='\033[93m' BLUE='\033[94m' MAGENTA='\033[95m' CYAN='\033[96m' fi BOLD='\033[1m' NC='\033[0m' export HOMEBREW_COLOR=1 else COLOR_COUNT=0 RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' BOLD='' 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=() CLEANUP_FAILED=0 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 ]] || { print_error "--width requires a value."; exit 2; } TERMINAL_WIDTH="$2" shift 2 ;; --width=*) TERMINAL_WIDTH="${1#*=}" shift ;; -h|--help) usage exit 0 ;; *) print_error "Unknown argument: $1" 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 print_error "terminal width must be an integer between 40 and 500." exit 2 fi export COLUMNS="$TERMINAL_WIDTH" } separator() { local i printf '%b' "$BLUE" for ((i = 0; i < TERMINAL_WIDTH; i++)); do printf '=' done printf '%b\n' "$NC" } print_header() { printf '%b%s%b\n' "$BLUE" "$1" "$NC" } print_error() { printf '%bError:%b %s\n' "$RED" "$NC" "$*" >&2 } print_warning() { printf '%bWarning:%b %s\n' "$YELLOW" "$NC" "$*" } print_notice() { printf '%b%s%b\n' "$YELLOW" "$*" "$NC" } print_success() { printf '%b%s%b\n' "$GREEN" "$*" "$NC" } print_info() { printf '%b%s%b\n' "$CYAN" "$*" "$NC" } print_colored_list() { local label="$1" color="$2" items="$3" item printf '%b%s%b\n' "$color" "$label" "$NC" while IFS= read -r item; do [[ -n "$item" ]] || continue printf ' %b%s%b\n' "$color" "$item" "$NC" done <<< "$items" } print_version_transition() { local line="$1" indent left arrow new_version suffix old_version package # Homebrew 常见版本行: # package 1.0.0 -> 1.1.0 # 1.0.0 -> 1.1.0 # 包名、旧版本、方向和新版本分别着色,让变化能被快速识别。 if [[ "$line" =~ ^([[:space:]]*)(.*[^[:space:]])[[:space:]]+(\-\>|=\>)[[:space:]]+([^[:space:]]+)(.*)$ ]]; then indent="${BASH_REMATCH[1]}" left="${BASH_REMATCH[2]}" arrow="${BASH_REMATCH[3]}" new_version="${BASH_REMATCH[4]}" suffix="${BASH_REMATCH[5]}" old_version="${left##*[[:space:]]}" package="${left%"$old_version"}" printf '%s' "$indent" if [[ -n "$package" ]]; then printf '%b%s%b' "$BOLD$CYAN" "$package" "$NC" fi printf '%b%s%b %b%s%b %b%s%b%s\n' \ "$YELLOW" "$old_version" "$NC" \ "$BLUE" "$arrow" "$NC" \ "$BOLD$GREEN" "$new_version" "$NC" "$suffix" return 0 fi return 1 } colorize_brew_heading() { local text="${1#==> }" action subject case "$text" in Upgrading\ *|Installing\ *|Reinstalling\ *|Fetching\ *|Downloading\ *|Pouring\ *|Would\ upgrade\ *) action="${text%% *}" subject="${text#* }" printf '%b==>%b %b%s%b %b%s%b\n' \ "$BLUE" "$NC" "$CYAN" "$action" "$NC" "$BOLD$GREEN" "$subject" "$NC" ;; Upgraded\ *|Installed\ *|Successfully\ *) printf '%b==>%b %b%s%b\n' "$BLUE" "$NC" "$BOLD$GREEN" "$text" "$NC" ;; Outdated\ *|Disabled\ *|Warning*) printf '%b==>%b %b%s%b\n' "$BLUE" "$NC" "$YELLOW" "$text" "$NC" ;; Formulae|Casks|New\ Formulae|New\ Casks|Caveats|Summary) printf '%b==>%b %b%s%b\n' "$BLUE" "$NC" "$BOLD$MAGENTA" "$text" "$NC" ;; *) printf '%b==>%b %b%s%b\n' "$BLUE" "$NC" "$CYAN" "$text" "$NC" ;; esac } colorize_brew_output() { local line while IFS= read -r line || [[ -n "$line" ]]; do if print_version_transition "$line"; then continue fi case "$line" in '==> '*) colorize_brew_heading "$line" ;; Warning:*|warning:*) printf '%b%s%b\n' "$YELLOW" "$line" "$NC" ;; Error:*|error:*|Failed:*|failed:*) printf '%b%s%b\n' "$RED" "$line" "$NC" ;; Removing:*) printf '%b%s%b\n' "$YELLOW" "$line" "$NC" ;; '✔︎ '*|'✔ '*|'🍺 '*|*'was successfully upgraded!'|*'successfully installed!'|All\ dependencies\ satisfied.) printf '%b%s%b\n' "$GREEN" "$line" "$NC" ;; Already\ up-to-date.*|Updated\ Homebrew\ *|Updated\ *tap*|Successfully\ rebased\ *|Pruned\ *|This\ operation\ has\ freed\ *) printf '%b%s%b\n' "$GREEN" "$line" "$NC" ;; ' Downloaded '*|' Verified '*) printf '%b%s%b\n' "$GREEN" "$line" "$NC" ;; *) printf '%s\n' "$line" ;; esac done } can_stream_colorize_tty_output() { [[ -t 1 && "$(uname -s 2>/dev/null || true)" == "Darwin" && \ -x /usr/bin/script && -x /usr/bin/perl ]] } colorize_brew_tty_stream() { # `script` 将 brew 的输出传递给本函数时,普通行以 CRLF 结尾,进度条则以 # CR 重绘。按行的 Bash read 无法同时处理两者,会等到下一个换行才刷新进度。 # 这个小型 Perl 流式过滤器会立即透传 CR 更新,仅对完整的普通行重复使用 # 现有的语义配色规则。 HB_STREAM_RED="$RED" \ HB_STREAM_GREEN="$GREEN" \ HB_STREAM_YELLOW="$YELLOW" \ HB_STREAM_BLUE="$BLUE" \ HB_STREAM_MAGENTA="$MAGENTA" \ HB_STREAM_CYAN="$CYAN" \ HB_STREAM_BOLD="$BOLD" \ HB_STREAM_NC="$NC" \ /usr/bin/perl -e ' use strict; use warnings; use utf8; $| = 1; my %color = map { $_ => ($ENV{"HB_STREAM_" . uc($_)} // "") } qw(red green yellow blue magenta cyan bold nc); for my $value (values %color) { $value =~ s/\\033/\e/g; $value =~ s/\\e/\e/g; } sub print_version_transition { my ($line) = @_; return 0 unless $line =~ /^(\s*)(.*\S)\s+(-\>|=\>)\s+(\S+)(.*)$/; my ($indent, $left, $arrow, $new_version, $suffix) = ($1, $2, $3, $4, $5); my ($old_version) = $left =~ /(\S+)$/; my $package = substr($left, 0, length($left) - length($old_version)); print $indent; print $color{bold}, $color{cyan}, $package, $color{nc} if length $package; print $color{yellow}, $old_version, $color{nc}, " "; print $color{blue}, $arrow, $color{nc}, " "; print $color{bold}, $color{green}, $new_version, $color{nc}, $suffix, "\n"; return 1; } sub print_heading { my ($line) = @_; my $text = substr($line, 4); if ($text =~ /^(Upgrading|Installing|Reinstalling|Fetching|Downloading|Pouring|Would)\s+(.+)$/) { print $color{blue}, "==>", $color{nc}, " "; print $color{cyan}, $1, $color{nc}, " "; print $color{bold}, $color{green}, $2, $color{nc}, "\n"; } elsif ($text =~ /^(?:Upgraded\s|Installed\s|Successfully\s)/) { print $color{blue}, "==>", $color{nc}, " ", $color{bold}, $color{green}, $text, $color{nc}, "\n"; } elsif ($text =~ /^(?:Outdated\s|Disabled\s|Warning)/) { print $color{blue}, "==>", $color{nc}, " ", $color{yellow}, $text, $color{nc}, "\n"; } elsif ($text =~ /^(?:Formulae|Casks|New Formulae|New Casks|Caveats|Summary)$/) { print $color{blue}, "==>", $color{nc}, " ", $color{bold}, $color{magenta}, $text, $color{nc}, "\n"; } else { print $color{blue}, "==>", $color{nc}, " ", $color{cyan}, $text, $color{nc}, "\n"; } } sub colorize_line { my ($line) = @_; # Homebrew 的进度队列会在普通输出前留下光标控制序列(例如显示光标)。 # 先原样输出这些序列,再匹配文本,避免它们成为包名的一部分而破坏版本着色。 my $control_prefix = ""; while ($line =~ s/\A(\e\[[0-?]*[ -\/]*[@-~])//) { $control_prefix .= $1; } print $control_prefix; return if print_version_transition($line); if ($line =~ /^==> /) { print_heading($line); } elsif ($line =~ /^(?:Warning:|warning:)/) { print $color{yellow}, $line, $color{nc}, "\n"; } elsif ($line =~ /^(?:Error:|error:|Failed:|failed:)/) { print $color{red}, $line, $color{nc}, "\n"; } elsif ($line =~ /^Removing:/) { print $color{yellow}, $line, $color{nc}, "\n"; } elsif ($line =~ /^(?:✔︎ |✔ |🍺 )/ || $line =~ /was successfully upgraded!|successfully installed!|All dependencies satisfied\./) { print $color{green}, $line, $color{nc}, "\n"; } elsif ($line =~ /^(?:Already up-to-date|Updated Homebrew |Updated .*tap|Successfully rebased |Pruned |This operation has freed )/) { print $color{green}, $line, $color{nc}, "\n"; } elsif ($line =~ /^ (?:Downloaded|Verified) /) { print $color{green}, $line, $color{nc}, "\n"; } else { print $line, "\n"; } } my $buffer = ""; while (sysread(STDIN, my $chunk, 8192)) { $buffer .= $chunk; while (length $buffer) { my $newline = index($buffer, "\n"); my $carriage = index($buffer, "\r"); my $position; if ($newline < 0) { $position = $carriage; } elsif ($carriage < 0) { $position = $newline; } else { $position = $newline < $carriage ? $newline : $carriage; } last if $position < 0; my $separator = substr($buffer, $position, 1); # 分块时 CR 可能正好在 CRLF 的边界,等待下一块再决定它是换行还是进度重绘。 last if $separator eq "\r" && $position + 1 == length($buffer); my $record = substr($buffer, 0, $position); if ($separator eq "\r" && substr($buffer, $position + 1, 1) eq "\n") { substr($buffer, 0, $position + 2, ""); colorize_line($record); } elsif ($separator eq "\n") { substr($buffer, 0, $position + 1, ""); colorize_line($record); } else { substr($buffer, 0, $position + 1, ""); print $record, "\r"; } } } if (length $buffer) { if ($buffer =~ s/\r\z//) { print $buffer, "\r"; } else { colorize_line($buffer); } } ' } run_brew_in_pseudoterminal() { local log_file="$1" shift # brewup 通常通过 curl 管道启动,因而主脚本的 stdin 可能是已关闭的管道。 # script(1) 若继承它,会向伪终端传入 ^D,使 Homebrew 的 y/n 提示直接失败。 # 应从控制终端读取输入,继续支持升级确认与实时进度。 if [[ -n "$log_file" ]]; then if [[ -r /dev/tty ]]; then /usr/bin/script -qF "$log_file" env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" < /dev/tty else /usr/bin/script -qF "$log_file" env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" fi elif [[ -r /dev/tty ]]; then /usr/bin/script -q /dev/null env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" < /dev/tty else /usr/bin/script -q /dev/null env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" fi } run_brew_colored() { local pipeline_status=() # 伪终端保留 Homebrew 的实时进度,流式着色器只处理完整文本行, # 不会缓冲或改写用 CR 刷新的进度输出。 if can_stream_colorize_tty_output; then if run_brew_in_pseudoterminal "" "$@" | colorize_brew_tty_stream; then return 0 fi pipeline_status=("${PIPESTATUS[@]}") if ((${pipeline_status[1]:-0} != 0)); then print_error "failed to colorize Homebrew output." return "${pipeline_status[1]}" fi return "${pipeline_status[0]}" elif [[ -t 1 ]]; then env -u HOMEBREW_COLOR brew "$@" return $? fi if env -u HOMEBREW_COLOR brew "$@" 2>&1 | colorize_brew_output; then return 0 fi pipeline_status=("${PIPESTATUS[@]}") if ((${pipeline_status[1]:-0} != 0)); then print_error "failed to colorize Homebrew output." return "${pipeline_status[1]}" fi return "${pipeline_status[0]}" } 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 print_error "another brewup process is already running (PID ${existing_pid})." exit 75 fi rm -f "$LOCK_DIR/pid" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || { print_error "unable to remove stale lock directory: $LOCK_DIR" exit 75 } if ! mkdir "$LOCK_DIR" 2>/dev/null; then print_error "another brewup process acquired the lock." 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 print_info "Retrying ${label} (${attempt}/${attempts}) after ${delay}s..." sleep "$delay" next_delay=$((delay * 2)) ((next_delay > 300)) && next_delay=300 delay="$next_delay" fi : > "$ACTIVE_LOG_FILE" if can_stream_colorize_tty_output; then # 为错误分类保留转录,同时通过伪终端和流式着色兼顾进度条与版本配色。 if run_brew_in_pseudoterminal "$ACTIVE_LOG_FILE" "$@" | colorize_brew_tty_stream; then status=0 else pipeline_status=("${PIPESTATUS[@]}") status="${pipeline_status[0]}" if ((${pipeline_status[1]:-0} != 0)); then print_error "failed to colorize Homebrew output." status="${pipeline_status[1]}" fi fi elif env -u HOMEBREW_COLOR brew "$@" 2>&1 | tee "$ACTIVE_LOG_FILE" | colorize_brew_output; then status=0 else pipeline_status=("${PIPESTATUS[@]}") status="${pipeline_status[0]}" if ((${pipeline_status[1]:-0} != 0)); then print_error "failed to write retry log." status="${pipeline_status[1]}" elif ((${pipeline_status[2]:-0} != 0)); then print_error "failed to colorize Homebrew output." status="${pipeline_status[2]}" 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 print_notice "${label} failed with a retryable error." done } get_outdated_formulae() { env -u HOMEBREW_COLOR brew outdated --formula 2>/dev/null } get_outdated_casks() { env -u HOMEBREW_COLOR brew outdated --cask --greedy 2>/dev/null } run_brew_doctor() { local output status=0 line if output="$(env -u HOMEBREW_COLOR brew doctor 2>&1)"; then status=0 else status=$? fi while IFS= read -r line; do case "$line" in Warning:*|*deprecated*|*disabled*|*pre-release*|*"find replacements"*|*"do not provide support"*|*"Tier 2 configuration"*) printf '%b%s%b\n' "$YELLOW" "$line" "$NC" ;; ' '*|' https://'*) printf '%b%s%b\n' "$CYAN" "$line" "$NC" ;; *) printf '%s\n' "$line" ;; esac done <<< "$output" return "$status" } run_formula_upgrade() { local bulk_status=0 initial_formulae="" initial_formulae_known=1 local outdated_formulae remaining_formulae formula local failed_formulae=() if ! initial_formulae="$(get_outdated_formulae)"; then initial_formulae_known=0 print_warning "unable to list Formulae requiring updates before upgrade." elif [[ -n "$initial_formulae" ]]; then print_colored_list "Formulae requiring updates:" "$YELLOW" "$initial_formulae" else print_success "No Formulae require updates." fi run_brew_colored upgrade --formula || bulk_status=$? if ((bulk_status == 0)); then if ((initial_formulae_known)) && [[ -n "$initial_formulae" ]]; then print_colored_list "Updated Formulae:" "$GREEN" "$initial_formulae" else print_success "Formula upgrade completed." fi return 0 fi print_warning "Formula upgrade returned ${bulk_status}; checking remaining Formulae..." if ! outdated_formulae="$(get_outdated_formulae)"; then print_warning "unable to verify outdated Formulae." return "$bulk_status" fi if [[ -z "$outdated_formulae" ]]; then if [[ -n "$initial_formulae" ]]; then print_colored_list "Updated Formulae:" "$GREEN" "$initial_formulae" else print_success "No outdated Formulae remain; the bulk error was recovered." fi return 0 fi while IFS= read -r formula; do [[ -n "$formula" ]] || continue print_info "Retrying Formula: ${formula}" 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 print_warning "unable to verify Formulae after individual retries." FAILED_FORMULAE=("${failed_formulae[@]}") return 1 fi if [[ -z "$remaining_formulae" ]]; then if [[ -n "$initial_formulae" ]]; then print_colored_list "Updated Formulae after isolated retries:" "$GREEN" "$initial_formulae" else print_success "All outdated Formulae were upgraded after isolated retries." fi return 0 fi while IFS= read -r formula; do [[ -n "$formula" ]] && FAILED_FORMULAE+=("$formula") done <<< "$remaining_formulae" print_colored_list "Outdated Formulae remaining after retries:" "$RED" "$remaining_formulae" return 1 } run_cask_upgrade() { local bulk_status=0 initial_casks="" initial_casks_known=1 local 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) print_warning "HB_CASK_FORCE is enabled; existing Cask files may be overwritten." fi if ! initial_casks="$(get_outdated_casks)"; then initial_casks_known=0 print_warning "unable to list Casks requiring updates before upgrade." elif [[ -n "$initial_casks" ]]; then print_colored_list "Casks requiring updates:" "$YELLOW" "$initial_casks" else print_success "No Casks require updates." fi run_brew_colored "${bulk_args[@]}" || bulk_status=$? if ((bulk_status == 0)); then if ((initial_casks_known)) && [[ -n "$initial_casks" ]]; then print_colored_list "Updated Casks:" "$GREEN" "$initial_casks" else print_success "Cask upgrade completed." fi return 0 fi print_warning "Cask upgrade returned ${bulk_status}; checking remaining Casks..." if ! outdated_casks="$(get_outdated_casks)"; then print_warning "unable to verify outdated Casks." return "$bulk_status" fi if [[ -z "$outdated_casks" ]]; then if [[ -n "$initial_casks" ]]; then print_colored_list "Updated Casks:" "$GREEN" "$initial_casks" else print_success "No outdated Casks remain; the bulk error was recovered." fi return 0 fi while IFS= read -r cask; do [[ -n "$cask" ]] || continue print_info "Retrying Cask: ${cask} (without --force)" 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 print_warning "unable to verify Casks after individual retries." FAILED_CASKS=("${failed_casks[@]}") return 1 fi if [[ -z "$remaining_casks" ]]; then if [[ -n "$initial_casks" ]]; then print_colored_list "Updated Casks after isolated retries:" "$GREEN" "$initial_casks" else print_success "All outdated Casks were upgraded after isolated retries." fi return 0 fi while IFS= read -r cask; do [[ -n "$cask" ]] && FAILED_CASKS+=("$cask") done <<< "$remaining_casks" print_colored_list "Outdated Casks remaining after retries:" "$RED" "$remaining_casks" return 1 } run_cleanup() { run_brew_colored cleanup --prune=all } print_summary() { local overall_status="$1" separator if ((overall_status == 0)); then print_success "All operations completed successfully." else printf '%bHomebrew maintenance completed with unresolved failures.%b\n' "$RED" "$NC" if ((${#FAILED_FORMULAE[@]} > 0)); then printf '%bFailed Formulae:%b %s\n' "$RED" "$NC" "${FAILED_FORMULAE[*]}" fi if ((${#FAILED_CASKS[@]} > 0)); then printf '%bFailed Casks:%b %s\n' "$RED" "$NC" "${FAILED_CASKS[*]}" fi if ((CLEANUP_FAILED)); then printf '%bHomebrew cleanup failed.%b\n' "$RED" "$NC" fi fi separator } parse_args "$@" resolve_terminal_width command -v brew >/dev/null 2>&1 || { print_error "Homebrew is not installed or not in PATH."; exit 127; } command -v tee >/dev/null 2>&1 || { print_error "tee is required."; 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 print_notice "brew doctor skipped because HB_SKIP_DOCTOR is enabled." elif ! run_brew_doctor; then print_warning "'brew doctor' detected issues. Manual review is recommended." else print_success "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 printf '\n' print_info ">>> [1/2] Upgrading CLI Formulae..." if ! run_formula_upgrade; then overall_status=1 fi printf '\n' print_info ">>> [2/2] Upgrading GUI Casks..." if ! run_cask_upgrade; then overall_status=1 fi separator printf '\n' separator print_header "Step 4: Cleaning all Homebrew cache and old versions (brew cleanup --prune=all)" if ! run_cleanup; then overall_status=1 CLEANUP_FAILED=1 fi separator printf '\n' print_summary "$overall_status" exit "$overall_status"