Files
script/homebrew/brew-upgrade-manager.sh
T
orion d090edd5c6 refactor(homebrew): 统一 Homebrew 输出着色处理
- 移除交互式终端的特殊处理逻辑,禁用 Homebrew 自带动态进度显示
- 统一使用语义着色处理所有场景下的 Homebrew 输出
- 删除 BSD script 伪终端分配功能,简化日志记录流程
- 更新 README 文档说明输出着色的一致性改进
2026-07-23 08:45:15 +08:00

990 lines
32 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 PortalGNOME、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
}
run_brew_colored() {
local pipeline_status=()
# 禁用 Homebrew 自带的不完整配色,统一按语义着色。这里不能在
# 交互式终端直接运行 brew,否则版本变化行会绕过着色器。
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 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"