get-shit-done 安装器修复深度解析:Homebrew Cellar 路径归一化如何避免dyld: Library not loaded
【免费下载链接】get-shit-doneA light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code by TÂCHES.项目地址: https://gitcode.com/GitHub_Trending/getshi/get-shit-done
导读
在 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
【免费下载链接】get-shit-doneA light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code by TÂCHES.项目地址: https://gitcode.com/GitHub_Trending/getshi/get-shit-done
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考