NEO.K / MSSP 開源專案考古001-commander
專案commander
授權MIT
檢視版本2.20.3
日期2026-08-01
來源upstream ↗

001 — commander 2.20.3

專案: commander.js授權: MIT | 檢視版本: 2.20.3 | 日期: 2026-08-01

為什麼選它

commander 是 Node 生態裡最被廣泛依賴的 CLI 函式庫之一。2.20.3 是大量專案長期 pin 住的版本,而它的全部程式碼是一個檔案、1,225 行、36 個原型方法

它符合考古最想要的組合:結構上有明顯可討論的地方,同時在真實世界裡活得非常好。這種專案通常代表那個結構在某個維度上是對的,而找出那個維度比證明它該被重寫有價值。

原專案的結構地圖

一個 Command 原型承擔全部工作:

關注點 觸及的方法數
解析(flags、參數、正規化) 7
說明文字產生與排版 7
事件發送 7
直接寫入 console/stdout 8
直接呼叫 process.exit() 9
子行程 spawn 1

executeSubCommand 一個方法同時觸及五類關注點(解析、事件、console、process、child_process),90 行。

這個結構是有理由的。commander 誕生時的定位是「寫一支 CLI 用的最短路徑」,而在那個定位下,process.exit() 就是正確答案——使用者要的就是參數錯了直接結束並印出訊息。方法鏈式 API(.option().command().parse())也讓單一物件持有全部狀態成為最自然的寫法。

那條縫

看最小的一個方法:

Command.prototype.unknownOption = function(flag) {
  if (this._allowUnknownOption) return;
  console.error("error: unknown option `%s'", flag);
  process.exit(1);
};

三行,其中兩行是對宿主行程的效果,而且呼叫端無法拒絕。

用 MSSP 的說法:這個方法同時持有兩件應該分開的東西。

Know(a, k) = 1 不蘊含 Permit(a, o) = 1。函式庫知道輸入有問題,但沒有任何東西授予它結束行程的權利。它只是把兩者寫在了同一個函式裡。

全檔案共 8 個 process.exit() 呼叫點,2.x 沒有任何覆寫機制(exitOverrideconfigureOutput_outputConfiguration 在 2.20.3 全部不存在)。

實際後果:

這條縫是真的:上游自己畫了同樣兩條

這一節是這次考古最重要的結果,因為它把結論從「我覺得該這樣切」變成可查證的事實。

翻 commander 的 CHANGELOG:

