Windows 上 Node spawn 命令为什么会 ENOENT:从 cmd shim 到 execa
本地预览脚本出问题时,最容易误判的是「外层命令已经能跑」。这次 server 命令就是这样:Windows 运行配置里明确调用了 pnpm.cmd run server,tsx build-scripts/dev-server.ts 也已经启动,真正挂掉的是 dev server 里面再次派生出来的子进程。
报错停在这里:
[dev-server] 首次启动,准备重新生成本地预览...
Error: spawn pnpm ENOENT
code: 'ENOENT',
syscall: 'spawn pnpm',
path: 'pnpm',
spawnargs: [ 'run', 'build:code-lab' ]这看起来像 pnpm 没装好,但现场已经证明外层的 pnpm.cmd 是可用的。真正的问题不在 build:code-lab,也不只在 pnpm:脚本内部裸写 spawn('some-cli'),本质上是在要求 Node 按当前子进程环境重新解析一次命令入口。到了 Windows,这个入口很可能不是一个能被直接执行的 .exe。
根因不是 pnpm,而是 Windows 命令入口
在 Unix-like 环境里,很多命令入口最终都是可执行文件加 shebang,spawn('pnpm', ['run', 'build:code-lab']) 通常能顺着 PATH 找到并启动。Windows 的情况更绕一点:包管理器、项目内 CLI、全局 CLI 经常通过 .cmd / .bat 或 node_modules/.bin shim 暴露命令。
Node 官方文档也把 Windows 下的 .bat / .cmd 单独拎出来说明:这类文件不能像普通可执行文件一样直接启动,通常要经由 shell、exec(),或者显式调用 cmd.exe。
所以这类问题不要只记成「Windows 上 spawn('pnpm') 会挂」。更准确的边界是:
spawn('pnpm')依赖当前子进程环境里的PATH、PATHEXT和 shim 解析。spawn('eslint')、spawn('tsx')、spawn('hexo')、spawn('vite')也可能踩到同类问题。- 外层
pnpm.cmd run server可用,只能说明第一层入口成立,不能证明脚本内部继续裸跑命令也成立。
这次 ENOENT 发生在命令创建阶段,说明 build:code-lab 还没有真正开始执行。往 Code Lab、Hexo 配置或具体构建命令里排查,方向会偏掉。
为什么不只加 shell true
一个很直观的修法是 Windows 下给 spawn() 加 shell: true。它确实能绕过一部分 .cmd / .bat 启动问题,但长期维护的工具脚本里,我不太愿意把它当成默认答案。
原因主要有三个。
第一,shell: true 把命令交给 shell 解释,参数转义边界会变复杂。只要参数里出现空格、引号、路径、用户输入或二次拼接,调用点就必须重新审视 escaping。
第二,它解决的是「怎么启动这一个命令」,没有顺手解决「怎么找项目本地 bin」「怎么拿到更好的错误对象」「怎么管理长进程生命周期」。dev server 这种脚本不是一次性跑完,它还要重启、清理、退出时带走子进程。
第三,一旦每个调用点自己判断 process.platform === 'win32',平台细节就会散在业务流程里。以后再遇到 tsx、hexo 或别的 CLI,同样的判断还要复制一遍。
这也是为什么我最后没有选「自己写一个专用 spawnPnpm()」作为结论。它能修这次错误,但容易把问题讲窄:真正值得抽出来的是「执行外部命令」这层,而不是「执行 pnpm」这一个点。
cross-spawn 和 execa 怎么选
如果目标只是把 Node 原生 spawn 换成一个更懂 Windows shim 的替代品,cross-spawn 很合适。它的定位就是跨平台替代 child_process.spawn() / spawnSync(),重点补 Windows 的 PATHEXT、shebang、shim 和参数问题。很多工具链依赖它,也是因为这个边界足够清楚。
但这个项目里的 dev server 还需要更多东西:
- 短命令要继承 stdio,拿到退出码后决定是否继续。
- 长命令要以 promise 形式挂着,方便在文件变化时停止旧的
hexo server。 - 命令解析要优先照顾项目本地 bin。
- 失败时要有更适合脚本消费的结果对象。
- 退出时要尽量清理长进程和后代进程。
这些已经超过了「只替换 spawn」的范围。execa 本来就是面向程序化命令执行的封装,内置了 Windows 支持、本地 bin 路径处理、promise 化结果、stdio 控制和进程清理选项。对这个 dev server 来说,它比 cross-spawn 更贴近实际需求。
还有一个现实约束:当前仓库用的是 ESM,并且本机跑的是 Node 25,pnpm add -D execa 安装到的 execa@10.0.1 要求 Node >=22,和项目环境匹配。如果要写给 Node 18 / 20 项目复用,就不能无脑照搬最新版 execa;那时可以选旧版 execa,或者回到更薄的 cross-spawn。
把命令执行统一放进 helper
修完以后,dev-server.ts 不再直接从 node:child_process import spawn,而是统一通过 execa 执行命令。
resolvePnpmCommand() 仍然存在,但它的职责变窄了:只处理「当前脚本如果本来就是由包管理器启动,要不要复用 npm_execpath」这个入口问题。拿不到 npm_execpath 时,直接回到普通命令名 pnpm,交给 execa 做跨平台解析。
// sheng-blog/build-scripts/dev-server.ts:30-53
function resolvePnpmCommand(args: string[]): CommandSpec {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath && existsSync(npmExecPath)) {
const extension = path.extname(npmExecPath).toLowerCase();
if (extension === '.cjs' || extension === '.js') {
return {
command: process.execPath,
args: [npmExecPath, ...args],
};
}
return {
command: npmExecPath,
args,
};
}
return {
command: 'pnpm',
args,
};
}真正的通用边界在 runCommand() 和 startCommand()。调用者只说「执行什么命令、传什么参数」,不再关心 Windows 是 .cmd、Unix 是 shebang,还是项目内 .bin。
// sheng-blog/build-scripts/dev-server.ts:154-184
async function runCommand(command: string, args: string[]) {
const result = await execa(command, args, {
cwd: repoRoot,
preferLocal: true,
reject: false,
stdio: 'inherit',
});
return result.exitCode ?? 1;
}
function runPnpmCommand(args: string[]) {
const { command, args: commandArgs } = resolvePnpmCommand(args);
return runCommand(command, commandArgs);
}
function startCommand(command: string, args: string[]) {
return execa(command, args, {
cleanup: true,
cwd: repoRoot,
forceKillAfterDelay: 5000,
killDescendants: true,
preferLocal: true,
reject: false,
stdio: 'inherit',
});
}后面的业务流程就干净很多:先构建 Code Lab,再清理 Hexo,再启动本地预览。
// sheng-blog/build-scripts/dev-server.ts:208-237
serverProcess = startPnpmCommand(['exec', 'hexo', 'server', ...serverArgs]);
// ...省略重启状态控制
let code = await runPnpmCommand(['run', 'build:code-lab']);
// ...省略错误处理
code = await runPnpmCommand(['exec', 'hexo', 'clean']);这个结构的价值在于把后续问题集中到一个薄边界里。要换 execa 选项、补日志、处理环境变量、改本地 bin 策略,都不用去业务流程里挖散落的 spawn()。
参数转发仍然要看最后一层命令
这次验证时还顺手暴露出另一个小问题:给 pnpm run server 传端口时,多出来的 -- 可能会原样进入 dev-server.ts。
pnpm run 文档里提到,脚本名后面的参数会传给被执行的脚本。外层命令可以写成:
pnpm run server -- --port <PORT>但 dev server 真正要转给 Hexo 的只有 --port <PORT>。如果首个 -- 没去掉,Hexo 可能收不到预期端口,最后仍然启动到默认的 http://localhost:4000/。
这里不需要做复杂解析,只要在 dev server 入口把首个参数里的分隔符剥掉:
// sheng-blog/build-scripts/dev-server.ts:7-8
const rawServerArgs = process.argv.slice(2);
const serverArgs = rawServerArgs[0] === '--' ? rawServerArgs.slice(1) : rawServerArgs;命令已经启动,不等于参数已经被目标子命令正确接收。排查这类脚本时,最好一路看到最后一层工具的日志。
验证要覆盖真实启动
这次修复不能只跑 TypeScript,因为 TypeScript 只能证明 helper 类型没错,证明不了 Windows 子进程真的能启动命令。
基础验证先跑构建脚本类型检查:
pnpm run typecheck:build-scripts再用 Windows 上真实的 pnpm.cmd 启动一次临时端口:
pnpm.cmd run server -- --port 4198最终要看到的是:
[dev-server] 启动 hexo server --port 4198
INFO Hexo is running at http://localhost:4198/ . Press Ctrl+C to stop.这个结果同时证明三件事:
- dev server 内部的
build:code-lab没有再因为命令解析失败挂掉。 hexo clean和hexo server都走过了同一套命令执行 helper。--port 4198被传到了 Hexo,而不是被多余的--挡在 dev server 这一层。
外层命令可用,只能证明第一层入口没坏;子进程、脚本参数和目标工具日志,才是这次问题的有效验证点。
总结
这次问题的关键在于:Node 脚本里裸 spawn() 外部 CLI 时,Windows 的 .cmd / .bat / shim 入口会变成一层额外边界。pnpm 只是这次最先暴露出来的命令,换成 hexo、tsx、eslint 或其他项目内 CLI,也可能遇到相似问题。
我的结论是:长期维护的 Node 工具脚本里,不要把跨平台命令执行散落在业务流程里;也不要一遇到 Windows 就到处补 pnpm.cmd 或 shell: true。先把命令执行统一进一个 helper,再根据脚本复杂度选工具。只需要近似原生 spawn 时,cross-spawn 很好;需要 promise 化结果、本地 bin、长进程清理和更完整错误模型时,execa 更合适。
放回这个 dev server,execa 是更贴切的选择。它让脚本从「我知道 Windows 上 pnpm 是 .cmd」退回到更稳的边界:「我需要可靠地执行一个外部命令」。这个抽象层次刚好,不会为了一个局部问题写过重的 lint 或 Skill,也不会把平台坑继续留在每个调用点里。