diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 76270be9b24a..14f7dc309fd5 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -341,6 +341,35 @@ describe("gateway-cli coverage", () => { expect(runtimeErrors).toHaveLength(0); }); + it.each(["abc", "7d", "1.5", "", " "])( + "rejects malformed usage-cost --days %j", + async (days) => { + callGateway.mockClear(); + + await expectGatewayExit(["gateway", "usage-cost", "--days", days, "--json"]); + + expect(callGateway).not.toHaveBeenCalled(); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ + ok: false, + error: { type: "cli_error", message: expect.stringContaining("Invalid --days") }, + }); + expect(runtimeErrors).toHaveLength(0); + }, + ); + + it("rejects a malformed health --timeout before calling Gateway", async () => { + callGateway.mockClear(); + + await expectGatewayExit(["gateway", "health", "--timeout", "abc", "--json"]); + + expect(callGateway).not.toHaveBeenCalled(); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ + ok: false, + error: { type: "cli_error", message: expect.stringContaining("Invalid --timeout") }, + }); + expect(runtimeErrors).toHaveLength(0); + }); + it("writes JSON for gateway health transport failures in JSON mode", async () => { const error = new Error("gateway closed (1006)"); const payload = { diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index 9b2285b4fdb6..a32278ad1e4b 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -353,21 +353,25 @@ describe("gateway register option collisions", () => { expectLocalGatewayCall("diagnostics.stability", 19095, { limit: 25 }); }, }, - { - name: "falls back for non-decimal usage-cost --days values", - argv: ["gateway", "usage-cost", "--days", "1e3", "--json"], - assert: () => { - expect(callGatewayCli).toHaveBeenCalledTimes(1); - const [method, _opts, params] = firstGatewayCall(); - expect(method).toBe("usage.cost"); - expect(params).toEqual({ days: 30 }); - }, - }, ])("$name", async ({ argv, assert }) => { await sharedProgram.parseAsync(argv, { from: "user" }); assert(); }); + it("rejects non-decimal usage-cost --days values instead of silently defaulting", async () => { + await sharedProgram.parseAsync(["gateway", "usage-cost", "--days", "1e3", "--json"], { + from: "user", + }); + + expect(callGatewayCli).not.toHaveBeenCalled(); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ + ok: false, + error: { type: "cli_error", message: expect.stringContaining("Invalid --days") }, + }); + expect(defaultRuntime.error).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + it.each([ { name: "call", @@ -396,21 +400,22 @@ describe("gateway register option collisions", () => { expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); - it("uses the effective local port override for gateway health auth diagnostics", async () => { + it("uses the effective local port and timeout for gateway health auth diagnostics", async () => { const authError = new Error("gateway auth required"); callGatewayCli.mockRejectedValueOnce(authError); emitReachableGatewayAuthDiagnostic.mockResolvedValueOnce(true); - await sharedProgram.parseAsync(["gateway", "health", "--port", "19081", "--json"], { - from: "user", - }); + await sharedProgram.parseAsync( + ["gateway", "health", "--port", "19081", "--timeout", "1234", "--json"], + { from: "user" }, + ); expect(emitReachableGatewayAuthDiagnostic).toHaveBeenCalledTimes(1); expect(emitReachableGatewayAuthDiagnostic).toHaveBeenCalledWith({ error: authError, config: {}, runtime: defaultRuntime, - timeoutMs: 10000, + timeoutMs: 1234, token: undefined, password: undefined, localPortOverride: 19081, diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 210d7a84babe..afc558c0d913 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -24,6 +24,7 @@ import { formatCliJsonFailure, rethrowExpectedCliError } from "../failure-output import { parseGatewayPortOption } from "../gateway-port-option.js"; import { addGatewayClientOptions, callGatewayFromCliWithTransport } from "../gateway-rpc.js"; import { formatHelpExamples } from "../help-format.js"; +import { parseTimeoutMsWithFallback } from "../parse-timeout.js"; import { setCommandJsonMode } from "../program/json-mode.js"; import type { GatewayDiscoverOpts } from "./discover.js"; import { isGatewayMachineOutput } from "./output-mode.js"; @@ -165,24 +166,15 @@ function parseDaysOption(raw: unknown, fallback = 30): number { if (typeof raw === "number" && Number.isFinite(raw)) { return Math.max(1, Math.floor(raw)); } - if (typeof raw === "string" && raw.trim() !== "") { - const parsed = parseStrictPositiveInteger(raw); - if (parsed !== undefined) { - return parsed; - } - } - return fallback; -} - -function parseGatewayRpcTimeoutOption(raw: unknown, fallback = 10_000): number { - if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { - return Math.floor(raw); - } - if (typeof raw === "string" && raw.trim() !== "") { + if (typeof raw === "string") { const parsed = parseStrictPositiveInteger(raw); if (parsed !== undefined) { return parsed; } + // A present-but-unparseable value (including an explicit empty one) is + // operator error; the main RPC path rejects malformed --timeout the same + // way instead of silently defaulting. + throw new Error(`Invalid --days. Use a positive integer, e.g. --days 30. Received: "${raw}".`); } return fallback; } @@ -699,7 +691,9 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie error, config: rpcOpts.config ?? (await readNonObservingHealthConfig()), runtime: defaultRuntime, - timeoutMs: parseGatewayRpcTimeoutOption(rpcOpts.timeout), + timeoutMs: parseTimeoutMsWithFallback(rpcOpts.timeout, 10_000, { + invalidType: "error", + }), token: rpcOpts.token, password: rpcOpts.password, localPortOverride: rpcOpts.localPortOverride,