首页
/ get-shit-done 修复指南:Managed JS Hooks 在 GUI/minimal-PATH 运行时下的 Node 解析(PR 2979)全解析

get-shit-done 修复指南:Managed JS Hooks 在 GUI/minimal-PATH 运行时下的 Node 解析(PR 2979)全解析

2026-09-07 16:58:27作者:冯梦姬Eddie

这篇技术文章以 .changeset/jolly-pumas-dance.md(对应 PR #2979 的变更集片段)为骨架,结合 bin/install.jstests/bug-2979-hook-absolute-node.test.cjs 等仓库源码,逐层剖析 get-shit-done 安装器如何用 process.execPath 取代裸 node 作为每个 Managed .js Hook 的命令执行器,从而修复在 macOS Finder 启动、Antigravity、Gemini、Codex 等精简 PATH 环境下 node: command not found 的问题。读完你将掌握:裸 node 失效的根因、绝对执行器(absolute runner)的解析与跨平台 shell 拼接规则、旧式 hook 命令的迁移重写机制,以及对应的结构化回归测试设计。

一、背景:一条 changeset 片段背后的真实故障

.changeset/jolly-pumas-dance.md 是 get-shit-done 采用 Keep a Changelog 风格的发布变更片段,全文内容为:

---
type: Fixed
pr: 2979
---
Managed JS hooks now resolve under GUI/minimal-PATH runtimes — installer emits
process.execPath (absolute, quoted, forward-slash-normalized) as the runner for
every .js hook command instead of bare node. See #2979.

要理解这条修复,需要先弄清 .changeset 目录的定位(参见 .changeset/README.md):每个带用户可见变更的 PR 都会在这里落一个 <随机名>.md 片段,发布时由 node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD 合并进根目录 CHANGELOG.md。随机三词命名的意义在于——多个 PR 若同时编辑 ### Fixed 区块必然在 merge 时冲突,而各自新增独立文件则天然无冲突。

回到故障本身。get-shit-done 为一众 AI Coding 运行时(Claude Code、Gemini CLI、Codex、Antigravity、Cursor、Windsurf、Kilo 等)安装"托管式 Hook"(Managed Hooks),例如检查更新、上下文监控、Prompt 注入扫描、读写守卫、工作流守卫等。安装器把这些 Hook 以 command 形式写入各运行时的 settings.json,形如:

node "<CONFIG_DIR>/hooks/gsd-check-update.js"

测试文件的开头注释(tests/bug-2979-hook-absolute-node.test.cjs)记录了故障现场:在精简 PATH(如 /usr/bin:/bin:/usr/sbin:/sbin,这是 macOS 上由 Finder 启动、或由 Antigravity 派生的进程的默认 PATH)中,node 无法被解析,Hook 命令直接以 /bin/sh: node: command not found(退出码 127)失败。也就是说:GUI 双击启动 / 桌面应用派生的进程没有继承你 shell 里配置的 nvm、Homebrew、Volta 等 Node 路径,而托管 Hook 却依赖裸 node

二、修复核心:resolveNodeRunner 用 process.execPath 铸造绝对执行器

安装器在 bin/install.js 中定义了修复的关键函数 resolveNodeRunner()

function resolveNodeRunner() {
  const execPath = typeof process.execPath === 'string' ? process.execPath : '';
  if (!execPath) return null;
  const stablePath = normalizeNodePath(execPath);
  // JSON.stringify produces a properly escaped double-quoted shell token,
  // safe for paths containing spaces or unusual characters.
  return JSON.stringify(stablePath.replace(/\\/g, '/'));
}

设计要点有三,全部对应源码中的明确意图:

  1. 取"正在运行安装器的那份 Node"process.execPath 返回的是当前执行安装器的 Node 二进制绝对路径。用户此刻能成功执行 npx get-shit-done-cc,说明这份 Node 一定是可用的;以它作为 Hook runner,是与用户安装环境天然一致的最稳妥默认。
  2. 返回 null 而非回退裸 node:当 process.execPath 不可用时,函数显式返回 null。测试断言了这一行为(tests/bug-2979-hook-absolute-node.test.cjs):宁可不注册,也不能退回写下一条必然失败的 node 命令。
  3. JSON 字符串化 = 安全引号 + 正斜杠JSON.stringify 天然产出被双引号包裹、内含转义的 shell token(空格路径安全);再配合 .replace(/\\/g, '/') 将 Windows 反斜杠统一为正斜杠,使产物在 POSIX 与 Windows 上皆可执行。测试专门断言返回值以 " 开头结尾、不含反斜杠、且 basename 匹配 /^node(\.exe)?$/itests/bug-2979-hook-absolute-node.test.cjs)。

2.1 进阶:normalizeNodePath 对抗 brew upgrade node(#3181)

如果只做 JSON.stringify(process.execPath),Homebrew 用户会踩另一个坑。normalizeNodePath()bin/install.js)的注释揭示了原因:Homebrew 下 process.execPath 解析符号链接后会得到带版本号的 Cellar 路径,例如 /usr/local/Cellar/node/25.8.1/bin/node。如果把该路径烤进 Hook 命令,一旦 brew upgrade node 升级版本,二进制引用的共享库 SOVERSION 变化,Hook 就会报 dyld: Library not loaded

因此该函数用正则识别 Intel(/usr/local/Cellar/node...)与 Apple Silicon(/opt/homebrew/Cellar/node...)两类 Cellar 路径,并替换为 Homebrew 原子重指、跨升级存活的稳定符号链接:

// Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
  return '/usr/local/bin/node';
}
// Apple Silicon Homebrew
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
  return '/opt/homebrew/bin/node';
}
return execPath;   // nvm / 系统 Node / Windows 等原样返回

