我家里那台开发机的Homebrew里长期躺着两百多个包,每次想搞清楚“哪个包可以放心卸载”“哪个包已经不被任何东西依赖了”,都要在终端里组合敲brew list、brew deps、brew uses这一串命令,输出铺满屏幕,还得自己人肉关联。上个月我花两个周末写了一个给Homebrew用的Web UI工具,命名为BrewUI,把包列表、依赖关系、更新状态、安装卸载操作统一塞进浏览器。这次把项目从需求到实现的过程完整拆一遍,包括架构选型、核心代码、部署方式和踩过的坑。这个项目适合自己写小工具、想给终端命令做可视化界面的开发者参考,哪怕你完全没接触过Go和前端,也能跟着把思路顺下来。
1. 终端管理包的痛点,以及BrewUI想解决的三件事
1.1 没有图形界面的包管理,信息全是碎片化的
Homebrew本身是个非常优秀的包管理器,但它的所有能力都暴露在命令行里,而且每一条命令只回答一个非常窄的问题。想知道“我装了哪些包”,要跑brew list --versions;想知道“哪些有新版”,要跑brew outdated;想知道“某个包的依赖长什么样”,要跑brew deps --tree;想知道“如果我卸载掉这个包,会不会连带影响其他包”,要跑brew uses <formula>。
单看每条命令都不复杂,但组合起来就非常痛苦。尤其是当包数量超过一百个的时候,大脑根本没有办法把这些命令的输出快速关联起来。我举个例子:brew deps --tree输出一个文本树,包一多,树就变得又长又宽,终端窗口根本放不下;而brew uses默认还只检查直接依赖,要查间接依赖还得加--recursive参数,输出又是一大坨。
还有一个更隐蔽的痛点:Homebrew的卸载和清理是有“连带逻辑”的。brew autoremove能卸载那些不再被任何包依赖的“孤儿包”,但默认情况下它只会告诉你“会卸载XX、YY、ZZ”,不会反过来告诉你“这个包为什么成了孤包”。在终端里,你得再跑一条brew uses --installed去反向验证,整个流程断成一节一节,信息全靠自己拼。
1.2 BrewUI的定位:不替代brew命令,而是可视化入口
动手之前我给自己定了三条边界,防止项目失控。
第一,BrewUI绝不做“点击安装一个App Store式商店”。它不解决“发现新软件”的问题,只解决“管理已有软件”的问题。第二,所有真正的操作执行,比如安装、卸载、升级、清理,一律调用brew原生命令完成,BrewUI只负责展示、确认和回传结果。这样 brew 一旦升级了内部逻辑,BrewUI的适配成本会低很多。第三,界面只监听本机地址,默认不做公网暴露,因为这类工具的本质是“本地开发机的控制台”,不是SaaS服务。
在这个边界下,BrewUI的目标收敛成三件事:
- 包列表可搜索、可排序、可标记状态,一眼看清哪些是“手动装的”、哪些是“作为依赖被带进来的”。
- 更新可追踪,
brew outdated的结果直接展示在仪表盘上,点一下就能批量升级。 - 依赖可视图,用图形化的方式展示“谁依赖谁”,卸载前先看影响面。
说白了,BrewUI做的是“终端信息的结构化”,把原本离散在十几条命令里的结果,变成一套可以被点击、被筛选、被观察的数据。
1.3 同类工具对比:为什么没有直接选现成的
做之前我也调研过市面上的Homebrew图形化工具。老牌的Cakebrew是macOS原生App,功能集中在安装、卸载、搜索和更新提示,但界面比较复古,快速搜索和依赖可视化都比较弱,而且维护节奏不快。还有一些菜单栏小工具,主要做“更新角标提醒”,信息量太少。另外这些工具大多是“半封闭”的,你想给某个包加上自定义的批量操作,比如“先把这几个包标记为依赖再全部重装”,它们基本不支持。
我在Xmind里画了个简单对比:
| 工具 | 形态 | 信息完整度 | 依赖可视化 | 自定义扩展 |
|---|---|---|---|---|
| Cakebrew | macOS原生App | 中 | 弱 | 低 |
| 菜单栏更新工具 | 菜单栏常驻 | 低 | 无 | 无 |
| 纯终端命令 | 终端 | 高但碎片化 | 文本树 | 高但门槛高 |
| BrewUI | 浏览器Web UI | 高且结构化 | 可视 | 中 |
结论很直接:如果只是想要“系统通知告诉我有哪些包可以更新”,现成工具够用;但如果你想要一个真正能“看全貌、做决策”的家目录控制台,还是得自己写一个。这也是BrewUI项目成立的核心理由:不是工具不好用,而是我想要的信息组织方式没有被满足。
2. BrewUI整体架构:Shell命令层、API服务层、Web前端层
2.1 后端选用Go而不是Python/Node的三个原因
后端我第一版用Python写过一版,后来推倒重来换成Go。不是说Python不行,而是在这个具体场景里,Go有三个优势是致命的。
第一是分发成本。Homebrew本身就是macOS环境里的工具,用户机器上大概率有Python3,但不一定有特定版本、特定依赖的Python环境。BrewUI要跑在别人机器上,不可能让用户先pip install -r requirements.txt再跑服务。Go编译出来的单个二进制文件没有任何运行时依赖,扔到/usr/local/bin就能跑,这才是工具类项目该有的形态。
第二是子进程和并发。BrewUI每执行一个brew install或brew upgrade,本质上是启动一个子进程并持续读取它的输出流。Go的os/exec+io.Pipe+ goroutine处理这种场景非常顺手,一个安装任务一个goroutine,任务状态通过channel同步,写起来非常清晰,不容易出现回调地狱。
第三是交叉编译友好。Homebrew同时跑在Intel和Apple Silicon两种架构的Mac上,Go一句GOARCH=amd64 go build和GOARCH=arm64 go build就能分别出两个平台的二进制,这在发布时特别省事。
2.2 前端用Vite+Vue而不是React的原因
前端选型相对简单。BrewUI的界面规模属于“中后台工具”,页面数量少、交互密度高,核心是表格、列表、抽屉、树形图这几类组件。Vue 3的组合式API写这类页面非常舒服,ref、computed、watch这几个API就把绝大多数状态管理需求覆盖了,不需要引入Redux那套复杂的数据流转。
构建工具用Vite,因为它对本地开发场景太友好了。写BrewUI的时候我经常改一行代码,浏览器几乎秒级热更新,调试体验比Webpack时代舒服太多。而且Vite的依赖预构建让npm install之后第一次启动也不用漫长的等编译。构建产物是纯静态文件,扔给Go的embed包直接嵌进二进制,这就让最终发布物仍然保持“一个文件”的形态,不需要另外部署Nginx。
2.3 一次完整请求的数据流
BrewUI的架构分三层,我把一次请求的流转路径理清楚:
浏览器里的Vue组件发起请求,打给Go后端暴露的REST API,比如GET /api/formulas。后端收到请求后,通过exec.Command调用brew info --json=v2 --installed,把Homebrew返回的JSON解析成结构体,再按前端需要的结构重组成字段精简后的JSON返回。浏览器拿到这份JSON渲染成表格。
如果是一次安装操作,数据流会多一条通道:前端发起POST /api/install,后端启动brew install <formula>子进程,把stdout和stderr逐行读取出来,通过WebSocket推送到浏览器页面,页面上的“日志区域”实时滚动显示。这就是Shell命令层、API服务层、Web前端层三者的完整协作关系。
3. 核心实现:解析brew JSON信息流与操作指令映射
3.1 brew info --json=v2 的数据结构解读
BrewUI整个项目的信息基础,来自Homebrew自带的JSON输出能力。brew info --json=v2这条命令返回的信息量非常大,而且字段稳定,是天然的接口文档。
最关键的一段Go结构体定义是这样的:
type FormulaInfo struct { Name string `json:"name"` Desc string `json:"desc"` Homepage string `json:"homepage"` Version string `json:"version"` Versions Versions `json:"versions"` Installed []Installed `json:"installed"` Dependencies []string `json:"dependencies"` BuildDependencies []string `json:"build_dependencies"` RuntimeDependencies []RuntimeDependency `json:"runtime_dependencies"` Outdated bool `json:"outdated"` KegOnly bool `json:"keg_only"` PouredFromBottle bool `json:"poured_from_bottle"` } type Installed struct { Version string `json:"version"` InstalledAsDependency bool `json:"installed_as_dependency"` InstalledOnRequest bool `json:"installed_on_request"` }这几个字段几乎是整个BrewUI的“信息底座”:
installed_as_dependency和installed_on_request是判断包来源的关键。installed_as_dependency=true表示这个包是被其他包带进来的,卸载时就要谨慎,可能影响别的包。dependencies和build_dependencies分别表示运行时依赖和编译时依赖,构建依赖关系图就靠这两个字段。runtime_dependencies是“反向视角”,它告诉你这个包在安装时实际拉进来的具体版本依赖,做升级风险评估时比dependencies更精确。outdated直接在JSON里给了标记,不用再去跑一条brew outdated单独判断。
3.2 操作指令的安全映射表
BrewUI要执行安装、卸载、升级、清理等操作,这里有一条铁律:永远不要用字符串拼接的方式构造命令。用户输入的包名直接拼进shell命令,等于把系统整个交出去了。正确做法是建立一层白名单映射,所有到达命令层的参数都要经过校验。
我维护了一张操作映射表,核心逻辑就封装在一个RunBrew函数里:
var allowedCommands = map[string][]string{ "list": {"list", "--versions"}, "info": {"info", "--json=v2"}, "install": {"install"}, "uninstall": {"uninstall"}, "upgrade": {"upgrade"}, "cleanup": {"cleanup"}, "autoremove": {"autoremove"}, } func RunBrew(operation string, args []string, env []string) (int, error) { baseArgs, ok := allowedCommands[operation] if !ok { return 0, fmt.Errorf("unknown operation: %s", operation) } for _, input := range args { if strings.ContainsAny(input, "&|;`$\n\r\"' ") { return 0, fmt.Errorf("invalid input: %s", input) } } fullArgs := append(baseArgs[1:], args...) cmd := exec.Command(baseArgs[0], fullArgs...) cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") return runWithOutput(cmd) }这里有两个细节值得展开。
一是参数校验。brew install <pkg>里的pkg理论上是一个合法包名,合法包名的字符集非常窄,基本就是字母、数字、短横线、加号和斜杠,所以直接禁止空格和所有shell元字符,基本能堵死注入。
二是环境变量。终端里的brew能正常工作,是因为shell已经加载了.zshrc里配置的PATH。但BrewUI很多时候是后台服务启动的,环境变量不完整。所以在执行命令时统一追加LC_ALL=C,可以避免brew输出被本地化翻译,这一点在后面踩坑部分我要重点讲。
3.3 实时日志推送:WebSocket如何对接brew输出流
BrewUI里体验最“灵”的功能,是安装包时能实时看到日志滚动。要实现这个效果,需要把brew子进程的输出流接到浏览器的WebSocket上。
这里的核心Go代码思路如下:
func RunStreaming(command string, args []string, callback func(line string)) error { cmd := exec.Command(command, args...) stdout, _ := cmd.StdoutPipe() stderr, _ := cmd.StderrPipe() if err := cmd.Start(); err != nil { return err } var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { callback(scanner.Text()) } }() go func() { defer wg.Done() scanner := bufio.NewScanner(stderr) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { callback(scanner.Text()) } }() wg.Wait() return cmd.Wait() }这段代码有两个容易踩坑的地方。
第一,bufio.Scanner默认的最大行长度是64KB,而brew install编译某些包的时候输出行可能特别长,踩到限制就直接报错停止扫描。解决办法是scanner.Buffer()同时设置初始缓冲区和最大缓冲区,我这里给到1MB,实测不会撞上。
第二,stdout和stderr必须同时读取。如果只读stdout不读stderr,等输出积累到一定量,管道会被内核缓冲区塞满,子进程卡死。所以两个goroutine分别负责一个管道,最后WaitGroup等两边都读完再收尾。
4. 前端界面:基于Vite + Vue的仪表盘如何设计
4.1 仪表盘布局与核心组件
打开BrewUI,用户第一眼看到的就是总览卡片和三段式布局。
总览卡片显示四个数字:已安装包总数、过时可更新数、依赖链中包数、磁盘占用估算。这些数字不是后端单独算的,而是前端对/api/formulas返回的数据做聚合计算,省掉一次请求。
三段式布局分别是:最左侧是包列表,支持关键字过滤和“只看手动安装/只看依赖包/只看过时包”的筛选;点击任意一个包,中间区域展开详情,展示版本信息、简介、依赖树和反向依赖列表;最右侧是操作面板,安装、卸载、升级、清理的按钮都在这里,所有破坏性操作都要求用户先输入包名确认,防止误触。
依赖关系图我最初想引入ECharts的graph图,后来发现包一多反而渲染卡顿。最终方案是做一个“两级依赖视图”:第一级列出当前包的直接依赖,第二级在用户点击某个依赖后再展开它的依赖,按需加载,交互轻快,也不会一次性渲染上千个节点。
4.2 不引Pinia:组合式API手写状态管理
BrewUI引不引Pinia,我犹豫过。后来想明白:这个项目的全局状态只有“当前包列表”“当前筛选条件”“当前选中包”“正在进行的任务列表”这几项,用组合式API完全够,引入Pinia反而增加概念负担。
我写了一个极简的store.js:
import { reactive, computed } from 'vue' export const store = reactive({ formulas: [], outdatedCount: 0, selectedName: '', tasks: new Map(), loadedAt: null }) export const selectedFormula = computed(() => store.formulas.find(f => f.name === store.selectedName) ) export async function refreshFormulas() { const res = await fetch('/api/formulas') store.formulas = await res.json() store.outdatedCount = store.formulas.filter(f => f.outdated).length store.loadedAt = new Date() }这种方式的好处是:任何组件都可以直接引用同一个store对象,页面之间的数据天然共享,不需要事件总线或者层层props传参。等哪天这个项目的状态复杂到需要时间旅行调试,再考虑引入Pinia也不迟,现在这个阶段保持轻量是对的。
4.3 交互细节:暗色模式、空状态、错误反馈
作为工具型Web UI,BrewUI的交互细节决定了它好不好用。
暗色模式我直接复用终端配色风格,背景色取#1e1e2e,文字取#cdd6f4,强调色用#89b4fa,这套配色在长时间盯屏幕时比亮色舒服很多。列表行号、状态徽章、依赖树连接线都按这个色板统一。
空状态的处理我特意做了设计。比如用户搜索“zzz”找不到任何包时,页面不是光秃秃显示“无结果”,而是显示“没有匹配到包,试试输入更短的关键词”,并给出几个相邻包名。当前选中的包如果已经被卸载,详情面板会显示“该包已不在系统中”,并自动跳回列表第一个可用项。
错误反馈方面,最关键的体验是:任何一次brew install失败,日志面板里除了显示原始错误输出,还会在顶部用红色条提醒“命令退出码非0”,并给出常见的失败原因标签,比如“网络超时”“依赖冲突”“权限不足”。这个提示是从日志文本里做关键词匹配来的,虽然不完美,但能帮用户快速定位问题方向。
5. 本地部署与日常使用:从brew install到浏览器打开
5.1 三分钟跑起来
BrewUI日常使用非常简单。如果你只想本地快速体验,克隆代码后直接:
cd BrewUI make build ./brewui --listen 127.0.0.1:8972端口我特意选了8972,避开3000、8080、8088这些开发常用端口,减少被其他本地服务抢走的概率。
构建脚本里做了两件事:先用npm run build把前端Vite项目编成静态文件,再用Go的embed包把这些静态文件嵌入二进制。最终交付物就是一个brewui可执行文件,拷到任何一台Mac上都能跑,不需要装Node、不需要装Go、不需要Nginx。
启动后浏览器打开http://127.0.0.1:8972,首页会自动请求一次brew info --json=v2 --installed,根据包量不同,首次加载可能耗时1到3秒,之后会有本地缓存,再刷新基本是毫秒级。
5.2 后台守护与launchd开机自启
开发机重启之后BrewUI还想保持运行,我们需要用macOS的launchd来守护进程。
我在项目仓库里放了一份com.brewui.daemon.plist模板:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.brewui.daemon</string> <key>ProgramArguments</key> <array> <string>/opt/homebrew/bin/brewui</string> <string>--listen</string> <string>127.0.0.1:8972</string> <string>--data-dir</string> <string>/opt/homebrew/var/brewui</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> <key>StandardOutPath</key> <string>/opt/homebrew/var/log/brewui.log</string> <key>StandardErrorPath</key> <string>/opt/homebrew/var/log/brewui.err.log</string> </dict> </plist>装入和启动命令:
cp com.brewui.daemon.plist ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/com.brewui.daemon.plist launchctl start com.brewui.daemon这一步有个非常隐蔽的坑:launchd启动的进程不会加载你的shell配置文件,所以PATH环境变量是系统默认值。在Apple Silicon Mac上,Homebrew的路径是/opt/homebrew/bin,根本不在默认PATH里。如果BrewUI内部还傻乎乎地用exec.Command("brew", ...),就永远找不到brew命令。
我的解决办法是在BrewUI启动时先探测一次brew --prefix,记录下来当成“brew基路径”,后面所有命令都用这个绝对路径执行,绕开PATH问题。
func DetectBrewPath() string { cmd := exec.Command("/opt/homebrew/bin/brew", "--prefix") out, err := cmd.Output() if err != nil { return "/usr/local" // Intel Mac fallback } return strings.TrimSpace(string(out)) }这个探测逻辑同时兼容Intel和Apple Silicon两种架构,非常关键。
5.3 监听地址与多用户使用的安全建议
BrewUI默认只监听127.0.0.1,这是有意为之。因为BrewUI能执行安装、卸载、升级系统级包的操作,本质上是一个“无鉴权的高权限控制台”,暴露到局域网就相当于把开发机的软件管理权送给了同网段的任何人。
如果你确实需要从另一台设备访问,我建议的姿势是:BrewUI继续只监听本机,通过一个带Basic Auth的反向代理把它带出去,而不是直接改监听地址。Nginx或Caddy配置都行,Caddy可以顺手把HTTPS一起解决了。总之“监听本机 + 反代鉴权”是最稳妥的组合。
6. 踩坑记录与后续扩展方向
6.1 brew输出被本地化翻译的坑
我第一次在中文环境的Mac上跑BrewUI,发现依赖关系树的文本节点里偶尔出现“这个包是依赖关系的一部分”之类的中文提示,导致我基于英文关键词做的日志错误判断全部失效。
排查半天,原因是brew的输出语言跟随系统LANG和LC_ALL环境变量。终端里能用,是因为我的shell配置了英文或中文环境,而BrewUI作为后台服务继承的系统环境变量不一定和终端一致。
修复方式就是在执行任何brew命令时统一强制设置环境:
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")这样brew所有输出都是英文,日志解析稳定了,用户界面上需要本地化的内容由前端自己处理,双端彻底解耦。
6.2 Intel Mac与Apple Silicon的路径差异
Homebrew在Intel Mac上默认往/usr/local目录写文件,在Apple Silicon上默认往/opt/homebrew目录写。很多工具默认写死一个路径,换台机器就废。BrewUI不能这么干,我的策略是先探测brew --prefix拿基路径,所有需要写缓存、日志、PID文件的目录都基于这个基路径拼接。
还有一个权限问题要提醒:/usr/local目录在部分Intel Mac环境下权限比较窄,如果BrewUI清理缓存遇到permission denied,先检查运行BrewUI的进程用户有没有对应目录的写权限。Apple Silicon上/opt/homebrew通常由当前用户拥有,这类问题少很多。
6.3 JSON字段的版本兼容性
Homebrew的--json=v2输出结构总体稳定,但跨版本时也会有小变动。比如早期版本里runtime_dependencies可能不存在,某些老版本installed数组里installed_on_request字段缺失,直接按字段索引就会panic。
我的做法是Go结构体字段上多放几个omitempty,解析时都用指针或切片类型,缺失了不会崩。另外在CI里搭了三个不同Homebrew版本的测试矩阵,每次BrewUI发版前跑一遍,这个投入换来的稳定性回报很高。
6.4 后续值得扩展的方向
BrewUI现在能解决我80%的画面管理需求,剩下的20%我列了三个方向,也是给想接手或二次开发的朋友一些思路。
第一个是批量操作。当前安装、卸载都是单个包维度,后续想加“勾选多个outdated包,确认后依次升级”的批量流程,配合WebSocket进度条,体验会再上一个台阶。
第二个是Tap管理。Homebrew的三方Tap源在终端里只能通过brew tap命令管理,BrewUI可以在设置页展示当前已装的Tap列表,支持添加、删除、查看某个Tap下的所有公式,这块信息目前是纯文本的,可视化空间不小。
第三个是通知联动。升级任务在后台跑的时候,用户可能切去做别的,等任务完成可以发一个系统通知。macOS上可以用osascript或者直接调通知中心接口,这样BrewUI就不只是“打开浏览器才存在”的工具,而是真正融入开发流水的后台助手。
我个人在这两个周末里最大的体会是:给命令行工具做UI,价值不在于“把命令换成按钮”,而在于把那些原本需要人肉组合的信息重新组织成一个可以思考的视图。BrewUI现在每天就在我浏览器里躺着,帮我拦下了好几次“想卸载结果差点拆掉依赖链”的操作,这就够了。