Go CLI 进度条:为什么我放弃 Bubble Tea 裸写了 ANSI

上个月换 Mac,.zshrc 从旧电脑 AirDrop 过来,git email 忘了配。一周后发现有个 commit 作者显示 noreply@github.com

写了个工具自动化这事------把 Mac 整个开发环境存成 snapshot,换机器一条命令还原。Go 写的,开源,叫 OpenBoot。开发过程踩了几个坑。

Bubble Tea 翻车

restore 的时候要跑 brew install 批量装包。brew 会往 stdout 打日志,我要一个进度条钉在终端最后一行,上面正常显示 brew 输出。

项目里其他交互界面(选包、表单)用的 Bubble Tea,写交互式 TUI 确实舒服。自然想用它做这个进度条。

但 Bubble Tea 有个前提:它要接管整个终端。 alt screen buffer,全屏渲染。你往 stdout 写东西它不知道,它渲染的时候把你的输出盖掉。两边互相踩。

能想到的方案都试了------brew 输出走 Bubble Tea 的 io.Writer、在 Model 里维护 log buffer、重定向 stderr/stdout------要么渲染闪烁要么输出错位。搞了两天,放弃。

后来想通了。这个场景的矛盾点在于:brew 在自由写 stdout,我只需要控制最后一行。Bubble Tea 要接管整个屏幕,但我根本不需要全屏控制。需求和工具不匹配。

裸写 ANSI escape:

go 复制代码
type StickyProgress struct {
    total      int
    completed  int
    currentPkg string
    mu         sync.Mutex
    active     bool
}

func (sp *StickyProgress) render() {
    filled := int(pct * float64(sp.barWidth))
    empty := sp.barWidth - filled

    bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
    status := fmt.Sprintf(" %d/%d (%3.0f%%)", sp.completed, sp.total, pct*100)

    fmt.Printf("\r\033[K%s%s %s %s", bar, status, eta, pkg)
}

\r 回行首,\033[K 清到行尾,重画。后台开个 goroutine,80ms 刷一次,同时处理 Ctrl+C:

go 复制代码
func (sp *StickyProgress) Start() {
    go func() {
        ticker := time.NewTicker(80 * time.Millisecond)
        defer ticker.Stop()
        for {
            select {
            case <-sp.stopCh:
                return
            case <-sp.sigCh:
                sp.Finish()
                os.Exit(130)
            case <-ticker.C:
                sp.mu.Lock()
                if sp.active {
                    sp.render()
                }
                sp.mu.Unlock()
            }
        }
    }()
}

整个 StickyProgress 250 行,sync.Mutex 保护状态。代码丑了点,但 brew 输出正常往上走,进度条钉在底下,不打架。

说白了就是别啥都往框架里塞。Bubble Tea 的 Model-Update-View 适合"我拥有整个终端"的场景。只需要控制最后一行的话,几个 ANSI escape code 反而更干净。

brew leaves 不是 brew list

snapshot 要扫 Homebrew 装了什么包。一开始用的 brew list,列出来一大堆,大部分是依赖。装一个 go,list 里冒出一串你从没见过的东西。拿这份列表去还原会出问题------Homebrew 试图重装已经作为依赖装好的包。

换成 brew leaves 就对了。只返回你手动装的顶层包,依赖让 Homebrew 自己处理。

整个 snapshot 分 8 步:

go 复制代码
steps := []captureStep{
    {"Homebrew Formulae",    func() { return CaptureFormulae() }},   // brew leaves
    {"Homebrew Casks",       func() { return CaptureCasks() }},      // brew list --cask
    {"Homebrew Taps",        func() { return CaptureTaps() }},
    {"NPM Global Packages",  func() { return CaptureNpm() }},
    {"macOS Preferences",    func() { return CaptureMacOSPrefs() }},
    {"Shell Environment",    func() { return CaptureShell() }},
    {"Git Configuration",    func() { return CaptureGit() }},
    {"Dev Tools",            func() { return CaptureDevTools() }},
}

每步独立跑,挂了不影响其他的。

shell 配置是直接读 .zshrc 文件,没走 source------启动子 shell 环境太不可控。正则抓两个字段:

go 复制代码
pluginsRe := regexp.MustCompile(`plugins=\(([^)]*)\)`)
themeRe := regexp.MustCompile(`ZSH_THEME="([^"]*)"`)

标准写法都能匹配。多行 plugins 暂时没处理。

macOS 偏好也没全量 dump------defaults read 不加参数能吐几万行。维护了一个常用 key 列表(Dock 大小、Finder 隐藏文件、键盘重复速率),只读这些。

.zshrc 写三遍就有三份

restore 要把 theme 和 plugins 写回 .zshrc。跑一次没问题,跑三次 .zshrc 里就三份一样的配置------每次 append 一遍。

sentinel block 解决:

bash 复制代码
# >>> OpenBoot-Restore
ZSH_THEME="agnoster"
plugins=(git zsh-autosuggestions docker)
# <<< OpenBoot-Restore

每次先删旧 block 再写新的:

go 复制代码
restoreBlockRe := regexp.MustCompile(`(?s)# >>> OpenBoot-Restore\n.*?# <<< OpenBoot-Restore\n?`)
content = restoreBlockRe.ReplaceAllString(content, "")
// append 新 block

跑几次都只有一份。Conda 和 nvm 也这个套路。


Go 1.24,MIT,零遥测,macOS only。所有改系统的操作都有 --dry-run

repo:github.com/openbootdot...

bash 复制代码
brew install openbootdotdev/tap/openboot
openboot snapshot  # 30 秒扫完

用 Charmbracelet 写 CLI 的应该不少,有碰过类似的 stdout 冲突问题吗?怎么解决的?

相关推荐
golang学习记6 小时前
Fiber v3 新特性全解析:更快、更强大、更优雅的 Go Web 框架
后端·go
追随者永远是胜利者17 小时前
(LeetCode-Hot100)53. 最大子数组和
java·算法·leetcode·职场和发展·go
追随者永远是胜利者21 小时前
(LeetCode-Hot100)62. 不同路径
java·算法·leetcode·职场和发展·go
追随者永远是胜利者21 小时前
(LeetCode-Hot100)56. 合并区间
java·算法·leetcode·职场和发展·go
追随者永远是胜利者21 小时前
(LeetCode-Hot100)55. 跳跃游戏
java·算法·leetcode·游戏·go
追随者永远是胜利者1 天前
(LeetCode-Hot100)64. 最小路径和
java·算法·leetcode·职场和发展·go
追随者永远是胜利者1 天前
(LeetCode-Hot100)70. 爬楼梯
java·算法·leetcode·职场和发展·go
追随者永远是胜利者1 天前
(LeetCode-Hot100)49. 字母异位词分组
java·算法·leetcode·职场和发展·go
追随者永远是胜利者1 天前
(LeetCode-Hot100)21. 合并两个有序链表
java·算法·leetcode·链表·go