这也解释了测试中出现 "/usr/local/bin/node" 作为固定 runner 的由来——它是"升级安全"语义的代表。

三、buildHookCommand:.js 走绝对 Node,.sh 走 PATH 解析的 bash

修复不能一刀切。安装器按 Hook 文件扩展名区分执行器策略,核心逻辑在 buildHookCommand()bin/install.js):

function buildHookCommand(configDir, hookName, opts) {
  if (!opts) opts = {};
  const nodeRunner = resolveNodeRunner();
  const runner = hookName.endsWith('.sh') ? resolveBashRunner(opts) : nodeRunner;
  if (runner === null) return null;

  if (opts.portableHooks) {
    // --portable-hooks:发射 $HOME 相对路径(WSL/Docker bind-mount 场景)
    // ...
  }
  const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
  return projectManagedHookCommand({ absoluteRunner: runner, scriptPath: hooksPath, runtime, platform });
}

两类 runner 的取舍依据(源码注释)值得展开:

  • .js Hook → 绝对 Node 路径(本修复 #2979):GUI 启动的运行时 PATH 极简,不含 nvm/Homebrew/Volta 的 node 二进制,必须给出绝对路径。
  • .sh Hook → 裸 bash(POSIX):POSIX 只保证 /bin/sh 存在、不保证 /bin/bash,且 NixOS 等发行版默认没有 /bin/bash;因此走 PATH 解析的 bash 在各类发行版间更可移植。测试断言 .sh Hook 的 runner 恰好是 bashtests/bug-2979-hook-absolute-node.test.cjs)。
  • Windows 上的 .sh Hook → 显式解析 Git Bash(#3393):Windows 的 PowerShell/cmd 环境里裸 bash 可能不在 PATH,resolveBashRunner()bin/install.js)会按序探测 GSD_BASH_PATHProgramFilesProgramFiles(x86)SystemDrive 下的 Git/bin/bash.exe;全部不存在时返回 null,调用方跳过注册而不是写一条注定坏掉的命令(tests/bug-2979-hook-absolute-node.test.cjs)。

buildHookCommand 的调用点覆盖了安装器托管的全部 Hook(bin/install.js),从中可梳理出 Managed JS Hooks 的完整名单与对应运行时事件(事件与 matcher 亦见 tests/bug-2979-hook-absolute-node.test.cjs):

Hook 文件 事件 / matcher 职责(依命名与注册代码推断)
gsd-check-update.js SessionStart 启动时检查新版本
gsd-statusline.js Session 状态栏 会话状态行
gsd-update-banner.js Session 更新横幅
gsd-context-monitor.js PostToolUse(Bash/Edit/Write/...) 上下文占用监控
gsd-prompt-guard.js PreToolUse(Write/Edit) 提示注入防护
gsd-read-guard.js PreToolUse(Write/Edit) 读取守卫
gsd-read-injection-scanner.js PostToolUse(Read) 读取注入扫描
gsd-workflow-guard.js PreToolUse(Bash/Edit/Write/...) 工作流守卫

(这些脚本源文件位于 hooks/ 目录。)同目录还有 .sh 类托管 Hook,如 gsd-session-state.shgsd-phase-boundary.shgsd-validate-commit.shgsd-graphify-update.sh,注册于 bin/install.js

四、存量修复:rewriteLegacyManagedNodeHookCommands 迁移旧式命令

resolveNodeRunner 只对未来新注册的 Hook 生效。PR #2979 的 code review(#3002)随即指出:旧安装残留的 bare-node 条目在重装后不会被改写,等于漏洞原样保留。于是 bin/install.js 增加了遍历重写函数 rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts)

  • 遍历 settings.hooks 全部事件的 command 条目,把以 node 开头(含裸词、单引号、双引号路径形式)的命令改写为 <absoluteRunner> <script>
  • 同时把旧式 Cellar 版本化路径 runner("/usr/local/Cellar/node/<v>/bin/node")统一规范化到稳定符号链接(#3181 联动);
  • 以 basename 精确相等判定"是否为托管 Hook",这是 #3002 CR 的防误伤要点:早期实现用 includes(托管文件名) 判断,会把路径恰好包含 gsd-check-update.js 子串的用户自定义 Hook 误改。测试明确覆盖了 wraps-gsd-check-update.js-helper.js 这类子串假阳性场景(tests/bug-2979-hook-absolute-node.test.cjs);
  • 用户自写的 bare-node Hook(文件名不在托管白名单)、.sh Hook(正确使用裸 bash)一律不动;absoluteRunnernull 时整个函数为 no-op(tests/bug-2979-hook-absolute-node.test.cjs)。

此外,作为最后防线,validateHookFields(settings) 在写回 settings.json 前剔除任何 command: null 的坏条目——因为若某注册点守卫回归,resolveNodeRunner() 返回 nullbuildHookCommand 也会返回 null,若没有清理由可能产生空命令残留。该函数对六类托管 JS Hook 逐一测试"真条目存活、null 条目被清",并保留 type: 'agent' 类型的 Hook(tests/bug-2979-hook-absolute-node.test.cjs)。

五、Windows 特殊面:PowerShell 调用运算符与 shell 中立的取舍

跨平台修复必须顾及 Windows 的两套 shell 环境,相关测试位于 tests/bug-2979-hook-absolute-node.test.cjs

  • Gemini(PowerShell)需要 & 调用运算符:PowerShell 中直接以引号开头的命令不会按可执行文件执行,必须前置 & 。因此 Gemini on Windows 的 .js Hook 命令形如 & "C:/nvm4w/nodejs/node.exe" "...gsd-check-update.js"bin/install.js 及 #3002 CR 测试)。
  • Claude(Git Bash / shell 中立)不需要 & :Claude Code 的 Hook 由 Git Bash 类 shell 执行,加了 & 反而不合法。测试断言 Claude on Windows 的 Hook 命令不含 PowerShell 前缀(tests/bug-2979-hook-absolute-node.test.cjs)。
  • 迁移重写器同样具备运行时感知:对 Gemini on Windows 补 &、对已是 & 前缀的条目防重复加前缀、对 Claude on Windows 剥离陈旧 &tests/bug-2979-hook-absolute-node.test.cjs),并从单引号/反斜杠窗式路径统一归一化为双引号正斜杠形式(#3392,见 tests/bug-2979-hook-absolute-node.test.cjs)。

从源码结构可推断:这套 "shell 命令投影"(shell command projection)策略在仓库中被抽象为独立能力模块(参见 docs/adr/0009-shell-command-projection-module.md 及其回归测试 tests/bug-3413-shell-command-projection.test.cjs),按 platformruntime 两个维度决定最终命令形态。

六、结构化测试:面向行为的断言而非源码 grep

值得借鉴的是本修复的测试方法论。测试文件头部明确声明:"No source-grep on install.js content——断言直接作用于导出函数的返回值与产出命令的解析结构"。具体做法:

  1. bin/install.js 解构导出 { buildHookCommand, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands, validateHookFields }
  2. 对 JS Hook 命令按"runner + hookPath"两个 token 做结构化解析(<runner> "<hookPath>",runner 本身可能是含空格的引号路径,故按尾部引号 token 切分而非首个空格);
  3. 断言 runner 是绝对引号路径、以 node/node.exe 结尾、正斜杠、不等于裸 node.sh runner 恰为 bash

这种"以导出函数返回的结构记录为断言对象"的方式,既锁死行为契约,又不过度耦合实现细节,任何回归(如哪天某调用点偷偷退回裸 node)都会被立即捕获。

七、小结:修复链路全景

.changeset/jolly-pumas-dance.md 的一句话出发,沿源码还原出的完整修复链路为:

  1. 根因:GUI/minimal-PATH 运行时(Finder 启动的 Gemini、Antigravity、Codex 等)下裸 node 不可解析,托管 .js Hook 报 127 退出码;
  2. 新注册路径resolveNodeRunner()process.execPath 铸造双引号、正斜杠、绝对路径的 runner,经 normalizeNodePath 消化 Homebrew Cellar 版本路径,并贯彻"拿不到就返回 null 宁缺毋滥"的纪律;
  3. 命令拼装buildHookCommand 按扩展名分流——.js 用绝对 Node、.sh 用 PATH 解析 bash(Windows 则显式探测 Git Bash);
  4. 存量迁移rewriteLegacyManagedNodeHookCommands 在重装时按 basename 精确匹配改写旧 bare-node 条目,validateHookFields 兜底清除 command: null 残留;
  5. 平台投影:按 platform/runtime 决定是否加 PowerShell & 运算符;
  6. 测试护栏:全部行为断言覆盖于 tests/bug-2979-hook-absolute-node.test.cjs,并由此派生 #3181(Homebrew Cellar)、#3392/#3393(Windows 路径与 bash)、#3413(shell 中立)等联动修复。

对一个"帮多个 AI 编码工具装 Hook"的安装器而言,这条看起来只有一句的 changeset,实质上回答了一个高频痛点:用户环境里 Node 不一定在 PATH 上,而托管代码不能假设任何 shell 上下文。以 process.execPath 作为执行器来源、以 JSON 字符串化保证 shell 安全、以 basename 白名单防止误伤,是本修复可复用的通用工程范式,值得在同类安装器/脚手架场景中借鉴。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390