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
10 changes: 9 additions & 1 deletion src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,13 +508,21 @@ for (const entry of CLI_COMMANDS) {
export const DISPATCH_COMMANDS: ReadonlySet<string> = new Set(Object.keys(commandRunners));
export const DISPATCH_ALIASES: ReadonlyMap<string, string> = aliasTargets;

/** Resolve the runner key for a command, following registry aliases to the
* canonical runner. Returns undefined when the command is unknown. */
export function resolveDispatchCommand(command: string | undefined): string | undefined {
if (command === undefined) return undefined;
if (Object.prototype.hasOwnProperty.call(commandRunners, command)) return command;
return aliasTargets.get(command);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export async function dispatchCommand(head: CliHead, deps: CliDispatchDeps): Promise<number> {
const command = head.command;
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
printUsage();
return 0;
}
const runner = commandRunners[command] ?? commandRunners[aliasTargets.get(command) ?? ""];
const runner = commandRunners[resolveDispatchCommand(command) ?? ""];
if (!runner) {
console.error(`Unknown command: ${command}`);
printUsage();
Expand Down
79 changes: 79 additions & 0 deletions tests/cli-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { CLI_COMMANDS } from "../src/cli/registry";
import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand } from "../src/cli/dispatch";
import type { CliDispatchDeps } from "../src/cli/dispatch";

/** Minimal fake deps. dispatchCommand only touches deps for real command
* runners, which these tests never invoke, so an empty object is enough. */
const fakeDeps = {} as unknown as CliDispatchDeps;

describe("CLI dispatch command coverage", () => {
test("every non-hidden registry command is dispatchable", () => {
const aliasResolved = new Set([...DISPATCH_COMMANDS, ...DISPATCH_ALIASES.keys()]);
const missing = CLI_COMMANDS.filter(entry => {
if (entry.hidden) return false;
// A visible command counts as dispatchable when it is a direct runner
// key or an alias that resolves to one (setup/eject/remove/model).
return !aliasResolved.has(entry.name);
}).map(entry => entry.name);
expect(missing).toEqual([]);
});

test("every dispatch alias resolves to a dispatchable command", () => {
for (const [alias, target] of DISPATCH_ALIASES) {
expect(DISPATCH_COMMANDS).toContain(target);
expect(alias).not.toBe(target);
}
});
});

describe("CLI dispatch aliases", () => {
test("canonical alias pairs resolve to their command", () => {
expect(DISPATCH_ALIASES.get("setup")).toBe("init");
expect(DISPATCH_ALIASES.get("eject")).toBe("restore");
expect(DISPATCH_ALIASES.get("remove")).toBe("uninstall");
expect(DISPATCH_ALIASES.get("model")).toBe("models");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("resolveDispatchCommand maps each alias to its canonical runner key", () => {
// The same resolver dispatchCommand uses for runner selection, exercised
// at the resolution level so a regression in the lookup is caught.
expect(resolveDispatchCommand("setup")).toBe("init");
expect(resolveDispatchCommand("eject")).toBe("restore");
expect(resolveDispatchCommand("remove")).toBe("uninstall");
expect(resolveDispatchCommand("model")).toBe("models");
// Canonical names resolve to themselves; unknown names resolve undefined.
expect(resolveDispatchCommand("init")).toBe("init");
expect(resolveDispatchCommand("definitely-not-a-command")).toBeUndefined();
expect(resolveDispatchCommand(undefined)).toBeUndefined();
});

test("resolveDispatchCommand rejects inherited Object property names", () => {
// commandRunners is a normal object; inherited names (__proto__,
// constructor, toString) must not resolve as valid commands.
expect(resolveDispatchCommand("__proto__")).toBeUndefined();
expect(resolveDispatchCommand("constructor")).toBeUndefined();
expect(resolveDispatchCommand("toString")).toBeUndefined();
});
});

describe("dispatchCommand exit codes", () => {
test("returns 0 for help forms", async () => {
expect(await dispatchCommand({ kind: "help", command: "help", args: ["help"] }, fakeDeps)).toBe(0);
expect(await dispatchCommand({ kind: "help", command: "--help", args: ["--help"] }, fakeDeps)).toBe(0);
expect(await dispatchCommand({ kind: "help", command: "-h", args: ["-h"] }, fakeDeps)).toBe(0);
expect(await dispatchCommand({ kind: "command", command: undefined, args: [] }, fakeDeps)).toBe(0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("returns 1 for an unknown command", async () => {
const head = { kind: "command" as const, command: "definitely-not-a-command", args: ["definitely-not-a-command"] };
expect(await dispatchCommand(head, fakeDeps)).toBe(1);
});

test("returns 1 for inherited Object property names", async () => {
for (const name of ["__proto__", "constructor", "toString"]) {
const head = { kind: "command" as const, command: name, args: [name] };
expect(await dispatchCommand(head, fakeDeps), `${name} must be unknown`).toBe(1);
}
});
});
Loading