From de827bf326c5dc801634ae43ed7092add590ca96 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 3 Aug 2026 10:59:03 +0000 Subject: [PATCH] fix(cli): report oclif parse errors without a raw stack trace The direct command-id route recognized parse errors by class name, so an invalid enum flag or argument value escaped as an unhandled rejection: a Node stack trace, an error-property dump, and exit 1 instead of the declared 2. Recognize the shape every oclif parse error carries so the route prints one actionable line and exits with the parser code. Signed-off-by: Tinson Lai --- src/lib/cli/oclif-runner.test.ts | 91 +++++++++++++++++++++++++++++ src/lib/cli/oclif-runner.ts | 9 +++ test/cli/oclif-parse-errors.test.ts | 36 ++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 test/cli/oclif-parse-errors.test.ts diff --git a/src/lib/cli/oclif-runner.test.ts b/src/lib/cli/oclif-runner.test.ts index cebab623247..d252882a320 100644 --- a/src/lib/cli/oclif-runner.test.ts +++ b/src/lib/cli/oclif-runner.test.ts @@ -246,6 +246,97 @@ describe("runOclifCommandById", () => { expect(exit).toHaveBeenCalledWith(2); }); + it("formats an invalid enum flag value instead of rethrowing it (#8123)", async () => { + class FlagInvalidOptionError extends Error { + oclif = { exit: 2 }; + parse = {}; + showHelp = false; + } + runCommandMock.mockRejectedValue( + new FlagInvalidOptionError( + "Expected --reasoning-effort=ultra to be one of: low, medium, high, default\nSee more help with --help", + ), + ); + const errorLine = vi.fn(); + const exit = vi.fn((code: number): never => { + throw new Error(`exit:${code}`); + }); + + await expect( + runOclifCommandById("inference:set", ["--reasoning-effort", "ultra"], { + rootDir: "/repo", + error: errorLine, + exit, + }), + ).rejects.toThrow("exit:2"); + + expect(errorLine).toHaveBeenCalledWith( + " Expected --reasoning-effort=ultra to be one of: low, medium, high, default\nSee more help with --help", + ); + expect(exit).toHaveBeenCalledWith(2); + }); + + it("formats an invalid enum argument value instead of rethrowing it (#8123)", async () => { + class ArgInvalidOptionError extends Error { + oclif = { exit: 2 }; + parse = {}; + showHelp = false; + } + runCommandMock.mockRejectedValue( + new ArgInvalidOptionError("Expected powershell to be one of: bash, zsh, fish"), + ); + const errorLine = vi.fn(); + const exit = vi.fn((code: number): never => { + throw new Error(`exit:${code}`); + }); + + await expect( + runOclifCommandById("completion", ["powershell"], { + rootDir: "/repo", + error: errorLine, + exit, + }), + ).rejects.toThrow("exit:2"); + + expect(errorLine).toHaveBeenCalledWith(" Expected powershell to be one of: bash, zsh, fish"); + expect(exit).toHaveBeenCalledWith(2); + }); + + it("recognizes a parse error by shape when its class name is unknown (#8123)", async () => { + class SomeLaterParserError extends Error { + oclif = { exit: 2 }; + parse = {}; + showHelp = true; + } + runCommandMock.mockRejectedValue(new SomeLaterParserError("A parser rule rejected the input")); + const errorLine = vi.fn(); + const exit = vi.fn((code: number): never => { + throw new Error(`exit:${code}`); + }); + + await expect( + runOclifCommandById("list", ["--json"], { rootDir: "/repo", error: errorLine, exit }), + ).rejects.toThrow("exit:2"); + + expect(errorLine).toHaveBeenCalledWith(" A parser rule rejected the input"); + expect(exit).toHaveBeenCalledWith(2); + }); + + it("keeps rethrowing a command failure that carries an unrelated parse property (#8123)", async () => { + class CommandFailure extends Error { + oclif = { exit: 2 }; + parse = { source: "sandboxes.json" }; + } + const error = new CommandFailure("Could not read the sandbox registry"); + runCommandMock.mockRejectedValue(error); + const errorLine = vi.fn(); + + await expect( + runOclifCommandById("list", [], { rootDir: "/repo", error: errorLine }), + ).rejects.toBe(error); + expect(errorLine).not.toHaveBeenCalled(); + }); + it("treats oclif graceful ExitError(0) as silent success", async () => { // Mirrors what `Command.exit(0)` and `--help` actually throw in oclif: an // ExitError instance whose synthetic `EEXIT: 0` message must NOT leak to diff --git a/src/lib/cli/oclif-runner.ts b/src/lib/cli/oclif-runner.ts index 8c5a46365eb..67a4c213623 100644 --- a/src/lib/cli/oclif-runner.ts +++ b/src/lib/cli/oclif-runner.ts @@ -25,6 +25,14 @@ function getOclifExitCode(error: unknown): number | null { return typeof oclif?.exit === "number" ? oclif.exit : null; } +function hasOclifParseErrorShape(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + if (typeof getOclifExitCode(error) !== "number") return false; + return ( + Object.hasOwn(error, "parse") && typeof (error as { showHelp?: unknown }).showHelp === "boolean" + ); +} + function isOclifParseError(error: unknown): boolean { const name = error && typeof error === "object" @@ -32,6 +40,7 @@ function isOclifParseError(error: unknown): boolean { : ""; const message = error instanceof Error ? error.message : ""; return ( + hasOclifParseErrorShape(error) || name === "NonExistentFlagsError" || name === "RequiredArgsError" || name === "UnexpectedArgsError" || diff --git a/test/cli/oclif-parse-errors.test.ts b/test/cli/oclif-parse-errors.test.ts new file mode 100644 index 00000000000..825e3fc10c9 --- /dev/null +++ b/test/cli/oclif-parse-errors.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { PARSER_EXIT_CODE, run } from "./helpers"; + +function expectCleanParseFailure(out: string): void { + expect(out).not.toMatch(/^\s+at /m); + expect(out).not.toContain("Node.js v"); + expect(out).not.toContain("@oclif/core/lib/parser"); + expect(out).not.toContain("InvalidOptionError"); + expect(out).not.toContain("showHelp:"); +} + +describe("oclif parse errors", () => { + it("reports an invalid enum flag value without a stack trace (#8123)", () => { + const r = run( + "inference set --provider compatible-endpoint --model gpt-4o-mini --reasoning-effort ultra --no-verify", + ); + + expect(r.code).toBe(PARSER_EXIT_CODE); + expect(r.out).toContain( + "Expected --reasoning-effort=ultra to be one of: low, medium, high, default", + ); + expectCleanParseFailure(r.out); + }); + + it("reports an invalid enum argument value without a stack trace (#8123)", () => { + const r = run("completion powershell"); + + expect(r.code).toBe(PARSER_EXIT_CODE); + expect(r.out).toContain("Expected powershell to be one of: bash, zsh, fish"); + expectCleanParseFailure(r.out); + }); +});