一言でいうと、Windows で child_process.spawn を shell: false のまま使うと、npm でグローバルに入れた CLI は起動できません。
しかも出るエラーが2種類あって、原因も対処も別物です。
この記事は、その2つを切り分けて shell: true に頼らずに解決するまでの手順です。
想定読者は、Windows で Node.js から npm install -g した CLI を子プロセスとして動かしたい人。
前提知識
child_process.spawn の shell オプションは、既定が false です。
true にするとコマンド文字列を cmd.exe に解釈させ、false なら OS のプロセス生成 API を直接叩きます。
shell: false が推奨されるのは、引数がシェルに解釈されないからです。
ファイル名に & や | が入っていても、コマンドとして実行されません。
僕は自分のツールで「外部コマンドは配列引数で渡す、shell: false に限定する」と決めていて、その方針のまま AI の CLI を起動しようとして詰まりました。
再現:エラーは2つあって、原因は別
まず何が起きるかを見てください。
検証環境は Node.js v22.15.0 / Windows 11 です。
const { spawnSync } = require("node:child_process");
for (const cmd of ["node", "npm", "npm.cmd"]) {
const r = spawnSync(cmd, ["--version"], { shell: false });
console.log(cmd, "->", r.error ? r.error.code : "OK");
}
結果はこうなりました。
node -> OK
npm -> ENOENT
npm.cmd -> EINVAL
node は通るのに npm は通りません。
そして npm.cmd と拡張子まで書くと、今度は別のエラーになります。
errno はそれぞれ -4058(ENOENT)と -4071(EINVAL)でした。
ここが分かれ目です。
ENOENT は「見つからない」、EINVAL は「見つかったが起動を拒否された」 です。
同じ「起動できない」に見えて、やることが正反対になります。
なぜ ENOENT になるのか
node と npm の違いは、PATH に置かれている実体です。
Windows の npm は、グローバルインストールしたコマンドを %APPDATA%\npm に置きます。
中身を見ると、1つのコマンドにつき3つのファイルが並んでいます。
%APPDATA%\npm\claude (拡張子なし。Unix系シェル用の sh スクリプト)
%APPDATA%\npm\claude.cmd (cmd.exe 用のバッチ)
%APPDATA%\npm\claude.ps1 (PowerShell 用)
claude.cmd の中身は @ECHO off から始まるただのバッチで、本体を呼び出すための中継役(シム)です。
一方 node は node.exe という実行ファイルそのものが PATH 上にあります。
つまり node が通って npm が通らないのは、実体が .exe か .cmd かの違いでした。
ここで意外だったのが PATHEXT です。
この環境の PATHEXT にはちゃんと .CMD が入っています。
PATHEXT = .COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL
.CMD が候補に入っているのに、spawn("npm") は ENOENT になります。
shell: false の探索では .cmd が補完されていない、ということです。
理由は次のセクションと地続きだと思っていますが、内部実装までは追っていないので断定はしません。
実務で必要なのは「実体が .cmd ならコマンド名だけでは解決されない」という事実のほうです。
なぜ EINVAL になるのか
こちらは公式に理由が書かれています。
2024年4月10日の Node.js セキュリティリリース(CVE-2024-27980)です。
バッチファイル経由だと、shell を切っていても引数から任意のコマンドを注入できた、という脆弱性でした。
修正にあたって、公式は破壊的変更があることを明記しています。
Node.js will now error with
EINVALif a.bator.cmdfile is passed tochild_process.spawnandchild_process.spawnSyncwithout theshelloption set.
つまり EINVAL は不具合ではなく、意図的に塞がれた結果です。
対象は 18.x / 20.x / 21.x の各リリースラインでした。
なお公式ページには修正版の具体的なバージョン番号までは書かれていないので、ここでも書きません。
自分の環境で spawnSync("npm.cmd", [], {shell:false}) を1回叩けば、EINVAL が出るかどうかで判定できます。
公式が案内している回避策は { shell: true } ですが、「入力がサニタイズされているなら」という条件つきです。
今回の脆弱性そのものが「引数から注入される」話だったので、ここは慎重に読む価値があります。
解決:シムではなく実体の .exe を叩く
shell: true にすれば1行で通ります。
でも僕は「shell: false に限定する」を自分のルールにしていました。
壁の正体が「拡張子が補完されない」と「.cmd が拒否される」の2つだと分かった時点で、ルールを緩める理由がなくなりました。
どちらもシムを経由しようとするから起きています。
シムを飛ばして本体を直接叩けば、両方いっぺんに消えます。
本体の場所は、パッケージの package.json に書いてあります。
const pkgPath = process.env.APPDATA + "\\npm\\node_modules\\@anthropic-ai\\claude-code\\package.json";
console.log(JSON.parse(require("node:fs").readFileSync(pkgPath, "utf8")).bin);
// -> { claude: 'bin/claude.exe' }
bin フィールドが指しているのが本体です。
これを絶対パスに直して渡すと、shell: false のまま通りました。
spawn("...\\@anthropic-ai\\claude-code\\bin\\claude.exe", ["--version"], {shell:false})
-> exit=0 / "2.1.221 (Claude Code)"
コード例
実際に書いた解決関数です。
ポイントはコメントに書いた3つで、どれも省くと事故ります。
const { existsSync, readFileSync } = require("node:fs");
const path = require("node:path");
// シムは候補にしない。.cmd は EINVAL になるうえ、通すには shell:true が要るため
const SHIM_EXTENSIONS = new Set([".cmd", ".bat", ".ps1"]);
const isShim = (p) => SHIM_EXTENSIONS.has(path.extname(p).toLowerCase());
function resolveExecutable(logicalName, packageName, options = {}) {
// 1. 明示指定があるなら、そこで確定する。
// 「無ければ自動探索へ」にすると、パスをtypoしたとき黙って別のバイナリが動く
if (options.override) {
if (!path.isAbsolute(options.override)) return { error: "not_absolute" };
if (!existsSync(options.override)) return { error: "not_found" };
if (isShim(options.override)) return { error: "shim_only" };
return { path: path.resolve(options.override) };
}
// 2. npm グローバルの node_modules から package.json の bin を読む。
// `npm root -g` を spawn して求めない。npm 自身が .cmd なので同じ壁に当たる
const appData = options.appData ?? process.env.APPDATA;
if (!appData) return { error: "not_found" };
const packageRoot = path.join(appData, "npm", "node_modules", ...packageName.split("/"));
const manifestPath = path.join(packageRoot, "package.json");
if (!existsSync(manifestPath)) return { error: "not_found" };
const bin = JSON.parse(readFileSync(manifestPath, "utf8")).bin;
const relative = typeof bin === "string" ? bin : bin?.[logicalName];
if (typeof relative !== "string") return { error: "not_found" };
// 3. 失敗は必ず error で返す。`{ path: logicalName }` のようなフォールバックは置かない。
// 黙って動かないより、名指しで落ちるほうが原因を追える
const candidate = path.resolve(packageRoot, relative);
if (!existsSync(candidate)) return { error: "not_found" };
if (isShim(candidate)) return { error: "shim_only" };
return { path: candidate };
}
1番目は一度やらかしています。
最初は「明示指定 → 見つからなければ自動探索 → 失敗」という順序で書いていて、明示したパスが実在しないときに黙って自動探索の結果へすり替わっていました。
環境変数のパスを打ち間違えたら、気づかないまま別のバイナリが動きます。
3番目も同じ性質です。
return { path: logicalName } のようなフォールバックを1行足したくなりますが、足すと解決に失敗したことが呼び出し側から見えなくなります。
結局どこを直したか
やったことは1つで、PATH 上のシムではなく package.json の bin が指す実体を絶対パスで渡しただけです。
判断の基準としてはこう整理しました。
- 実体が
.exeなら、コマンド名のままで通る(nodeがこれ) - 実体が
.cmdなら、コマンド名では ENOENT、明示すると EINVAL - 両方とも、シムを飛ばして実体を叩けば消える
shell: true を選ぶ場面がないとは思いません。
起動対象がバッチファイルしか無いこともあります。
ただ、少なくとも今回のように本体が .exe で存在するなら、shell を開ける必要はありませんでした。
切り分けの入口としては、エラーコードが変わったかどうかを見るのが早いです。
.cmd を明示して EINVAL に変わったなら、そのコマンドの実体はバッチなので、この記事の手順がそのまま当てはまります。
個人でAIツールや業務自動化を作っているMiyokiといいます。
今回のような自分の作業を楽にするための小さなツールを作っては、その開発ログをZennとQiitaに書いています。
作ったものやポートフォリオはこちらです。