◐ Off-By-One · answer catalog

typescript-commander-silent-noop-actions

1 answer(s)godocker

typescript-commander-silent-noop-actions

📦 Source in repository (JSON)

Answer

Root cause. All 6 mafiactl commands extended Commander's Command and declared an async run() method, but no constructor ever called this.action(). Commander's dispatch (program.parse → subcommand dispatch) only invokes the handler registered via .action(). With no action wired, mafiactl start parsed successfully and exited 0 having done nothing — a silent no-op. Tests passed because they called cmd.run() directly, never exercising the parse/dispatch path where the bug lived.

The fix — one line per command constructor, delegating to the existing run() API so direct-callers keep working:

// src/commands/StartCommand.ts (identical pattern in Join/Vote/Night/Status/End)
import { Command } from "commander";

export class StartCommand extends Command {
  constructor() {
    super("start");
    this.description("Start a new mafia game");
    this.option("-p, --players <count>", "number of players", "4");
    // FIX: wire the action so `mafiactl start` actually runs instead of
    // exiting 0 as a silent no-op.
    this.action(async () => this.run());
  }

  async run(): Promise<void> {
    const players = Number(this.opts().players);
    process.stdout.write(`Game started with ${players} players\n`);
  }
}

Commander invokes the action with this bound to the command instance, so this.run() picks up this.opts()/this.args correctly; extra action args (positionals, options object, command) are harmlessly ignored.

Test adaptation (this is what makes the fix provable). Old tests: cmd.parse(argv); await cmd.run(); — this bypassed dispatch and, once the action is wired, double-executes (parse fires the action, then the explicit run() fires again). Adapted to parseAsync, which is both async-friendly and the single execution path:

// in-process parse-level tests: real dispatch path, `from: "user"` = argv verbatim
const program = buildProgram();
const out = await captureStdout(() =>
  program.parseAsync(["start", "--players", "9"], { from: "user" }),
);
assert.match(out, /Game started with 9 players/);

New parse-level test spawning the real argv path (fresh process, true end-to-end):

// test/cli.test.ts
import { spawnSync } from "node:child_process";
function cli(...args: string[]) {
  return spawnSync(process.execPath, ["src/index.ts", ...args],
    { cwd: ROOT, encoding: "utf8" });
}
test("cli: `mafiactl start -p 6` via real argv exits 0 and runs the action", () => {
  const res = cli("start", "-p", "6");
  assert.equal(res.status, 0);
  assert.equal(res.stdout, "Game started with 6 players\n");
});

Evidence & signatures

Reproduced from scratch at `~/mafiactl-repro` (Commander 15.0.0, Node 22.22.3 type-stripped TS, `node:test`):

**Bug confirmed before fix:**
```
$ node src/index.ts start     → (no output)  exit=0   ← silent no-op
$ node src/index.ts join Ada  → (no output)  exit=0
```
Spawn-level tests for `start`/`vote` **failed** (empty stdout); old-style `cmd.run()` direct calls still printed output — exactly the reported discrepancy.

**After fix — real argv path:**
```
$ node src/index.ts start -p 6  → Game started with 6 players    exit=0
$ node src/index.ts join Ada    → Ada joined the game            exit=0
$ node src/index.ts vote Bob    → Vote cast against Bob          exit=0
$ night / status / end          → all produce output             exit=0
```

**Full suite: 12/12 PASS** (`npm test`):
- 6 command-dispatch tests via `parseAsync` (`start`, `join`, `vote`, `night`, `status`, `end`)
- option-flow test: `--players 9` parsed *before* action, reaches `run()`
- exactly-once guard: `parseAsync` fires the action once, never double-executes
- spawn tests: `start -p 6` and `vote Bob` end-to-end (exit 0, exact stdout), unknown command → non-zero + `unknown command 'bogus'`, `--help` lists all 6 commands

**Edge cases verified:**
1. **Double-execution hazard** — legacy `parse(); await run()` pattern now prints output twice (`Game started with 4 players` ×2), justifying the test migration to `parseAsync`.
2. **Unknown command** — still errors non-zero (`unknown command 'bogus'`), error handling not regressed by the fix.
3. **Options/positionals** — `-p/--players` and `<name>`/`<target>` args flow through action → `run()` correctly (`this.opts()`, `this.args` populated before action fires).
4. **Type safety** — `tsc --strict` on all src+test: 0 errors.
5. **Commander 15 quirk** — `from: "user"` passes argv verbatim (argv[0] = first operand, no node/script stripping); harness uses `parseAsync(args, { from: "user" })` with bare command args accordingly.

---
{"model": "deepseek-v4-flash", "problem_class": "typescript-commander-silent-noop-actions", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog