首页
/ get-shit-done 跨平台加固系列:shell-command-projection 缝中的子进程分发与平台文件 I/O(IO Seam)拆解

get-shit-done 跨平台加固系列:shell-command-projection 缝中的子进程分发与平台文件 I/O(IO Seam)拆解

2026-09-07 16:07:05作者:丁柯新Fawn

本篇基于 get-shit-done 仓库的变更集 shell-projection-io-seam.md 及其引用的架构决策记录,深入讲解该仓库跨平台加固(cross-platform hardening)第一阶段的核心成果:shell-command-projection.cjs 如何一次性收编了子进程分发(execGitexecNpmexecToolprobeTty)与平台文件 I/O(platformWriteSyncplatformReadSyncplatformEnsureDirnormalizeContent)两类 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) to shell-command-projection.cjs. Single seam for all OS-facing I/O — phase 1 of cross-platform hardening. See #3465.

拆解为三条关键结论:

  1. 能力落点:子进程分发与平台文件 I/O 两组共 8 个函数,全部加入 shell-command-projection.cjs,而不是新建独立模块;
  2. 架构意图:"Single seam for all OS-facing I/O"——所有与操作系统直接打交道的调用(起子进程、读/写文件、探测 TTY)必须经过同一个缝,平台条件逻辑只允许出现在缝内部;
  3. 阶段定位:这是跨平台加固的第一阶段,上游问题追踪号为 #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 变更集)引入的:

职责 代表导出 引入阶段
运行时感知的命令文本渲染 projectManagedHookCommandprojectCodexHookTomlCommandformatSdkPathDiagnostic ADR 原始范围
子进程分发 execGitexecNpmexecToolprobeTty Phase 2(#3466)
平台文件 I/O platformWriteSyncplatformReadSyncplatformEnsureDirnormalizeContent 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: truenpm 实为 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=0GCM_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 上 npmnpm.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 返回 nullopts.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:走 _normalizeMdL438-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 platformReadSyncplatformEnsureDir —— 读空安全与目录幂等

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 判断 ENOENTplatformEnsureDir 是对 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 的情况下对投影逻辑做断言。

六、如何验证与延伸阅读

小结

这条看似只有一句话的变更集,实际是 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 缝 + 结果形状不变量 + 消费点批量迁移"的做法,是一个可以直接复用的参考模式。

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

项目优选

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