Skip to content

Commit b14321c

Browse files
committed
fix: 存在しないサブコマンドへの --help をエラー扱いにして exit 1 を返す
Commander は --help/-h を検出すると、残りの operand が既知のサブコマンドか 検証せずにヘルプを表示して exit 0 していた (例: geonic hello --help)。 サブコマンドを持つコマンドの outputHelp をラップし、未解決の operand が 残っている場合は 'geonic help <unknown>' と同じエラー形式で exit 1 にする。
1 parent b565b47 commit b14321c

3 files changed

Lines changed: 94 additions & 2 deletions

File tree

src/cli.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { registerRulesCommand } from "./commands/rules.js";
1515
import { registerModelsCommand } from "./commands/models.js";
1616
import { registerCatalogCommand } from "./commands/catalog.js";
1717
import { registerHealthCommand, registerVersionCommand } from "./commands/health.js";
18-
import { registerHelpCommand } from "./commands/help.js";
18+
import { enforceKnownCommandHelp, registerHelpCommand } from "./commands/help.js";
1919
import { registerCliCommand } from "./commands/cli.js";
2020
import { addAttrsSubcommands } from "./commands/attrs.js";
2121

@@ -62,5 +62,7 @@ export function createProgram(): Command {
6262
addAttrsSubcommands(hiddenAttrs);
6363
program.addCommand(hiddenAttrs, { hidden: true });
6464

65+
enforceKnownCommandHelp(program);
66+
6567
return program;
6668
}

src/commands/help.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import chalk from "chalk";
2-
import type { Command, Help, Option } from "commander";
2+
import type { Command, Help, HelpContext, Option } from "commander";
33

44
interface Example {
55
description: string;
@@ -249,6 +249,42 @@ function showHelp(program: Command, args: string[]): void {
249249
console.log(formatCommandDetails(program, target, path));
250250
}
251251

252+
function reportUnknownCommand(cmd: Command, operand: string): never {
253+
// getCommandPath() starts with the program name ("geonic"); drop it
254+
const prefix = getCommandPath(cmd).split(" ").slice(1);
255+
const attempted = [...prefix, operand].join(" ");
256+
console.error(chalk.red(`Error: '${attempted}' is not a geonic command.`));
257+
console.error(`\nSee 'geonic help' for available commands.`);
258+
process.exit(1);
259+
}
260+
261+
export function enforceKnownCommandHelp(program: Command): void {
262+
// Commander prints help and exits 0 when --help/-h is present, even if the
263+
// remaining operand is not a known subcommand (e.g. `geonic hello --help`).
264+
// Wrap outputHelp() on every command group so that case errors with exit 1,
265+
// matching `geonic help <unknown>`. Call after all commands are registered.
266+
const visit = (cmd: Command): void => {
267+
if (cmd.commands.length > 0) {
268+
const originalOutputHelp = cmd.outputHelp.bind(cmd);
269+
// Same parameter type as Command.outputHelp (including deprecated overload)
270+
cmd.outputHelp = (
271+
contextOptions?: HelpContext | ((str: string) => string),
272+
): void => {
273+
// cmd.args is operands followed by unparsed options such as --help
274+
const operand = cmd.args.find((arg) => !arg.startsWith("-"));
275+
if (operand !== undefined && !findCommand(cmd, operand)) {
276+
reportUnknownCommand(cmd, operand);
277+
}
278+
originalOutputHelp(contextOptions as HelpContext | undefined);
279+
};
280+
}
281+
for (const sub of cmd.commands) {
282+
visit(sub);
283+
}
284+
};
285+
visit(program);
286+
}
287+
252288
export function registerHelpCommand(program: Command): void {
253289
// Disable Commander's built-in help command
254290
program.addHelpCommand(false);

tests/help.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,60 @@ describe("help", () => {
683683
});
684684
});
685685

686+
describe("unknown subcommand with --help", () => {
687+
async function runExpectingError(argv: string[]): Promise<string> {
688+
const prog = createProgram();
689+
prog.exitOverride();
690+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
691+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
692+
throw new Error("process.exit");
693+
});
694+
try {
695+
await prog.parseAsync(["node", "geonic", ...argv]);
696+
} catch {
697+
// expected
698+
}
699+
const output = stripAnsi(errSpy.mock.calls.map((c) => c[0]).join("\n"));
700+
expect(exitSpy).toHaveBeenCalledWith(1);
701+
errSpy.mockRestore();
702+
exitSpy.mockRestore();
703+
return output;
704+
}
705+
706+
it("errors for unknown top-level command with --help", async () => {
707+
const output = await runExpectingError(["hello", "--help"]);
708+
expect(output).toContain("'hello' is not a geonic command");
709+
expect(output).toContain("geonic help");
710+
});
711+
712+
it("errors for unknown top-level command with -h", async () => {
713+
const output = await runExpectingError(["hello", "-h"]);
714+
expect(output).toContain("'hello' is not a geonic command");
715+
});
716+
717+
it("errors for unknown nested subcommand with --help", async () => {
718+
const output = await runExpectingError(["entities", "hello", "--help"]);
719+
expect(output).toContain("'entities hello' is not a geonic command");
720+
});
721+
722+
it("still shows help for a leaf command with an argument and --help", async () => {
723+
const prog = createProgram();
724+
prog.exitOverride();
725+
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
726+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
727+
try {
728+
await prog.parseAsync(["node", "geonic", "entities", "get", "urn:x", "--help"]);
729+
} catch {
730+
// exitOverride throws on help
731+
}
732+
const output = stripAnsi(writeSpy.mock.calls.map((c) => c[0]).join(""));
733+
expect(output).toContain("geonic entities get");
734+
expect(errSpy).not.toHaveBeenCalled();
735+
writeSpy.mockRestore();
736+
errSpy.mockRestore();
737+
});
738+
});
739+
686740
describe("no-args handler", () => {
687741
it("shows help when geonic is run with no arguments", async () => {
688742
const prog = createProgram();

0 commit comments

Comments
 (0)