上个月换 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 冲突问题吗?怎么解决的?