diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5bf71..58a7beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ ## [Unreleased] +### 2026-07-15 +- **Fix**: 存在しないサブコマンドへの `--help` (`geonic hello --help` 等) がヘルプ表示 + exit 0 で成功扱いになっていた問題を修正 — `geonic help ` と同じエラーを stderr に出力し exit 1 を返す (#147) + - エラーメッセージはユーザーが入力したエイリアス表記をそのまま echo する(`models badsub` を `custom-data-models badsub` に置換しない) + - 未知オプションの値はサブコマンド名と誤認しない(`geonic entities --bogus json --help` は従来通りヘルプ表示) + ## [0.18.0] - 2026-07-15 ### 2026-07-15 diff --git a/src/cli.ts b/src/cli.ts index 7951b71..2c9caa7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,7 @@ import { registerRulesCommand } from "./commands/rules.js"; import { registerModelsCommand } from "./commands/models.js"; import { registerCatalogCommand } from "./commands/catalog.js"; import { registerHealthCommand, registerVersionCommand } from "./commands/health.js"; -import { registerHelpCommand } from "./commands/help.js"; +import { enforceKnownCommandHelp, registerHelpCommand } from "./commands/help.js"; import { registerCliCommand } from "./commands/cli.js"; import { addAttrsSubcommands } from "./commands/attrs.js"; @@ -62,5 +62,7 @@ export function createProgram(): Command { addAttrsSubcommands(hiddenAttrs); program.addCommand(hiddenAttrs, { hidden: true }); + enforceKnownCommandHelp(program); + return program; } diff --git a/src/commands/help.ts b/src/commands/help.ts index a5c7f23..f3198c1 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,5 +1,5 @@ import chalk from "chalk"; -import type { Command, Help, Option } from "commander"; +import type { Command, Help, HelpContext, Option } from "commander"; interface Example { description: string; @@ -220,6 +220,12 @@ export function formatCommandDetails( return lines.join("\n"); } +function unknownCommandError(attempted: string): never { + console.error(chalk.red(`Error: '${attempted}' is not a geonic command.`)); + console.error(`\nSee 'geonic help' for available commands.`); + process.exit(1); +} + function showHelp(program: Command, args: string[]): void { if (args.length === 0) { console.log(formatTopLevelHelp(program)); @@ -232,12 +238,7 @@ function showHelp(program: Command, args: string[]): void { for (let i = 0; i < args.length; i++) { const found = findCommand(current, args[i]); if (!found) { - const attempted = args.slice(0, i + 1).join(" "); - console.error( - chalk.red(`Error: '${attempted}' is not a geonic command.`), - ); - console.error(`\nSee 'geonic help' for available commands.`); - process.exit(1); + unknownCommandError(args.slice(0, i + 1).join(" ")); } resolved.push(found); current = found; @@ -249,6 +250,62 @@ function showHelp(program: Command, args: string[]): void { console.log(formatCommandDetails(program, target, path)); } +export function enforceKnownCommandHelp(program: Command): void { + // Commander prints help and exits 0 when --help/-h is present, even if the + // remaining operand is not a known subcommand (e.g. `geonic hello --help`). + // Wrap outputHelp() on every command group so that case errors with exit 1, + // matching `geonic help `. Call after all commands are registered. + + // Token the user actually typed to invoke each command (name or alias), + // recorded at dispatch time so error messages echo e.g. "models" as typed, + // not the canonical name "custom-data-models". + const typedNames = new WeakMap(); + + // Path from the program down to cmd, using the recorded typed tokens. + const typedCommandPath = (cmd: Command): string[] => { + const path: string[] = []; + let current: Command | null = cmd; + while (current && current.parent) { + path.unshift(typedNames.get(current) ?? current.name()); + current = current.parent; + } + return path; + }; + + const visit = (cmd: Command): void => { + if (cmd.commands.length > 0) { + // preSubcommand fires on dispatch, before commander's --help + // short-circuit; parent.args[0] is the operand commander resolved the + // subcommand from. Hooks are not inherited, so attach per group. + cmd.hook("preSubcommand", (parent, subCommand) => { + const token = parent.args[0]; + if (token !== undefined) { + typedNames.set(subCommand, token); + } + }); + + const originalOutputHelp = cmd.outputHelp.bind(cmd); + // Same parameter type as Command.outputHelp (including deprecated overload) + cmd.outputHelp = ( + contextOptions?: HelpContext | ((str: string) => string), + ): void => { + // cmd.args lists operands first, then unparsed options such as --help. + // Only a leading non-empty, non-dash token is a genuine operand — a + // non-dash token found later may be the value of an unrecognized option. + const operand = cmd.args[0]; + if (operand && !operand.startsWith("-") && !findCommand(cmd, operand)) { + unknownCommandError([...typedCommandPath(cmd), operand].join(" ")); + } + originalOutputHelp(contextOptions as HelpContext | undefined); + }; + } + for (const sub of cmd.commands) { + visit(sub); + } + }; + visit(program); +} + export function registerHelpCommand(program: Command): void { // Disable Commander's built-in help command program.addHelpCommand(false); diff --git a/tests/help.test.ts b/tests/help.test.ts index 714a718..5970127 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -16,6 +16,30 @@ function findCommand(program: ReturnType, ...names: string return current; } +// Parses argv expecting the unknown-command error path: captures stderr, +// asserts process.exit(1), and returns the stripped stderr output. +async function runExpectingError(argv: string[]): Promise { + const prog = createProgram(); + prog.exitOverride(); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + try { + await prog.parseAsync(["node", "geonic", ...argv]); + } catch { + // expected + } + const output = stripAnsi(errSpy.mock.calls.map((c) => c[0]).join("\n")); + expect(exitSpy).toHaveBeenCalledWith(1); + return output; + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + describe("help", () => { const program = createProgram(); @@ -609,21 +633,8 @@ describe("help", () => { }); it("shows error for invalid command name in help", async () => { - const prog = createProgram(); - prog.exitOverride(); - const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - try { - await prog.parseAsync(["node", "geonic", "help", "nonexistent"]); - } catch { - // expected - } - const output = errSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(stripAnsi(output)).toContain("'nonexistent' is not a geonic command"); - errSpy.mockRestore(); - exitSpy.mockRestore(); + const output = await runExpectingError(["help", "nonexistent"]); + expect(output).toContain("'nonexistent' is not a geonic command"); }); it("shows top-level help when help is called with no args", async () => { @@ -683,6 +694,87 @@ describe("help", () => { }); }); + describe("unknown subcommand with --help", () => { + // Parses argv expecting help to be shown: captures stdout and returns it, + // asserting no error was printed. + async function runExpectingHelp(argv: string[]): Promise { + const prog = createProgram(); + prog.exitOverride(); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + try { + await prog.parseAsync(["node", "geonic", ...argv]); + } catch { + // exitOverride throws on help + } + const output = stripAnsi(writeSpy.mock.calls.map((c) => c[0]).join("")); + expect(errSpy).not.toHaveBeenCalled(); + return output; + } finally { + writeSpy.mockRestore(); + errSpy.mockRestore(); + } + } + + it("errors for unknown top-level command with --help", async () => { + const output = await runExpectingError(["hello", "--help"]); + expect(output).toContain("'hello' is not a geonic command"); + expect(output).toContain("geonic help"); + }); + + it("errors for unknown top-level command with -h", async () => { + const output = await runExpectingError(["hello", "-h"]); + expect(output).toContain("'hello' is not a geonic command"); + }); + + it("errors for unknown nested subcommand with --help", async () => { + const output = await runExpectingError(["entities", "hello", "--help"]); + expect(output).toContain("'entities hello' is not a geonic command"); + }); + + it("still shows help for a leaf command with an argument and --help", async () => { + const output = await runExpectingHelp(["entities", "get", "urn:x", "--help"]); + expect(output).toContain("geonic entities get"); + }); + + it("echoes the alias the user typed, not the canonical command name", async () => { + const output = await runExpectingError(["models", "badsub", "--help"]); + expect(output).toContain("'models badsub' is not a geonic command"); + expect(output).not.toContain("custom-data-models"); + }); + + it("echoes the typed alias even when an option value collides with a command name", async () => { + // The --service value must not be mistaken for the typed command token + const output = await runExpectingError([ + "--service", "custom-data-models", "models", "badsub", "--help", + ]); + expect(output).toContain("'models badsub' is not a geonic command"); + expect(output).not.toContain("custom-data-models badsub"); + }); + + it("does not mistake an unknown option's value for a subcommand", async () => { + // "--bogus json" must not be reported as `geonic entities json` + const output = await runExpectingHelp(["entities", "--bogus", "json", "--help"]); + expect(output).toContain("geonic entities"); + }); + + it("shows top-level help when --help precedes a non-command token", async () => { + const output = await runExpectingHelp(["--help", "hello"]); + expect(output).toContain("AVAILABLE COMMANDS"); + }); + + it("shows group help when --help precedes a non-command token in a subgroup", async () => { + const output = await runExpectingHelp(["entities", "--help", "badsub"]); + expect(output).toContain("geonic entities"); + }); + + it("does not report an empty-string operand as an unknown command", async () => { + const output = await runExpectingHelp(["entities", "", "--help"]); + expect(output).toContain("geonic entities"); + }); + }); + describe("no-args handler", () => { it("shows help when geonic is run with no arguments", async () => { const prog = createProgram();