首页
/ get-shit-done 安装器修复深度解析:Homebrew Cellar 路径归一化如何避免 `dyld: Library not loaded`

get-shit-done 安装器修复深度解析:Homebrew Cellar 路径归一化如何避免 `dyld: Library not loaded`

2026-09-07 11:51:58作者:姚月梅Lane

导读

在 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.jsresolveNodeRunner() 的职责。

问题: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.jsresolveNodeRunner() 是安装器生成钩子解释器的统一入口。它的完整逻辑是:

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, '/'));
}

关键点有两个:

  1. 先归一化再返回process.execPath 若命中 Cellar 布局,返回的是稳定符号链接(仍以双引号包裹,例如 "/usr/local/bin/node"),而非原版本化路径;
  2. 返回 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)被调用来"清洗"存量命令。

从源码结构看,它的行为可以归纳为以下几条精确规则(均有对应测试断言):

  1. 识别两种待改写形态:① 遗留的裸 node <script> 形态(#2979/#3002 的旧产物);② Cellar 形态 "/usr/local/Cellar/node/<v>/bin/node" <script>"/opt/homebrew/Cellar/node/<v>/bin/node" <script>(#3181 的新目标)。
  2. 只处理托管钩子脚本:通过 basename 与托管钩子清单做精确等值匹配(而非子串包含),见 测试。用户自建的钩子即便恰好也指向一个 Cellar node,也不会被改动。
  3. 幂等无扰动:已经使用稳定 runner 的条目直接跳过(changed=false),避免每次重装都改写用户配置产生噪音,见 测试
  4. 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 断言返回 nullL111-L163);
  • rewriteLegacyManagedNodeHookCommands:Cellar runner 被改写到稳定符号链接、已稳定条目不动、非托管脚本不动、旧的裸 node 形态仍然照常被改写(L167-L263)。

最后一个用例尤其关键:它证明 #3181 的 Cellar 改写与 #2979 的裸-node 改写是叠加而非互斥的,两条历史修复链在同一个清洗函数中共存且互不破坏。

如何复现、验证与升级

如果你正运行 get-shit-done 且使用 Homebrew 安装的 node,可以按下面的思路自检:

  1. 检查已烧写路径:查看 ~/.claude/settings.json(或 ~/.gemini/settings.json 等对应运行时配置)中钩子命令是否包含形如 /usr/local/Cellar/node//opt/homebrew/Cellar/node/ 的字符串。若包含,说明属于修复前写入的旧形态。
  2. 触发清洗:重新执行安装器(覆盖安装 / 重装流程即会调用 rewriteLegacyManagedNodeHookCommands),确认命令中的 Cellar 路径被改写为 /usr/local/bin/node/opt/homebrew/bin/node。参见 安装说明 或仓库根 README 中的安装方式。
  3. 升级 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 布局与全部边界情况的回归测试。这种"写入即正确、存量可迁移、越界不误伤"的组合,值得所有在安装脚本中持久化运行路径的工程实践借鉴。

相关实现与证据均位于仓库内,可继续深入阅读:

登录后查看全文
热门项目推荐
相关项目推荐