Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 11 additions & 1 deletion src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
// Resolve plugins
const plugins = await resolvePlugins(cmd.plugins ?? []);

// Resolve default sub command
const defaultSubCommand = await resolveValue(cmd.default);
if (defaultSubCommand && cmd.run) {
throw new CLIError(
`Command has a handler specified and a default sub command.`,
"E_DUPLICATE_COMMAND",
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Thread default through resolveSubCommand() too.

runCommand() now honors cmd.default, but resolveSubCommand() still only resolves an explicit bare token and returns the parent command for rawArgs = []. That makes the two exported entry points disagree about which command is active for the same definition.

Also applies to: 123-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/command.ts` around lines 37 - 44, resolveSubCommand() currently ignores
cmd.default and only treats an explicit bare token as a subcommand, causing
inconsistency with runCommand() which honors cmd.default; update
resolveSubCommand() to thread the command's default value (cmd.default) into its
resolution logic so that when rawArgs is empty or lacks an explicit subcommand
it returns the resolved default subcommand instead of the parent command.
Specifically, inside resolveSubCommand() (and the alternate branch referenced
around lines 123-133), consult resolveValue(cmd.default) the same way
runCommand() does, validate conflicts with cmd.run, and return the resolved
default command token so both entry points agree on the active command.


let result: unknown;
let runError: unknown;
try {
Expand All @@ -51,7 +60,8 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
const subCommands = await resolveValue(cmd.subCommands);
if (subCommands && Object.keys(subCommands).length > 0) {
const subCommandArgIndex = findSubCommandIndex(opts.rawArgs, cmdArgs);
const subCommandName = opts.rawArgs[subCommandArgIndex];
const subCommandName =
opts.rawArgs[subCommandArgIndex] || defaultSubCommand;
if (subCommandName) {
const subCommand = await _findSubCommand(subCommands, subCommandName);
if (!subCommand) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export type SubCommandsDef = Record<string, Resolvable<CommandDef<any>>>;
export type CommandDef<T extends ArgsDef = ArgsDef> = {
meta?: Resolvable<CommandMeta>;
args?: Resolvable<T>;
default?: Resolvable<keyof ArgsDef | string>;
subCommands?: Resolvable<SubCommandsDef>;
plugins?: Resolvable<CittyPlugin>[];
setup?: (context: CommandContext<T>) => any | Promise<any>;
Expand Down
70 changes: 70 additions & 0 deletions test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,76 @@ describe("resolveSubCommand", () => {
});
});

describe("default sub command", () => {
vi.spyOn(process, "exit").mockImplementation(() => 0 as never);

it("runs default sub command when no args provided", async () => {
const runMock = vi.fn();

const command = defineCommand({
default: "dev",
subCommands: {
dev: {
run: async () => {
runMock("dev");
},
},
build: {
run: async () => {
runMock("build");
},
},
},
});

await runMain(command, { rawArgs: [] });

expect(runMock).toHaveBeenCalledOnce();
expect(runMock).toHaveBeenCalledWith("dev");
});

it("runs explicit sub command even when default is set", async () => {
const runMock = vi.fn();

const command = defineCommand({
default: "dev",
subCommands: {
dev: {
run: async () => {
runMock("dev");
},
},
build: {
run: async () => {
runMock("build");
},
},
},
});

await runMain(command, { rawArgs: ["build"] });

expect(runMock).toHaveBeenCalledOnce();
expect(runMock).toHaveBeenCalledWith("build");
});

it("throws when both default and run are specified", async () => {
const command = defineCommand({
default: "dev",
subCommands: {
dev: {
run: async () => {},
},
},
run: async () => {},
});

await expect(
commandModule.runCommand(command, { rawArgs: [] }),
).rejects.toThrow(/handler specified and a default sub command/);
});
});

describe("builtin flag conflicts", () => {
vi.spyOn(process, "exit").mockImplementation(() => 0 as never);

Expand Down