feat(homebrew): 添加 Homebrew 输出流式着色功能

- 实现 can_stream_colorize_tty_output 函数检测流式着色支持
- 添加 colorize_brew_tty_stream 函数处理实时进度条和语义配色
- 使用 Perl 脚本实现流式过滤器,即时处理 CRLF 和 CR 换行
- 优化 run_brew_colored 函数集成伪终端和流式着色
- 改进日志记录功能,保留错误分类和重试决策所需信息
- 修复进度条缓冲问题,确保实时显示升级状态
This commit is contained in:
2026-07-24 08:32:50 +08:00
parent 972e7a142d
commit 8124810b6e
2 changed files with 160 additions and 11 deletions
+2 -1
View File
@@ -31,6 +31,7 @@
- Homebrew - Homebrew
- Bash - Bash
- `curl` - `curl`
- macOS 自带的 `/usr/bin/script``/usr/bin/perl`(用于兼顾实时进度与自定义配色)
- macOS Keychain 工具 `/usr/bin/security`,仅启动器需要 - macOS Keychain 工具 `/usr/bin/security`,仅启动器需要
可先检查: 可先检查:
@@ -197,7 +198,7 @@ BREWUP_CONNECT_TIMEOUT_SECONDS=20 BREWUP_LOW_SPEED_TIME_SECONDS=45 brewup
为了兼顾性能,Formula 和 Cask 的批量阶段都只执行一次,只有仍然过期的失败项进入单项重试。单项升级保持串行,因为并行运行多个 Homebrew 写操作会争用 Homebrew 锁和 `/Applications`。用于错误分类的临时日志会在命令结束或收到信号后删除。 为了兼顾性能,Formula 和 Cask 的批量阶段都只执行一次,只有仍然过期的失败项进入单项重试。单项升级保持串行,因为并行运行多个 Homebrew 写操作会争用 Homebrew 锁和 `/Applications`。用于错误分类的临时日志会在命令结束或收到信号后删除。
在交互式终端中,主脚本会 Homebrew 直接连接 TTY,以便 Homebrew 6 实时重绘并行下载进度。需要记录错误以供重试判断时,macOS 自带的 `script(1)`创建伪终端并同步保存日志。如果将 `brewup` 输出重定向到文件或 CI,则自动切换为稳定的逐行输出,不绘制动态进度条。 在交互式终端中,主脚本会通过 macOS 自带的 `script(1)` Homebrew 创建伪终端,以便 Homebrew 6 实时重绘并行下载进度。后续的流式着色器会立即透传进度条的回车刷新,并只对完整文本行应用包名、版本和状态配色。需要记录错误以供重试判断时,`script(1)` 会同步保存日志。如果将 `brewup` 输出重定向到文件或 CI,则自动切换为稳定的逐行输出,不绘制动态进度条。
## Cask 与清理策略 ## Cask 与清理策略
+158 -10
View File
@@ -570,13 +570,158 @@ colorize_brew_output() {
done 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) = @_;
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_colored() { run_brew_colored() {
local pipeline_status=() local pipeline_status=()
# Homebrew 6 的并行下载队列只有在 stdout 是 TTY 时才会绘制 # 伪终端保留 Homebrew 的实时进度,流式着色器只处理完整文本行,
# 实时进度。交互式终端中直接运行 brew,保留它的原生进度条与 # 不会缓冲或改写用 CR 刷新的进度输出。
# 动态下载状态;重定向到文件或 CI 时再使用按行语义着色。 if can_stream_colorize_tty_output; then
if [[ -t 1 ]]; then if /usr/bin/script -q /dev/null env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" | 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 "$@" env -u HOMEBREW_COLOR brew "$@"
return $? return $?
fi fi
@@ -704,14 +849,17 @@ run_brew_with_retries() {
fi fi
: > "$ACTIVE_LOG_FILE" : > "$ACTIVE_LOG_FILE"
if [[ -t 1 && "$(uname -s 2>/dev/null || true)" == "Darwin" && -x /usr/bin/script ]]; then if can_stream_colorize_tty_output; then
# tee 会把 brew 的 stdout 变成管道,导致 Homebrew 关闭实时进度 # 为错误分类保留转录,同时通过伪终端和流式着色兼顾进度条与版本配色
# macOS script(1) 为 brew 分配伪终端,同时把完整输出记录到 if /usr/bin/script -qF "$ACTIVE_LOG_FILE" env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" | colorize_brew_tty_stream; then
# 日志,供后续错误分类与重试决策使用。-F 确保记录实时刷新。
if /usr/bin/script -qF "$ACTIVE_LOG_FILE" env -u HOMEBREW_COLOR brew "$@"; then
status=0 status=0
else else
status=$? 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 fi
elif env -u HOMEBREW_COLOR brew "$@" 2>&1 | tee "$ACTIVE_LOG_FILE" | colorize_brew_output; then elif env -u HOMEBREW_COLOR brew "$@" 2>&1 | tee "$ACTIVE_LOG_FILE" | colorize_brew_output; then
status=0 status=0