get-shit-done 安装器修复深度解析:Homebrew Cellar 路径归一化如何避免 `dyld: Library not loaded`
导读
在 macOS 上通过 Homebrew 安装 Node 后执行 brew upgrade node,再启动依赖 Node 运行钩子(hooks)的 Claude Code / Gemini / Codex 等运行时,有时会直接崩溃并抛出 dyld: Library not loaded。本文基于 get-shit-done(get-shit-done)仓库的变更记录(changeset)及其安装器实现,深入剖析这一故障的根因、resolveNodeRunner() 与 rewriteLegacyManagedNodeHookCommands() 两个核心修复函数的内部逻辑、归一化规则边界,以及对应的回归测试覆盖。读完你将掌握一套"在安装/升级工具链中把动态二进制路径收敛为稳定符号链接"的通用工程方法。
背景:为什么 hooks 命令里要"烧写"绝对 Node 路径
get-shit-done 是一套面向 Claude Code 的 meta-prompting 与上下文工程系统,它通过向各类 AI 运行时的配置目录注册"会话启动钩子"(SessionStart)与"工具调用后钩子"(PostToolUse / AfterTool)来注入状态行、更新检测等能力。这些以 .js 编写的钩子(例如 gsd-check-update.js)最终会以 type: "command" 的形式写进运行时的 settings.json(Claude Code、Gemini、Antigravity)或 config.toml(Codex),例如:
{
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "\"/usr/local/bin/node\" \"/Users/x/.gemini/hooks/gsd-check-update.js\""
}]
}]
}
}
早期版本使用裸 node <script> 前缀,但 bin/install.js 的实现注释明确记录了缺陷 #2979 的教训:当运行时从 Finder、Dock 等 GUI 入口启动时,进程 PATH 被裁剪到 /usr/bin:/bin:/usr/sbin:/sbin,nvm、Homebrew、Volta 的 node 二进制都不在 PATH 上,裸 node 会直接 command not found,钩子静默失效。因此在后续修复中,安装器会把"正在运行安装器的 Node 可执行文件绝对路径"(process.execPath)作为钩子解释器烧写进命令,从源码结构可以推断出这一点正是 bin/install.js 中 resolveNodeRunner() 的职责。
问题:Cellar 路径会随升级失效
路径一旦从"裸命令"变成"绝对路径",看似稳定,却引入了第二个故障(即本次变更记录对应的问题 #3181):
Homebrew 安装在解析符号链接后,process.execPath 返回的往往是带版本的 Cellar 内部路径,例如:
- Intel Mac:
/usr/local/Cellar/node/25.8.1/bin/node - Apple Silicon:
/opt/homebrew/Cellar/node/18.20.4/bin/node - 版本化 formula:
/usr/local/Cellar/node@20/20.11.0/bin/node
把这样的路径烧写进 settings.json 之后,一旦执行 brew upgrade node,该版本目录的共享库 SOVERSION 发生变化,Cellar 二进制无法再加载其依赖库,于是钩子启动时抛出:
dyld: Library not loaded
安装器在升级前烧写的旧路径就此"硬失效"。
修复一:normalizeNodePath() 把 Cellar 路径映射为稳定符号链接
解决问题的关键是识别出 Homebrew 其实始终维护着两个不随版本变化的稳定符号链接,每次 brew upgrade 时它们会被原子地重新指向新版本:
/usr/local/bin/node(Intel)/opt/homebrew/bin/node(Apple Silicon)
因此 bin/install.js 中的 normalizeNodePath(execPath) 用两段精确正则,把"Cellar 下的版本化路径"重写为对应的稳定符号链接:
function normalizeNodePath(execPath) {
if (!execPath) return execPath;
// Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
// 或 /usr/local/Cellar/node@20/<version>/bin/node
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
return '/usr/local/bin/node';
}
// Apple Silicon Homebrew: /opt/homebrew/Cellar/node/<version>/bin/node
// 或 /opt/homebrew/Cellar/node@18/<version>/bin/node
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
return '/opt/homebrew/bin/node';
}
return execPath;
}
该归一化策略的边界非常克制(全部可从 回归测试 中逐条验证):
| 输入路径 | 归一化结果 |
|---|---|
/usr/local/Cellar/node/25.8.1/bin/node |
/usr/local/bin/node |
/usr/local/Cellar/node/20.11.0/bin/node |
/usr/local/bin/node |
/usr/local/Cellar/node/22.0.0-rc.1/bin/node |
/usr/local/bin/node |
/usr/local/Cellar/node@20/20.11.0/bin/node |
/usr/local/bin/node |
/opt/homebrew/Cellar/node/25.8.1/bin/node |
/opt/homebrew/bin/node |
/opt/homebrew/Cellar/node@18/18.20.4/bin/node |
/opt/homebrew/bin/node |
/Users/dev/.nvm/versions/node/v20.11.0/bin/node |
原样返回 |
/usr/bin/node |
原样返回 |
C:\Program Files\nodejs\node.exe |
原样返回 |
非 Homebrew 安装(nvm、系统 node、Windows)不做任何改写;空字符串与 null 亦原样透传,保留了既有的空值防护语义。从源码结构看,(@\d+)? 这个分组正是为 node@20 / node@18 这类版本化 formula 设计的,体现了对 Homebrew 生态两种布局的完整覆盖。
修复二:resolveNodeRunner() 在生成 runner 时先归一化
bin/install.js 的 resolveNodeRunner() 是安装器生成钩子解释器的统一入口。它的完整逻辑是:
function resolveNodeRunner() {
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
if (!execPath) return null;
const stablePath = normalizeNodePath(execPath);
// JSON.stringify 产生带正确转义的双引号 shell token,
// 对含空格或特殊字符的路径是安全的
return JSON.stringify(stablePath.replace(/\\/g, '/'));
}
关键点有两个:
- 先归一化再返回:
process.execPath若命中 Cellar 布局,返回的是稳定符号链接(仍以双引号包裹,例如"/usr/local/bin/node"),而非原版本化路径; - 返回 null 意味着跳过注册:当
execPath为空时返回null,调用方(如 buildHookCommand、Codex hooks 注册逻辑)会选择"警告并跳过注册"而不是写出一条注定失败的裸命令——宁可少一个钩子,也不写一个坏的。
resolveNodeRunner() 是跨运行时共享的 runner 来源,同时服务于 settings.json 表面(Claude/Gemini/Antigravity)与 Codex 的 TOML / hooks.json 表面。因此这一个修复点即可覆盖所有受影响运行时的新增钩子。
修复三:rewriteLegacyManagedNodeHookCommands() 治愈历史存量
仅在安装新钩子时使用稳定路径是不够的:升级前已经写进用户 settings.json 的旧 Cellar 命令仍然存在,若不处理,用户重装后它们依旧指向失效路径。为此 bin/install.js 实现了 rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts),在安装流程中(bin/install.js)被调用来"清洗"存量命令。
从源码结构看,它的行为可以归纳为以下几条精确规则(均有对应测试断言):
- 识别两种待改写形态:① 遗留的裸
node <script>形态(#2979/#3002 的旧产物);② Cellar 形态"/usr/local/Cellar/node/<v>/bin/node" <script>或"/opt/homebrew/Cellar/node/<v>/bin/node" <script>(#3181 的新目标)。 - 只处理托管钩子脚本:通过 basename 与托管钩子清单做精确等值匹配(而非子串包含),见 测试。用户自建的钩子即便恰好也指向一个 Cellar node,也不会被改动。
- 幂等无扰动:已经使用稳定 runner 的条目直接跳过(
changed=false),避免每次重装都改写用户配置产生噪音,见 测试。 - PowerShell 调用运算符兼容:Windows 下
&前缀会被临时剥离、在投影后按运行时策略恢复。
函数返回布尔值 changed 表示是否有条目被重写,重写后的命令进一步经由 projectLegacySettingsHookCommand 投影为符合目标运行时(Claude/Gemini/Codex 等)的形状——也就是说该函数同时是"命令投影缝"(shell command projection seam)的入口之一,相关设计可参见 ADR 0009。
用测试锁定行为边界
本次修复并非一次性补丁,而是携带了系统化的回归测试 tests/bug-3181-node-cellar-path.test.cjs,测试文件头部注释完整复述了 bug 成因,且明确约定"所有断言都基于导出函数的返回值,不做源码文本搜索"。测试组覆盖:
normalizeNodePath:Intel / Apple Silicon 的普通版本与node@NN版本化路径均收敛为稳定符号链接;nvm、系统 node、Windows、空值一律原样返回(L38-L107);resolveNodeRunner:通过临时重定义process.execPath模拟 Intel/Apple Silicon Cellar 场景,断言返回双引号包裹的稳定符号链接;nvm 场景断言原路径不变;空execPath断言返回null(L111-L163);rewriteLegacyManagedNodeHookCommands:Cellar runner 被改写到稳定符号链接、已稳定条目不动、非托管脚本不动、旧的裸node形态仍然照常被改写(L167-L263)。
最后一个用例尤其关键:它证明 #3181 的 Cellar 改写与 #2979 的裸-node 改写是叠加而非互斥的,两条历史修复链在同一个清洗函数中共存且互不破坏。
如何复现、验证与升级
如果你正运行 get-shit-done 且使用 Homebrew 安装的 node,可以按下面的思路自检:
- 检查已烧写路径:查看
~/.claude/settings.json(或~/.gemini/settings.json等对应运行时配置)中钩子命令是否包含形如/usr/local/Cellar/node/或/opt/homebrew/Cellar/node/的字符串。若包含,说明属于修复前写入的旧形态。 - 触发清洗:重新执行安装器(覆盖安装 / 重装流程即会调用
rewriteLegacyManagedNodeHookCommands),确认命令中的 Cellar 路径被改写为/usr/local/bin/node或/opt/homebrew/bin/node。参见 安装说明 或仓库根 README 中的安装方式。 - 升级 node 验证:执行
brew upgrade node后再次启动运行时,钩子不再报dyld: Library not loaded,即表明稳定符号链接生效。
在升级前修复早已合并至 v1.41.0 发布线,对应发布说明可见 RELEASE-v1.41.0.md。如果你在 macOS 上通过 Homebrew 管理 node,并曾遇到过升级 node 后 AI 客户端钩子静默失效或 dyld 崩溃,本修复正是针对该场景的收敛方案。
小结:一类值得复用的"可升级路径"设计模式
从本次变更可以提炼出一条具有普适性的工程原则:凡是会被长期持久化(写入配置文件)的可执行路径,都不应使用解析符号链接后的版本化路径,而应收敛到工具链维护的稳定符号链接。安装器在"何时归一化"(resolveNodeRunner 新增写入)、"何处清洗"(rewriteLegacyManagedNodeHookCommands 存量迁移)、"哪些该动"(basename 精确匹配、幂等跳过)三个层面分别做了处理,并配齐了覆盖两个 Homebrew 架构、两种 formula 布局与全部边界情况的回归测试。这种"写入即正确、存量可迁移、越界不误伤"的组合,值得所有在安装脚本中持久化运行路径的工程实践借鉴。
相关实现与证据均位于仓库内,可继续深入阅读:
- 变更记录:.changeset/gallant-badgers-bark.md
- 归一化实现:bin/install.js
- Runner 解析:bin/install.js
- 存量命令清洗:bin/install.js
- 回归测试:tests/bug-3181-node-cellar-path.test.cjs
- 命令投影架构:docs/adr/0009-shell-command-projection-module.md
- 发布说明:docs/RELEASE-v1.41.0.md
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00