逃生口 引入版本 說明
exitOverride() 4.0.0 覆寫對 process.exit 的呼叫,讓程式可以繼續執行(#1040)
configureOutput() 7.0.0 修改 stdout/stderr 的使用方式,自訂錯誤顯示(#1387)

上游在 2.x 之後兩個大版本畫出了退出邊界,五個大版本畫出了輸出邊界。這正好是下面 MSSP 重切出來的兩條線,而且是在完全獨立的情況下、由實際使用壓力推出來的。

換句話說:這條縫不是事後諸葛。它真實到維護者最終自己畫了它。MSSP 在這裡的價值不是「發現了別人沒發現的東西」,而是提供一組判準,讓這條縫在第一次寫的時候就看得見

MSSP 重切

src/ 是這條縫的可執行重切。範圍只有「選項與參數解析 + 錯誤回報」,子命令、說明產生、可執行子命令都不在裡面。

SMS — parse.js 解析回傳一個值:{kind:"ok", options, args}{kind:"error", code, detail}。不寫入、不退出、不碰任何宿主全域。移除它系統就不再是解析器,所以它是核心。

SCL — host-policy.js 這一層在原專案中不存在。它把「這個解析器可以對宿主做什麼」變成一個值:

TMS — messages/en.jsmessages/zh.js 錯誤訊息的兩種渲染。兩者都只依賴 SMS 的 ERROR 常數,互不引用——這也是為什麼翻譯在重切後才成為可能。

DMS — trace.js 解析過程中發生了什麼,包括被政策擋下的動作。原專案沒有對應層,因為一次失敗的解析會結束行程,什麼都不會留下。

執行結果

node src/main.js

同樣三個輸入,跑在三種宿主政策下:

--- policy: cli / messages: en ---
  unknown option           error unknown_option
    stderr: error: unknown option `--porrt'
    process.exit(1) <- commander 2.20.3 stops here

--- policy: collecting / messages: zh ---
  unknown option           error unknown_option
    collected: 無法辨識的選項 `--porrt'

--- policy: silent / messages: en ---
  unknown option           error unknown_option
    Policy "silent" declined to write.

第一種是原本的行為。後兩種在 commander 2.20.3 裡無法表達。

孤島測試

node src/island-test.js

八項全過。其中一項是刻意設計的:它把 process.exit 換成一個記錄用的假函式,然後跑兩次會失敗的解析,斷言它從來沒被呼叫。

  [OK]   parse touches no host global

如果解析器像原專案那樣退出,這個測試檔會在那一行結束,後面的檢查根本不會印出來——而那正是原專案難以單元測試的原因本身。

什麼不適合拆

這是考古最該保留的部分。

方法鏈式 API 不該拆。 .option('-p, --port <n>').command('build').parse(argv) 這種寫法要求單一物件持有累積狀態。這是使用者體驗的核心,硬要拆成無狀態函式組合會讓 API 變差而不是變好。commander 把狀態集中在 Command 上是對的。

flag 字串的解析格式不該抽象化。 '-p, --port <n>' 這種 DSL 看起來像是可以做成可插拔的格式層,但它其實是這個函式庫的身份。抽象掉之後你得到的是一個更通用、也更沒人想用的東西。

說明文字產生可以拆,但收益有限。 七個方法涉及排版(padWidthlargestOptionLengthoptionHelp…),它們確實可以成為一個 TMS。但它們彼此高度內聚、對外只有一個入口,拆出去換到的主要是「可替換」,而實際上很少有人要替換它。這是一個「可以拆但不划算」的例子,正是「度」在真實專案裡的樣子。

這次沒有解決什麼

重切原始碼

FMS

FMS/manifest.json
{
  "system": {
    "id": "001-commander",
    "purpose": "Re-cut of one seam in commander 2.20.3: separate the parse decision from the effect on the host process.",
    "scope": "Only option and argument parsing plus error reporting. Subcommands, help generation, variadic arguments and executable subcommands are out of scope.",
    "non_goals": [
      "a replacement for commander",
      "feature parity with any version",
      "a claim that the original is badly written"
    ]
  },
  "capability_index": {
    "SMS": { "parse": "src/SMS/parse.js — returns {kind:'ok'|'error'}; performs no effect" },
    "SCL": { "host-policy": "src/SCL/host-policy.js — whether this parser may write to the host and whether it may exit" },
    "TMS": {
      "messages/en": "English rendering of a parse error",
      "messages/zh": "Traditional Chinese rendering of the same"
    },
    "DMS": { "trace": "src/DMS/trace.js — what the parse did, still inspectable after a rejection" }
  },
  "upstream_reference": {
    "project": "commander",
    "version_examined": "2.20.3",
    "license": "MIT",
    "process_exit_call_sites": 8,
    "escape_hatches_added_upstream": {
      "exitOverride": "4.0.0",
      "configureOutput": "7.0.0"
    }
  }
}

SCL

SCL/host-policy.js
// SCL — what this parser is permitted to do to its host, and who decides.
//
// In commander 2.20.3 this layer does not exist. The permission to write to
// stderr and to terminate the process is taken unconditionally at eight call
// sites, and the calling program has no way to withhold it. That is the
// knowledge/permission fusion the method warns about: the library knows the
// input is malformed, but nothing granted it the right to end the process.
//
// Making the policy a value has an effect that is easy to miss: the same parser
// now works in a CLI, in a test, and inside a long-running process, without the
// parser knowing which one it is in.

export const EXIT_CODE = Object.freeze({ USAGE: 1, OK: 0 });

/** A CLI: report to stderr, then end the process. What commander 2.x hardcodes. */
export function cliPolicy({ write = (text) => process.stderr.write(text), exit = (code) => process.exit(code) } = {}) {
  return Object.freeze({
    name: "cli",
    mayWrite: true,
    mayExit: true,
    onError(message) {
      write(`error: ${message}\n`);
      exit(EXIT_CODE.USAGE);
    },
  });
}

/** A test or an embedded host: collect, never exit. */
export function collectingPolicy() {
  const written = [];
  return Object.freeze({
    name: "collecting",
    mayWrite: true,
    mayExit: false,
    written,
    onError(message) {
      written.push(message);
    },
  });
}

/** Silent: the caller intends to render the failure itself. */
export function silentPolicy() {
  return Object.freeze({
    name: "silent",
    mayWrite: false,
    mayExit: false,
    onError() {},
  });
}

SMS

SMS/parse.js
// SMS — the parse itself. Returns a result; performs no effect.
//
// This is the whole re-cut. commander 2.20.3 writes:
//
//     Command.prototype.unknownOption = function(flag) {
//       if (this._allowUnknownOption) return;
//       console.error("error: unknown option `%s'", flag);
//       process.exit(1);
//     };
//
// Three lines, of which two act on the host process. The caller cannot decline
// either one. Here the same knowledge — "this flag is unknown" — becomes a value
// the caller receives and decides about.

/** @typedef {{kind: "ok", options: object, args: string[]}} Ok */
/** @typedef {{kind: "error", code: string, detail: object}} Err */

export const ERROR = Object.freeze({
  UNKNOWN_OPTION: "unknown_option",
  MISSING_OPTION_ARGUMENT: "missing_option_argument",
  MISSING_ARGUMENT: "missing_argument",
});

/**
 * @param {{flags: string, takesValue: boolean, name: string}[]} spec
 * @param {string[]} argv
 * @param {{allowUnknown?: boolean, required?: string[]}} [config]
 * @returns {Ok | Err}
 */
export function parse(spec, argv, config = {}) {
  const byFlag = new Map();
  for (const option of spec) {
    for (const flag of option.flags.split(",").map((f) => f.trim())) byFlag.set(flag, option);
  }

  const options = {};
  const args = [];

  for (let i = 0; i < argv.length; i += 1) {
    const token = argv[i];

    if (!token.startsWith("-")) {
      args.push(token);
      continue;
    }

    const option = byFlag.get(token);
    if (!option) {
      if (config.allowUnknown) {
        args.push(token);
        continue;
      }
      return { kind: "error", code: ERROR.UNKNOWN_OPTION, detail: { flag: token } };
    }

    if (!option.takesValue) {
      options[option.name] = true;
      continue;
    }

    const value = argv[i + 1];
    // An option that wants a value and is followed by another flag is missing
    // its argument — the same case commander reports then exits on.
    if (value === undefined || value.startsWith("-")) {
      return { kind: "error", code: ERROR.MISSING_OPTION_ARGUMENT, detail: { flags: option.flags, got: value ?? null } };
    }
    options[option.name] = value;
    i += 1;
  }

  for (const name of config.required ?? []) {
    if (!(name in options)) {
      return { kind: "error", code: ERROR.MISSING_ARGUMENT, detail: { name } };
    }
  }

  return { kind: "ok", options, args };
}

TMS

TMS/messages/en.js
// TMS — one renderer for parse errors. Swappable, and unaware of any sibling.
//
// commander 2.20.3 embeds these strings at the point of failure, inside the same
// three lines that also write and exit. Pulling them into a TMS is what makes
// them translatable at all.

import { ERROR } from "../../SMS/parse.js";

export function englishMessages() {
  return Object.freeze({
    name: "en",
    render(result) {
      switch (result.code) {
        case ERROR.UNKNOWN_OPTION:
          return `unknown option \`${result.detail.flag}'`;
        case ERROR.MISSING_OPTION_ARGUMENT:
          return result.detail.got
            ? `option \`${result.detail.flags}' argument missing, got \`${result.detail.got}'`
            : `option \`${result.detail.flags}' argument missing`;
        case ERROR.MISSING_ARGUMENT:
          return `missing required argument \`${result.detail.name}'`;
        default:
          return `parse failed: ${result.code}`;
      }
    },
  });
}
TMS/messages/zh.js
// TMS — a second renderer, structurally parallel and unaware of the first.
//
// It exists to make the boundary real rather than theoretical: if the English
// renderer owned the phrasing, this file would have to import it, and the two
// would stop being separately loadable.

import { ERROR } from "../../SMS/parse.js";

export function chineseMessages() {
  return Object.freeze({
    name: "zh",
    render(result) {
      switch (result.code) {
        case ERROR.UNKNOWN_OPTION:
          return `無法辨識的選項 \`${result.detail.flag}'`;
        case ERROR.MISSING_OPTION_ARGUMENT:
          return result.detail.got
            ? `選項 \`${result.detail.flags}' 缺少參數,讀到的是 \`${result.detail.got}'`
            : `選項 \`${result.detail.flags}' 缺少參數`;
        case ERROR.MISSING_ARGUMENT:
          return `缺少必要參數 \`${result.detail.name}'`;
        default:
          return `解析失敗:${result.code}`;
      }
    },
  });
}

DMS

DMS/trace.js
// DMS — what the parse did, separate from what it returned.
//
// commander 2.x has no equivalent. A failed parse ends the process, so there is
// nothing left to inspect: the exit code is the entire report.

export function createTrace() {
  const events = [];
  return {
    record(event, data = {}) { events.push({ event, ...data }); },
    events: () => [...events],
    humanView() {
      if (!events.length) return "Nothing recorded.";
      return events.map((e) => {
        if (e.event === "parse.ok") return `Parsed ${e.optionCount} option(s) and ${e.argCount} argument(s).`;
        if (e.event === "parse.error") return `Rejected: ${e.message}`;
        if (e.event === "policy.declined") return `Policy "${e.policy}" declined to ${e.action}.`;
        return e.event;
      }).join("\n");
    },
  };
}

root

island-test.js
// Island test. Run: node src/island-test.js
//
// The claim being tested is the one the re-cut exists for: the parser decides
// nothing about the host, and either message renderer works without the other.

import assert from "node:assert/strict";
import { parse, ERROR } from "./SMS/parse.js";
import { silentPolicy, collectingPolicy } from "./SCL/host-policy.js";
import { chineseMessages } from "./TMS/messages/zh.js";

let failures = 0;
function check(name, fn) {
  try {
    fn();
    console.log(`  [OK]   ${name}`);
  } catch (error) {
    failures += 1;
    console.log(`  [FAIL] ${name} — ${error.message}`);
  }
}

const SPEC = [
  { flags: "-p, --port", name: "port", takesValue: true },
  { flags: "-v, --verbose", name: "verbose", takesValue: false },
];

console.log("island test: SMS/parse alone, no policy and no renderer loaded");

check("a valid parse returns a value", () => {
  const r = parse(SPEC, ["--port", "8080", "build"]);
  assert.equal(r.kind, "ok");
  assert.deepEqual(r.options, { port: "8080" });
  assert.deepEqual(r.args, ["build"]);
});

check("an unknown option is data, not an exit", () => {
  const r = parse(SPEC, ["--porrt"]);
  assert.equal(r.kind, "error");
  assert.equal(r.code, ERROR.UNKNOWN_OPTION);
  assert.equal(r.detail.flag, "--porrt");
});

check("a missing option value is data, not an exit", () => {
  const r = parse(SPEC, ["--port", "--verbose"]);
  assert.equal(r.kind, "error");
  assert.equal(r.code, ERROR.MISSING_OPTION_ARGUMENT);
});

check("allowUnknown keeps the token instead of failing", () => {
  const r = parse(SPEC, ["--porrt"], { allowUnknown: true });
  assert.equal(r.kind, "ok");
  assert.deepEqual(r.args, ["--porrt"]);
});

check("parse touches no host global", () => {
  // If parse called process.exit the way commander does, this test file would
  // end here and the remaining checks would never print — which is exactly the
  // failure mode that makes the original hard to unit test.
  const original = process.exit;
  let called = false;
  process.exit = () => { called = true; };
  parse(SPEC, ["--porrt"]);
  parse(SPEC, ["--port"]);
  process.exit = original;
  assert.equal(called, false);
});

console.log("\nisland test: TMS/messages/zh alone, English renderer never imported");

check("the Chinese renderer works with no sibling loaded", () => {
  const r = parse(SPEC, ["--porrt"]);
  const text = chineseMessages().render(r);
  assert.match(text, /無法辨識的選項/);
});

check("a silent policy performs no effect at all", () => {
  const policy = silentPolicy();
  assert.equal(policy.mayWrite, false);
  assert.equal(policy.mayExit, false);
  policy.onError("anything");
});

check("a collecting policy captures instead of exiting", () => {
  const policy = collectingPolicy();
  policy.onError("unknown option `--porrt'");
  assert.equal(policy.written.length, 1);
  assert.equal(policy.mayExit, false);
});

console.log(failures ? `\n${failures} check(s) failed` : "\nall island checks passed");
process.exit(failures ? 1 : 0);
main.js
// The same three inputs, run under three different host policies.
//
// In commander 2.20.3 the first of these ends the process and the other two are
// not expressible.

import { parse } from "./SMS/parse.js";
import { cliPolicy, collectingPolicy, silentPolicy } from "./SCL/host-policy.js";
import { englishMessages } from "./TMS/messages/en.js";
import { chineseMessages } from "./TMS/messages/zh.js";
import { createTrace } from "./DMS/trace.js";

const SPEC = [
  { flags: "-p, --port", name: "port", takesValue: true },
  { flags: "-v, --verbose", name: "verbose", takesValue: false },
];

const CASES = [
  { label: "valid", argv: ["--port", "8080", "--verbose", "build"] },
  { label: "unknown option", argv: ["--porrt", "8080"] },
  { label: "option missing its value", argv: ["--port", "--verbose"] },
];

function run(policy, messages) {
  const trace = createTrace();
  const results = [];

  for (const testCase of CASES) {
    const result = parse(SPEC, testCase.argv, { required: [] });

    if (result.kind === "ok") {
      trace.record("parse.ok", { optionCount: Object.keys(result.options).length, argCount: result.args.length });
      results.push(`${testCase.label.padEnd(24)} ok    ${JSON.stringify(result.options)} args=${JSON.stringify(result.args)}`);
      continue;
    }

    const message = messages.render(result);
    trace.record("parse.error", { message });
    if (!policy.mayWrite) trace.record("policy.declined", { policy: policy.name, action: "write" });
    policy.onError(message);
    results.push(`${testCase.label.padEnd(24)} error ${result.code}`);
  }

  return { results, trace };
}

// A CLI policy that reports where the process would have died, rather than
// dying — so this file can demonstrate all three without ending early.
const wouldExit = [];
const reportingCli = cliPolicy({
  write: (text) => wouldExit.push(`stderr: ${text.trim()}`),
  exit: (code) => wouldExit.push(`process.exit(${code}) <- commander 2.20.3 stops here`),
});

for (const [policy, messages] of [
  [reportingCli, englishMessages()],
  [collectingPolicy(), chineseMessages()],
  [silentPolicy(), englishMessages()],
]) {
  console.log(`\n--- policy: ${policy.name} / messages: ${messages.name} ---`);
  const { results, trace } = run(policy, messages);
  for (const line of results) console.log(`  ${line}`);
  console.log("  DMS:");
  for (const line of trace.humanView().split("\n")) console.log(`    ${line}`);
  if (policy.name === "cli") for (const line of wouldExit) console.log(`    ${line}`);
  if (policy.written) for (const line of policy.written) console.log(`    collected: ${line}`);
}

console.log("\nSame parser, three hosts. The parse never decided whether to end the process.");