
はじめに
社内向けのCLIツールで、「エラーは出ているはずなのに何も表示されず、終了コードだけがおかしい」という不具合を追いかけたことはないでしょうか。
原因を辿っていくと、たいてい特定の1箇所ではなく、複数の小さな「例外的なケースをまとめて同じ扱いにする」判断が積み重なっています。今回は、あるCLIツールの改修で実際に見つかった4つの落とし穴を紹介します。どれもCLIやバッチ処理を書く人には「あるある」な話だと思います。
こんな人におすすめ
- commander.js などのCLIフレームワークでサブコマンドを作っている方
- 汎用的なエラーハンドラを書いていて、分類ロジックが徐々に複雑化している方
- バッチ処理をCI/CDやcronから呼び出しており、exit codeを判定条件に使っている方
- 「テストは通るのに本番だけ挙動が違う」に心当たりがある方
罠1: サブコマンドへの設定継承はタイミングで決まる
commander.js(や類似のCLIフレームワーク)では、親コマンドに設定したオプションは、.command() を呼び出した瞬間にサブコマンドへコピーされます。
エラーハンドリング用の設定を全コマンド定義の「後」に書いてしまうと、どのサブコマンドにも反映されず、デフォルトの「メッセージなしで即終了」という挙動になります。
// NG: サブコマンドが増えたあとに exitOverride() を呼んでいる
const program = new Command();
program.command("sync").action(runSync);
program.command("deploy").action(runDeploy);
program.exitOverride(); // ← ここでは手遅れ。既存のサブコマンドには効かない
// OK: サブコマンドを定義する前に親側の設定を済ませる
const program = new Command();
program.exitOverride();
program.command("sync").action(runSync);
program.command("deploy").action(runDeploy);
「動いているように見えて、実は設定が効いていない」タイプの不具合なので、単体テストで気づきにくいのが厄介なところです。フレームワークの「いつコピーされるか」を意識する必要があります。
罠2: 汎用エラーハンドラが「型が合うだけ」の例外まで飲み込む
外部APIのエラーを整形する関数を、ファイルシステムエラーやJSONパースエラーなど、本来は別種の例外にも無条件に適用してしまうと、原因不明のエラーがすべて「外部API通信エラー」という誤ったメッセージにすり替わります。
対処法はシンプルで、分類ロジックに入る前に「本当にその形をしたエラーか」を明示的にチェックすることです。
function isKnownApiErrorShape(candidate: LikeApiError): boolean {
return typeof candidate.response?.status === "number" || typeof candidate.code === "number";
}
export function normalizeApiError(error: unknown): AppError {
const candidate = error as LikeApiError;
if (!isKnownApiErrorShape(candidate)) {
const code = typeof candidate.code === "string" ? candidate.code : undefined;
const message = candidate.message ?? String(error);
if (code && FS_ERROR_CODES.test(code)) {
return new AppError(message, EXIT_USAGE_ERROR, { cause: error });
}
if (code && NETWORK_ERROR_CODES.test(code)) {
return new AppError(`Network error: ${message}`, EXIT_RUNTIME_ERROR, { cause: error });
}
return new AppError(message, EXIT_RUNTIME_ERROR, { cause: error });
}
// ここから先は「外部APIのエラーだと確信できる場合」だけの分類ロジック
// ...
}
ガード条件を関数として切り出しておくと、「なぜこの分岐に入ったのか」がテストコードからも読みやすくなります。
罠3: バリデーションは入口で止める、内部で黙って丸めない
数値オプションに上限を設けていても、下限だけをCLI側でチェックし、上限は下位モジュール内で Math.min() により黙ってクランプしていると、ユーザーは「指定した値が無視されて、別の値で実行された」ことに気づけません。
// NG: 上限を超えても黙って丸めるだけ
function resolveWindow(input: number): number {
return Math.min(input, MAX_WINDOW);
}
// OK: 入力を受け取る層でエラーとして弾く
function validateWindow(input: number): number {
if (input > MAX_WINDOW) {
throw new AppError(`--window は ${MAX_WINDOW} 以下で指定してください`, EXIT_USAGE_ERROR);
}
if (input < MIN_WINDOW) {
throw new AppError(`--window は ${MIN_WINDOW} 以上で指定してください`, EXIT_USAGE_ERROR);
}
return input;
}
バリデーションは呼び出し側(入力を受け取る層)で完結させ、範囲外なら明示的にエラーを返す方針にしておくと、後から下位モジュールを読み替える際にも安心です。
罠4: 「全件スキップ」を「全件成功」と同じ終了コードにしない
複数対象を処理するバッチコマンドで、エラー件数だけをチェックして終了コードを決めていると、「対象が1件も処理できずすべてスキップされた」ケースが「全部問題なく完了した」(exit 0)と区別できなくなります。
CI/CDやcronジョブから呼ぶCLIでは、この区別がないと障害に気づけません。
function setBatchExitCode(results: BatchResult<unknown>[]): void {
if (results.some((r) => r.status === "error")) {
process.exitCode = EXIT_RUNTIME_ERROR;
} else if (results.length > 0 && results.every((r) => r.status === "skipped")) {
process.exitCode = EXIT_AUTH_ERROR; // 「対象なし」を「成功」と区別する
}
}
エラーが1件でもあれば最優先、そうでなくても全件スキップなら通常成功とは別コードにする、という2段階の判定にしておくのがポイントです。
つまづきやすいポイント
- 秘匿情報の検出パターンを1箇所にまとめる: シークレットのキー名を検出する正規表現が、オブジェクトのキー判定用と文字列スキャン用で別々に定義されていると、片方だけ更新されて検出漏れが起きます。共通のキー名リストを1箇所で定義し、両方のパターンがそれを参照する形に統一すると防げます。
execFile系のデフォルトmaxBufferに注意: 外部コマンドの出力をJSONとして受け取る処理で、デフォルトの1MiB上限を超えると出力が切り詰められてパースエラーになります。件数や本文が多くなりうる出力を扱う場合は、明示的に上限を引き上げておく必要があります。- バージョン文字列を二重管理しない:
--versionの出力がpackage.jsonとは別にハードコードされていると、更新を忘れてズレます。createRequire経由でpackage.jsonから読み込み、単一の情報源に統一するのが安全です。 - テストは実際に遭遇したエラー形状ごとに揃える: ファイルシステム、ネットワーク、外部APIレスポンス、プレーンな
Errorなど、形の違うエラーごとにテストケースを持っておくと、同種の「取りこぼし」を将来防ぎやすくなります。
まとめ
CLIツールの不具合は、派手な機能追加よりも「例外的なケースの握りつぶし」に潜んでいることが多いです。
今回見た4つの罠は、いずれも単体では小さな見落としですが、積み重なると「エラーは出ているはずなのに何も表示されない」という原因の特定しづらい不具合になります。
サブコマンドへの設定タイミング、エラー分類のガード条件、バリデーションの位置、そしてバッチ処理の終了コード。この4点は、CLIツールを設計する際のチェックリストとして手元に置いておくと役立つはずです。


