From 9c63b7381c5ceef703f56ba09c532357f4b2dd00 Mon Sep 17 00:00:00 2001 From: kricsleo Date: Mon, 21 Apr 2025 14:18:48 +0800 Subject: [PATCH 1/3] feat: warn unknown options --- src/command.ts | 6 ++++++ src/types.ts | 1 + src/validate.ts | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 src/validate.ts diff --git a/src/command.ts b/src/command.ts index 48781971..ae2d459b 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,6 +1,7 @@ import type { CommandContext, CommandDef, ArgsDef } from "./types"; import { CLIError, resolveValue } from "./_utils"; import { parseArgs } from "./args"; +import { validateUnknownOptions } from "./validate"; export function defineCommand( def: CommandDef, @@ -18,9 +19,14 @@ 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); + if (!cmdMeta || !cmdMeta.allowUnknownOptions) { + validateUnknownOptions(cmdArgs, parsedArgs); + } + const context: CommandContext = { rawArgs: opts.rawArgs, args: parsedArgs, diff --git a/src/types.ts b/src/types.ts index e9892f6f..a1887be4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,7 @@ export interface CommandMeta { version?: string; description?: string; hidden?: boolean; + allowUnknownOptions?: boolean; } // Command: Definition diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 00000000..927bf8f2 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,23 @@ +import { CLIError, toArray } from "./_utils"; +import { ArgsDef, ParsedArgs } from "./types"; + +export function validateUnknownOptions( + argsDef: T, + args: ParsedArgs, +): void { + const names: string[] = []; + for (const [name, argDef] of Object.entries(argsDef || {})) { + names.push(name, ...toArray((argDef as any).alias)); + } + + for (const arg in args) { + if (arg === "_") continue; + + if (!names.includes(arg)) { + throw new CLIError( + `Unknown option \`${arg.length > 1 ? `--${arg}` : `-${arg}`}\``, + "E_UNKNOWN_OPTION", + ); + } + } +} From 59159ae71dfcb9a2c52d23e1d1467e37c8c3a127 Mon Sep 17 00:00:00 2001 From: kricsleo Date: Mon, 21 Apr 2025 14:39:14 +0800 Subject: [PATCH 2/3] test: add tests for `allowUnknownOptions` --- test/main.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/main.test.ts b/test/main.test.ts index 2bbfa419..8a7c4847 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 consola from "consola"; import { createMain, @@ -20,8 +20,9 @@ describe("runMain", () => { .spyOn(consola, "error") .mockImplementation(() => undefined); - afterAll(() => { + afterEach(() => { consoleMock.mockReset(); + consolaErrorMock.mockReset(); }); it("shows version with flag `--version`", async () => { @@ -116,6 +117,22 @@ describe("runMain", () => { await runMain(command, { rawArgs }); expect(mockRunCommand).toHaveBeenCalledWith(command, { rawArgs }); + expect(consolaErrorMock).toHaveBeenCalledWith("Unknown option `--foo`"); + }); + + 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(consolaErrorMock).not.toHaveBeenCalledWith("Unknown option `--foo`"); }); }); From e69f0db1e29cb544edf47eac2273266a8b019c9e Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 1 Apr 2026 20:23:32 +0200 Subject: [PATCH 3/3] fix: validate unknown options after subcommand resolution Use Object.keys instead of for...in, Set instead of array for lookups, and skip parent validation when a subcommand handles the args. --- src/command.ts | 11 +++++++---- src/validate.ts | 13 ++++++++----- test/main.test.ts | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/command.ts b/src/command.ts index ae2d459b..9fc51c76 100644 --- a/src/command.ts +++ b/src/command.ts @@ -23,10 +23,6 @@ export async function runCommand( const cmdArgs = await resolveValue(cmd.args || {}); const parsedArgs = parseArgs(opts.rawArgs, cmdArgs); - if (!cmdMeta || !cmdMeta.allowUnknownOptions) { - validateUnknownOptions(cmdArgs, parsedArgs); - } - const context: CommandContext = { rawArgs: opts.rawArgs, args: parsedArgs, @@ -41,6 +37,7 @@ export async function runCommand( // Handle sub command let result: unknown; + let handledBySubCommand = false; try { const subCommands = await resolveValue(cmd.subCommands); if (subCommands && Object.keys(subCommands).length > 0) { @@ -57,6 +54,7 @@ export async function runCommand( } const subCommand = await resolveValue(subCommands[subCommandName]); if (subCommand) { + handledBySubCommand = true; await runCommand(subCommand, { rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1), }); @@ -66,6 +64,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/validate.ts b/src/validate.ts index 927bf8f2..eb146d7c 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,19 +1,22 @@ import { CLIError, toArray } from "./_utils"; -import { ArgsDef, ParsedArgs } from "./types"; +import type { ArgsDef, ParsedArgs } from "./types"; export function validateUnknownOptions( argsDef: T, args: ParsedArgs, ): void { - const names: string[] = []; + const names = new Set(); for (const [name, argDef] of Object.entries(argsDef || {})) { - names.push(name, ...toArray((argDef as any).alias)); + names.add(name); + for (const alias of toArray((argDef as any).alias)) { + names.add(alias); + } } - for (const arg in args) { + for (const arg of Object.keys(args)) { if (arg === "_") continue; - if (!names.includes(arg)) { + 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 8a7c4847..8807c37d 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -120,6 +120,27 @@ describe("runMain", () => { expect(consolaErrorMock).toHaveBeenCalledWith("Unknown option `--foo`"); }); + it("should not warn for subcommand options", async () => { + const consolaErrorMockLocal = vi + .spyOn(consola, "error") + .mockImplementation(() => undefined); + + const command = defineCommand({ + subCommands: { + foo: { + args: { + bar: { type: "string" }, + }, + run: () => {}, + }, + }, + }); + + await runMain(command, { rawArgs: ["foo", "--bar", "baz"] }); + + expect(consolaErrorMockLocal).not.toHaveBeenCalled(); + }); + it("should allow unknown options with `allowUnknownOptions` enabled", async () => { const mockRunCommand = vi.spyOn(commandModule, "runCommand");