get-shit-done 修复指南:Managed JS Hooks 在 GUI/minimal-PATH 运行时下的 Node 解析(PR 2979)全解析
这篇技术文章以
.changeset/jolly-pumas-dance.md(对应 PR #2979 的变更集片段)为骨架,结合 bin/install.js 与 tests/bug-2979-hook-absolute-node.test.cjs 等仓库源码,逐层剖析 get-shit-done 安装器如何用process.execPath取代裸node作为每个 Managed.jsHook 的命令执行器,从而修复在 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, '/'));
}
设计要点有三,全部对应源码中的明确意图:
- 取"正在运行安装器的那份 Node":
process.execPath返回的是当前执行安装器的 Node 二进制绝对路径。用户此刻能成功执行npx get-shit-done-cc,说明这份 Node 一定是可用的;以它作为 Hook runner,是与用户安装环境天然一致的最稳妥默认。 - 返回
null而非回退裸node:当process.execPath不可用时,函数显式返回null。测试断言了这一行为(tests/bug-2979-hook-absolute-node.test.cjs):宁可不注册,也不能退回写下一条必然失败的node命令。 - JSON 字符串化 = 安全引号 + 正斜杠:
JSON.stringify天然产出被双引号包裹、内含转义的 shell token(空格路径安全);再配合.replace(/\\/g, '/')将 Windows 反斜杠统一为正斜杠,使产物在 POSIX 与 Windows 上皆可执行。测试专门断言返回值以"开头结尾、不含反斜杠、且 basename 匹配/^node(\.exe)?$/i(tests/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 的取舍依据(源码注释)值得展开:
.jsHook → 绝对 Node 路径(本修复 #2979):GUI 启动的运行时 PATH 极简,不含 nvm/Homebrew/Volta 的 node 二进制,必须给出绝对路径。.shHook → 裸bash(POSIX):POSIX 只保证/bin/sh存在、不保证/bin/bash,且 NixOS 等发行版默认没有/bin/bash;因此走 PATH 解析的bash在各类发行版间更可移植。测试断言.shHook 的 runner 恰好是bash(tests/bug-2979-hook-absolute-node.test.cjs)。- Windows 上的
.shHook → 显式解析 Git Bash(#3393):Windows 的 PowerShell/cmd 环境里裸bash可能不在 PATH,resolveBashRunner()(bin/install.js)会按序探测GSD_BASH_PATH、ProgramFiles、ProgramFiles(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.sh、gsd-phase-boundary.sh、gsd-validate-commit.sh、gsd-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-
nodeHook(文件名不在托管白名单)、.shHook(正确使用裸 bash)一律不动;absoluteRunner为null时整个函数为 no-op(tests/bug-2979-hook-absolute-node.test.cjs)。
此外,作为最后防线,validateHookFields(settings) 在写回 settings.json 前剔除任何 command: null 的坏条目——因为若某注册点守卫回归,resolveNodeRunner() 返回 null 时 buildHookCommand 也会返回 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 的.jsHook 命令形如& "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),按 platform 与 runtime 两个维度决定最终命令形态。
六、结构化测试:面向行为的断言而非源码 grep
值得借鉴的是本修复的测试方法论。测试文件头部明确声明:"No source-grep on install.js content——断言直接作用于导出函数的返回值与产出命令的解析结构"。具体做法:
- 从 bin/install.js 解构导出
{ buildHookCommand, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands, validateHookFields }; - 对 JS Hook 命令按"runner + hookPath"两个 token 做结构化解析(
<runner> "<hookPath>",runner 本身可能是含空格的引号路径,故按尾部引号 token 切分而非首个空格); - 断言 runner 是绝对引号路径、以
node/node.exe结尾、正斜杠、不等于裸node;.shrunner 恰为bash。
这种"以导出函数返回的结构记录为断言对象"的方式,既锁死行为契约,又不过度耦合实现细节,任何回归(如哪天某调用点偷偷退回裸 node)都会被立即捕获。
七、小结:修复链路全景
从 .changeset/jolly-pumas-dance.md 的一句话出发,沿源码还原出的完整修复链路为:
- 根因:GUI/minimal-PATH 运行时(Finder 启动的 Gemini、Antigravity、Codex 等)下裸
node不可解析,托管.jsHook 报 127 退出码; - 新注册路径:
resolveNodeRunner()以process.execPath铸造双引号、正斜杠、绝对路径的 runner,经normalizeNodePath消化 Homebrew Cellar 版本路径,并贯彻"拿不到就返回 null 宁缺毋滥"的纪律; - 命令拼装:
buildHookCommand按扩展名分流——.js用绝对 Node、.sh用 PATH 解析 bash(Windows 则显式探测 Git Bash); - 存量迁移:
rewriteLegacyManagedNodeHookCommands在重装时按 basename 精确匹配改写旧 bare-node条目,validateHookFields兜底清除command: null残留; - 平台投影:按
platform/runtime决定是否加 PowerShell&运算符; - 测试护栏:全部行为断言覆盖于 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 白名单防止误伤,是本修复可复用的通用工程范式,值得在同类安装器/脚手架场景中借鉴。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00