Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<const T extends ArgsDef = ArgsDef>(
def: CommandDef<T>,
Expand All @@ -21,6 +22,7 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
cmd: CommandDef<T>,
opts: RunCommandOptions,
): Promise<{ result: unknown }> {
const cmdMeta = await resolveValue(cmd.meta);
const cmdArgs = await resolveValue(cmd.args || {});
const parsedArgs = parseArgs<T>(opts.rawArgs, cmdArgs);

Expand All @@ -35,6 +37,7 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
const plugins = await resolvePlugins(cmd.plugins ?? []);

let result: unknown;
let handledBySubCommand = false;
let runError: unknown;
try {
// Plugin setup hooks
Expand All @@ -58,6 +61,7 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
if (!subCommand) {
throw new CLIError(`Unknown command ${cyan(explicitName)}`, "E_UNKNOWN_COMMAND");
}
handledBySubCommand = true;
await runCommand(subCommand, {
rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1),
});
Expand All @@ -78,6 +82,7 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
"E_UNKNOWN_COMMAND",
);
}
handledBySubCommand = true;
await runCommand(subCommand, {
rawArgs: opts.rawArgs,
});
Expand All @@ -87,6 +92,11 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
}
}

// 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);
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface CommandMeta {
description?: string;
hidden?: boolean;
alias?: string | string[];
allowUnknownOptions?: boolean;
}

// Command: Definition
Expand Down
26 changes: 26 additions & 0 deletions src/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CLIError, toArray } from "./_utils.ts";
import type { ArgsDef, ParsedArgs } from "./types.ts";

export function validateUnknownOptions<T extends ArgsDef = ArgsDef>(
argsDef: T,
args: ParsedArgs<T>,
): void {
const names = new Set<string>();
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",
);
}
}
}
38 changes: 36 additions & 2 deletions test/main.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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`");
});
});

Expand Down
Loading