get-shit-done 跨平台加固系列:shell-command-projection 缝中的子进程分发与平台文件 I/O(IO Seam)拆解
本篇基于 get-shit-done 仓库的变更集 shell-projection-io-seam.md 及其引用的架构决策记录,深入讲解该仓库跨平台加固(cross-platform hardening)第一阶段的核心成果:shell-command-projection.cjs 如何一次性收编了子进程分发(execGit、execNpm、execTool、probeTty)与平台文件 I/O(platformWriteSync、platformReadSync、platformEnsureDir、normalizeContent)两类 OS 面向能力。读完后,你将理解"单一 OS 缝(seam)"这一架构模式如何消除散落在安装器、规划工作流各处的平台条件分支,以及如何通过统一的返回形状约定让 2000+ 行规模的命令文本渲染、子进程与文件写入逻辑变得可测试、可迁移。
一、变更集说了什么:一个缝,收编所有 OS 面向 I/O
.changeset/shell-projection-io-seam.md 是一份标准 changeset 变更记录(type: Changed,关联 PR #3470),其核心事实只有一句话,但信息量很大:
Add subprocess dispatch (
execGit,execNpm,execTool,probeTty) and platform file I/O seam (platformWriteSync,platformReadSync,platformEnsureDir,normalizeContent) toshell-command-projection.cjs. Single seam for all OS-facing I/O — phase 1 of cross-platform hardening. See #3465.
拆解为三条关键结论:
- 能力落点:子进程分发与平台文件 I/O 两组共 8 个函数,全部加入 shell-command-projection.cjs,而不是新建独立模块;
- 架构意图:"Single seam for all OS-facing I/O"——所有与操作系统直接打交道的调用(起子进程、读/写文件、探测 TTY)必须经过同一个缝,平台条件逻辑只允许出现在缝内部;
- 阶段定位:这是跨平台加固的第一阶段,上游问题追踪号为 #3465。
要理解这条变更记录的分量,需要回溯该缝的来历。ADR-0009(0009-shell-command-projection-module.md)最初提出的 Shell Command Projection Module 只负责"命令文本渲染"——把类型化的命令意图(platform / shell / runtime / executable / args / pathStyle)投影成目标 shell 的具体命令文本,明确"不作为通用命令运行器"。其动因是一长串跨 shell 回归缺陷历史(#2376、#2979、#3002、#3011、#3181、#3393、#3413 等):安装器各处手工拼接 hook 命令、PATH 修复指令、shim 脚本,引号策略、路径斜杠方向、PowerShell 调用符前缀散落多处,一处漏改就是一类回归。
二、缝的职责扩张:从"只渲染"到"渲染 + 执行 + 文件"
ADR-0009 末尾的更新章节(2026-05-13,Phases 1–4 expansion,#3465–#3468)明确记录了这次"越界"是被有意批准的:
The "does not become a generic command runner" and "does not replace safe internal subprocess APIs" constraints ... were intentionally superseded.
现在 shell-command-projection.cjs 实际拥有的职责有四块,其中后两块正是本文档(IO seam 变更集)引入的:
| 职责 | 代表导出 | 引入阶段 |
|---|---|---|
| 运行时感知的命令文本渲染 | projectManagedHookCommand、projectCodexHookTomlCommand、formatSdkPathDiagnostic 等 |
ADR 原始范围 |
| 子进程分发 | execGit、execNpm、execTool、probeTty |
Phase 2(#3466) |
| 平台文件 I/O | platformWriteSync、platformReadSync、platformEnsureDir、normalizeContent |
Phase 3(#3467) |
移除 core.cjs 中的遗留包装器 atomicWriteFileSync / safeReadFile / normalizeMd |
— | Phase 4(#3468) |
ADR 同时给出了该缝必须维持的结果形状不变量(Result-shape invariant):
- 所有
exec*函数一律返回{ exitCode, stdout, stderr }形状,非零退出码不抛异常; - 平台条件逻辑(
shell: process.platform === 'win32'、probeTty在 Windows 返回 null、.md感知的归一化)只允许存在于缝内部。
这个不变量是整个 IO seam 设计的验收标准,也是后文读源码的对照基准。
三、子进程分发:execGit / execNpm / execTool / probeTty
3.1 为什么要把 spawnSync 调用收进缝里
在迁移之前,调用方各自直接 require('child_process') 并内联平台判断:Windows 上 npm 需要 shell: true(npm 实为 npm.cmd)、git 凭据提示可能无限挂起、ENOENT 与超时各写各的容错。变更集 .changeset/shell-projection-subprocess-migration.md(PR #3476)记录了消费侧迁移:"Removes scattered platform-conditional logic and normalizes subprocess error handling. Local execGit wrapper removed from core.cjs; verify.cjs and worktree-safety.cjs migrated to the seam's (args, opts) signature."
3.2 逐个拆解(源码见 shell-command-projection.cjs#L364-L434)
归一化出口 _spawnResult:所有分发函数共享同一个结果适配器。它的两条规则直接落实了结果形状不变量:
- 子进程不存在(
ENOENT)时不抛错,而是映射为{ exitCode: 127, stdout: '', stderr: '<program>: not found' }——与 shell 的"命令未找到"语义一致; - 其余情况输出
stdout/stderr一律toString().trim(),保证消费方拿到的是干净字符串。
execGit(args, opts):
const env = {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
...(opts.env || {}),
};
const result = childProcess.spawnSync('git', args, {
cwd: opts.cwd,
env,
encoding: 'utf-8',
stdio: 'pipe',
timeout: opts.timeout ?? 10_000,
});
两个值得注意的工程决策:
- 非交互默认值:注入
GIT_TERMINAL_PROMPT=0与GCM_INTERACTIVE=never,让挂起的凭据提示或终端输入探测在 10 秒超时内以失败返回,而不是永远阻塞工具(源码注释原话:"a hung credential prompt ... must surface as a timeout, not block the tool forever"); - 调用方可覆盖:
opts.env展开在最后,允许特定调用点(如 CI)恢复交互式行为,而默认路径保持安全。
execNpm(args, opts):关键的平台条件分支就在缝内——shell: process.platform === 'win32'。这就是 ADR 所说的"平台条件逻辑只活在缝里"的直接体现:Windows 上 npm 是 npm.cmd,不启用 shell 解析会 ENOENT。默认超时 15 秒,stdio 首项为 'ignore' 以隔离 stdin。
execTool(program, args, opts):通用逃生舱,任意程序名 + 参数数组,默认超时 30 秒,支持 opts.env 增量合并。
probeTty(opts):最典型的"平台差异封装进缝"的例子:
function probeTty(opts = {}) {
const platform = opts.platform ?? process.platform;
if (platform === 'win32') return null; // Windows 直接短路
try {
const ttyPath = childProcess.execFileSync('tty', [], { ... }).trim();
if (!ttyPath || ttyPath === 'not a tty') return null;
return ttyPath;
} catch { return null; }
}
tty(1) 是 POSIX 工具,Windows 上没有对应物,因此在缝内直接对 win32 返回 null;opts.platform 可注入,使该函数在测试中可被"伪造平台"。消费方只需理解一个语义:返回值非 null 表示存在可用 TTY,无需关心各平台的探测方式。
3.3 一个为可测试性而生的实现细节
文件头部的导入方式(shell-command-projection.cjs#L5-L8)刻意避开了解构:
// Use non-destructured access so test-time mock.method(childProcess, 'spawnSync')
// can intercept calls from this seam — destructured imports capture references
// at load time and become un-mockable.
const childProcess = require('child_process');
因为 const { spawnSync } = require(...) 会在加载时捕获函数引用,导致测试用 mock.method(childProcess, 'spawnSync') 的拦截失效。这是"缝"模式的一个隐藏收益:把 OS 调用集中到一处后,这一处就足够成为唯一的 mock 注入点,全仓库的子进程行为都可以被单元测试伪造。
四、平台文件 I/O:normalizeContent / platformWriteSync / platformReadSync / platformEnsureDir
配套的消费侧迁移见 .changeset/shell-projection-fs-migration.md(PR #3467):"Consolidates write atomicity, line-ending normalization, directory creation, and read null-safety. The seam owns .md normalization automatically — normalizeMd is dropped at every write site that used it as a pre-call."
这四个函数解决了四类此前散落在各写入点的重复/易错逻辑(源码见 shell-command-projection.cjs#L436-L524):
4.1 normalizeContent(filePath, content, opts) —— 按文件类型自动归一化
function normalizeContent(filePath, content, opts = {}) {
const encoding = opts.encoding ?? 'utf-8';
const isMd = path.extname(filePath).toLowerCase() === '.md';
let normalized;
if (isMd) {
normalized = _normalizeMd(content);
} else {
normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
}
return { content: normalized, encoding };
}
- 非 Markdown:CRLF → LF,行尾收敛为单个换行;
- Markdown:走
_normalizeMd(L438-L482),在换行归一化之外还做排版级规整——标题前后插空行、代码围栏(```)前后插空行、列表起始行前插空行、连续三个以上换行压缩为两个,且用围栏状态机(insideFence)避免误伤代码块内部。
关键点在于:归一化由文件路径的扩展名自动触发,调用点从此不再需要"写完再手动 normalize"的两段式调用,这正是 Phase 4 能整体删除 core.cjs 里遗留 normalizeMd/safeReadFile 的前提。
4.2 platformWriteSync —— 原子写 + 目录自愈
function platformWriteSync(filePath, content, opts = {}) {
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmpPath = filePath + '.tmp.' + process.pid;
try {
fs.writeFileSync(tmpPath, normalized, encoding);
fs.renameSync(tmpPath, filePath);
} catch {
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
fs.writeFileSync(filePath, normalized, encoding);
}
}
三层防御:先 normalizeContent(含 .md 自动规整),再递归建父目录,然后走"写临时文件 → 原子 rename"的经典原子写模式(临时文件带 process.pid 防并发互踩);rename 失败(如跨设备链接点)时降级为直接覆盖写,并清理残留临时文件。任何调用 platformWriteSync 的位置从此都获得"要么完整新内容、要么旧内容"的保证。
4.3 platformReadSync 与 platformEnsureDir —— 读空安全与目录幂等
function platformReadSync(filePath, opts = {}) {
const encoding = opts.encoding ?? 'utf-8';
try {
return fs.readFileSync(filePath, encoding);
} catch (err) {
if (err.code === 'ENOENT') {
if (opts.required) throw err;
return null;
}
throw err;
}
}
"文件不存在"从异常流变成可判定的 null 返回值,opts.required 则保留严格语义——调用点不再需要各自包 try/catch 判断 ENOENT。platformEnsureDir 是对 fs.mkdirSync(dirPath, { recursive: true }) 的最小包装,价值在于把"建目录"这个 OS 动作也纳入缝的导出面,使调用点的 OS 依赖清单收敛到这一个模块。
五、测试证据:用不变量而不是字符串拼接做断言
tests/shell-command-projection-dispatch.test.cjs 直接 require 缝文件并针对八个新导出编写了黑盒用例,代表性断言包括:
execGit(['--version'])返回对象必须自身拥有exitCode/stdout/stderr三个属性(hasOwnProperty逐一把关,防止形状漂移);- 在非 git 仓库目录执行
git status --porcelain时"exitCode 非零且不抛异常"——这就是 ADR 结果形状不变量的可执行形式; execNpm(['--version'])的 stdout 非空,验证 Windows shell 路径在 CI 环境下的行为。
测试同时通过 createTempGitProject()(tests/helpers.cjs)在临时目录构造真实 git 仓库,保证 cwd 选项、ENOENT 语义等被端到端验证而非仅靠 mock。配合 child_process 的非解构导入策略,tests/bug-3413-shell-command-projection.test.cjs 等回归测试也可以在不触碰真实 shell 的情况下对投影逻辑做断言。
六、如何验证与延伸阅读
- 阅读变更集本体:.changeset/shell-projection-io-seam.md;
- 阅读缝的完整实现(约 550 行,导出清单见文件尾部
module.exports):get-shit-done/bin/lib/shell-command-projection.cjs; - 阅读架构决策与 Phases 1–4 范围决议:docs/adr/0009-shell-command-projection-module.md,其 "Update — 2026-05-13" 一节还记录了开放问题的裁决(Q4 已裁决为"共享缝",位于
get-shit-done/bin/lib/,被安装器、规划工作流及所有 fs/子进程调用点消费); - 消费侧迁移:.changeset/shell-projection-subprocess-migration.md(子进程调用点迁缝)、.changeset/shell-projection-fs-migration.md(文件 I/O 调用点迁缝);
- 运行分发层测试:仓库测试为 Node 原生
node:test用例,可直接执行node --test tests/shell-command-projection-dispatch.test.cjs查看八项新能力的行为基线。
小结
这条看似只有一句话的变更集,实际是 get-shit-done 跨平台工程化的一次结构性收敛:把"起子进程"和"读写文件"这两类最底层、最容易因平台差异出错的 OS 交互,与已有的命令文本渲染一起关进 shell-command-projection.cjs 这唯一的缝里。带来的收益是可验证的三条硬约束——exec* 统一返回 { exitCode, stdout, stderr } 且非零不抛错、platformReadSync 缺失文件返回 null、平台条件分支只允许出现在缝内——以及可测试性的质变:非解构导入让全仓库的子进程行为在一个注入点上可 mock,opts.platform/opts.timeout/opts.env 让每个函数在任意平台假设下都能被单元测试覆盖。对于需要同时支持 Windows(PowerShell/cmd/Git Bash)与 POSIX 的 CLI 工具,这种"单一 OS 缝 + 结果形状不变量 + 消费点批量迁移"的做法,是一个可以直接复用的参考模式。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
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