Skip to content

Commit 68e234f

Browse files
authored
fix(cli): preserve usage-error exits for lazy reparses
Reparse nested lazy commands from the Commander root so unknown options keep the original argv and exit non-zero. Adds nested lazy-command coverage for the root rawArgs path. Fixes #92069.
1 parent 5854e0c commit 68e234f

2 files changed

Lines changed: 46 additions & 9 deletions

File tree

src/cli/program/action-reparse.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ vi.mock("./helpers.js", () => ({
1616
resolveCommandOptionArgs: resolveCommandOptionArgsMock,
1717
}));
1818

19+
function setRawArgs(command: Command, rawArgs: string[]): void {
20+
(command as Command & { rawArgs: string[] }).rawArgs = rawArgs;
21+
}
22+
1923
describe("reparseProgramFromActionArgs", () => {
2024
beforeEach(() => {
2125
vi.clearAllMocks();
@@ -26,12 +30,11 @@ describe("reparseProgramFromActionArgs", () => {
2630

2731
it("uses action command name + args as fallback argv", async () => {
2832
const program = new Command().name("openclaw");
33+
setRawArgs(program, ["node", "openclaw", "status", "--json"]);
2934
const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program);
3035
const actionCommand = {
3136
name: () => "status",
32-
parent: {
33-
rawArgs: ["node", "openclaw", "status", "--json"],
34-
},
37+
parent: program,
3538
} as unknown as Command;
3639
resolveActionArgsMock.mockReturnValue(["--json"]);
3740

@@ -47,18 +50,19 @@ describe("reparseProgramFromActionArgs", () => {
4750

4851
it("falls back to action args without command name when action has no name", async () => {
4952
const program = new Command().name("openclaw");
53+
setRawArgs(program, ["node", "openclaw"]);
5054
const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program);
5155
const actionCommand = {
5256
name: () => "",
53-
parent: {},
57+
parent: program,
5458
} as unknown as Command;
5559
resolveActionArgsMock.mockReturnValue(["--json"]);
5660

5761
await reparseProgramFromActionArgs(program, [actionCommand]);
5862

5963
expect(buildParseArgvMock).toHaveBeenCalledWith({
6064
programName: "openclaw",
61-
rawArgs: undefined,
65+
rawArgs: ["node", "openclaw"],
6266
fallbackArgv: ["--json"],
6367
});
6468
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]);
@@ -85,6 +89,27 @@ describe("reparseProgramFromActionArgs", () => {
8589
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]);
8690
});
8791

92+
it("uses root raw args and reparses the root for nested lazy commands", async () => {
93+
const root = new Command().name("openclaw");
94+
setRawArgs(root, ["node", "openclaw", "workspaces", "audit", "export", "--since", "1"]);
95+
const workspaces = root.command("workspaces");
96+
const audit = workspaces.command("audit");
97+
const exportCommand = audit.command("export");
98+
const parseAsync = vi.spyOn(root, "parseAsync").mockResolvedValue(root);
99+
const auditParseAsync = vi.spyOn(audit, "parseAsync");
100+
resolveActionArgsMock.mockReturnValue(["--since", "1"]);
101+
102+
await reparseProgramFromActionArgs(audit, [exportCommand]);
103+
104+
expect(buildParseArgvMock).toHaveBeenCalledWith({
105+
programName: "openclaw",
106+
rawArgs: ["node", "openclaw", "workspaces", "audit", "export", "--since", "1"],
107+
fallbackArgv: ["export", "--since", "1"],
108+
});
109+
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]);
110+
expect(auditParseAsync).not.toHaveBeenCalled();
111+
});
112+
88113
it("uses program root when action command is missing", async () => {
89114
const program = new Command().name("openclaw");
90115
const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program);

src/cli/program/action-reparse.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,31 @@ function buildFallbackArgv(program: Command, actionCommand: Command | undefined)
1212
: [...parentOptionArgs, ...actionArgsList];
1313
}
1414

15+
function findRootCommand(cmd: Command): Command {
16+
let current: Command = cmd;
17+
while (current.parent) {
18+
current = current.parent;
19+
}
20+
return current;
21+
}
22+
1523
/** Rebuild argv from Commander action args and re-run parsing after lazy registration. */
1624
export async function reparseProgramFromActionArgs(
1725
program: Command,
1826
actionArgs: unknown[],
1927
): Promise<void> {
2028
const actionCommand = actionArgs.at(-1) as Command | undefined;
21-
const root = actionCommand?.parent ?? program;
22-
const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs;
29+
// Use the true root program for argv reconstruction and parsing.
30+
// For nested lazy commands (e.g. workspaces → audit), `program` is a sub-command
31+
// whose rawArgs is cleared by Commander's restoreStateBeforeParse(). Only the
32+
// root program retains the rawArgs set by _prepareUserArgs.
33+
const rootProgram = findRootCommand(actionCommand ?? program);
34+
const rawArgs = (rootProgram as Command & { rawArgs?: string[] }).rawArgs;
2335
const fallbackArgv = buildFallbackArgv(program, actionCommand);
2436
const parseArgv = buildParseArgv({
25-
programName: program.name(),
37+
programName: rootProgram.name(),
2638
rawArgs,
2739
fallbackArgv,
2840
});
29-
await program.parseAsync(parseArgv);
41+
await rootProgram.parseAsync(parseArgv);
3042
}

0 commit comments

Comments
 (0)