diff --git a/src/command.ts b/src/command.ts index e0c7316..8e36d69 100644 --- a/src/command.ts +++ b/src/command.ts @@ -4,6 +4,7 @@ import { CLIError, resolveValue, toArray } from "./_utils.ts"; import { parseArgs } from "./args.ts"; import { cyan } from "./_color.ts"; import { resolvePlugins } from "./plugin.ts"; +import { validateUnknownOptions } from "./validate.ts"; export function defineCommand( def: CommandDef, @@ -21,6 +22,7 @@ export async function runCommand( cmd: CommandDef, opts: RunCommandOptions, ): Promise<{ result: unknown }> { + const cmdMeta = await resolveValue(cmd.meta); const cmdArgs = await resolveValue(cmd.args || {}); const parsedArgs = parseArgs(opts.rawArgs, cmdArgs); @@ -35,6 +37,7 @@ export async function runCommand( const plugins = await resolvePlugins(cmd.plugins ?? []); let result: unknown; + let handledBySubCommand = false; let runError: unknown; try { // Plugin setup hooks @@ -58,6 +61,7 @@ export async function runCommand( if (!subCommand) { throw new CLIError(`Unknown command ${cyan(explicitName)}`, "E_UNKNOWN_COMMAND"); } + handledBySubCommand = true; await runCommand(subCommand, { rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1), }); @@ -78,6 +82,7 @@ export async function runCommand( "E_UNKNOWN_COMMAND", ); } + handledBySubCommand = true; await runCommand(subCommand, { rawArgs: opts.rawArgs, }); @@ -87,6 +92,11 @@ export async function runCommand( } } + // Validate unknown options (skip if handled by sub command) + if (!handledBySubCommand && !cmdMeta?.allowUnknownOptions) { + validateUnknownOptions(cmdArgs, parsedArgs); + } + // Handle main command if (typeof cmd.run === "function") { result = await cmd.run(context); diff --git a/src/types.ts b/src/types.ts index e4280ef..da9ee82 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,6 +90,7 @@ export interface CommandMeta { description?: string; hidden?: boolean; alias?: string | string[]; + allowUnknownOptions?: boolean; } // Command: Definition diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..30e6279 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,26 @@ +import { CLIError, toArray } from "./_utils.ts"; +import type { ArgsDef, ParsedArgs } from "./types.ts"; + +export function validateUnknownOptions( + argsDef: T, + args: ParsedArgs, +): void { + const names = new Set(); + for (const [name, argDef] of Object.entries(argsDef || {})) { + names.add(name); + for (const alias of toArray((argDef as any).alias)) { + names.add(alias); + } + } + + for (const arg of Object.keys(args)) { + if (arg === "_") continue; + + if (!names.has(arg)) { + throw new CLIError( + `Unknown option \`${arg.length > 1 ? `--${arg}` : `-${arg}`}\``, + "E_UNKNOWN_OPTION", + ); + } + } +} diff --git a/test/main.test.ts b/test/main.test.ts index 13cc609..3de20c0 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterAll } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { createMain, defineCommand, renderUsage, runMain, showUsage } from "../src/index.ts"; import * as commandModule from "../src/command.ts"; @@ -9,8 +9,9 @@ describe("runMain", () => { const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => undefined); - afterAll(() => { + afterEach(() => { consoleMock.mockReset(); + consoleErrorMock.mockReset(); }); it("shows version with flag `--version`", async () => { @@ -102,6 +103,39 @@ describe("runMain", () => { await runMain(command, { rawArgs }); expect(mockRunCommand).toHaveBeenCalledWith(command, { rawArgs }); + expect(consoleErrorMock).toHaveBeenCalledWith("Unknown option `--foo`"); + }); + + it("should not warn for subcommand options", async () => { + const command = defineCommand({ + subCommands: { + foo: { + args: { + bar: { type: "string" }, + }, + run: () => {}, + }, + }, + }); + + await runMain(command, { rawArgs: ["foo", "--bar", "baz"] }); + + expect(consoleErrorMock).not.toHaveBeenCalled(); + }); + + it("should allow unknown options with `allowUnknownOptions` enabled", async () => { + const mockRunCommand = vi.spyOn(commandModule, "runCommand"); + + const command = defineCommand({ + meta: { allowUnknownOptions: true }, + }); + + const rawArgs = ["--foo", "bar"]; + + await runMain(command, { rawArgs }); + + expect(mockRunCommand).toHaveBeenCalledWith(command, { rawArgs }); + expect(consoleErrorMock).not.toHaveBeenCalledWith("Unknown option `--foo`"); }); });