Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

## [Unreleased]

### 2026-07-15
- **Fix**: 存在しないサブコマンドへの `--help` (`geonic hello --help` 等) がヘルプ表示 + exit 0 で成功扱いになっていた問題を修正 — `geonic help <unknown>` と同じエラーを 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
Expand Down
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

enforceKnownCommandHelp(program);

return program;
}
71 changes: 64 additions & 7 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand All @@ -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 <unknown>`. 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<Command, string>();

// 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);
Expand Down
122 changes: 107 additions & 15 deletions tests/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ function findCommand(program: ReturnType<typeof createProgram>, ...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<string> {
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();

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<string> {
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();
Expand Down
Loading