CLI: resolve parent/subcommand option collisions (#18725)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b7e51cf909
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-17 20:57:09 -05:00
committed by GitHub
parent fa4f66255c
commit 985ec71c55
17 changed files with 856 additions and 38 deletions

View File

@@ -0,0 +1,160 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayCli = vi.fn(async () => ({ ok: true }));
const gatewayStatusCommand = vi.fn(async () => {});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../cli-utils.js", () => ({
runCommandWithRuntime: async (
_runtime: unknown,
action: () => Promise<void>,
onError: (err: unknown) => void,
) => {
try {
await action();
} catch (err) {
onError(err);
}
},
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../../commands/gateway-status.js", () => ({
gatewayStatusCommand: (opts: unknown, runtime: unknown) => gatewayStatusCommand(opts, runtime),
}));
vi.mock("./call.js", () => ({
gatewayCallOpts: (cmd: Command) =>
cmd
.option("--url <url>", "Gateway WebSocket URL")
.option("--token <token>", "Gateway token")
.option("--password <password>", "Gateway password")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false)
.option("--json", "Output JSON", false),
callGatewayCli: (method: string, opts: unknown, params?: unknown) =>
callGatewayCli(method, opts, params),
}));
vi.mock("./run.js", () => ({
addGatewayRunCommand: (cmd: Command) =>
cmd
.option("--token <token>", "Gateway token")
.option("--password <password>", "Gateway password"),
}));
vi.mock("../daemon-cli.js", () => ({
addGatewayServiceCommands: () => undefined,
}));
vi.mock("../../commands/health.js", () => ({
formatHealthChannelLines: () => [],
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("../../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: async () => [],
}));
vi.mock("../../infra/widearea-dns.js", () => ({
resolveWideAreaDiscoveryDomain: () => undefined,
}));
vi.mock("../../terminal/health-style.js", () => ({
styleHealthChannelLine: (line: string) => line,
}));
vi.mock("../../terminal/links.js", () => ({
formatDocsLink: () => "docs.openclaw.ai/cli/gateway",
}));
vi.mock("../../terminal/theme.js", () => ({
colorize: (_rich: boolean, _fn: (value: string) => string, value: string) => value,
isRich: () => false,
theme: {
heading: (value: string) => value,
muted: (value: string) => value,
success: (value: string) => value,
},
}));
vi.mock("../../utils/usage-format.js", () => ({
formatTokenCount: () => "0",
formatUsd: () => "$0.00",
}));
vi.mock("../help-format.js", () => ({
formatHelpExamples: () => "",
}));
vi.mock("../progress.js", () => ({
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
}));
vi.mock("./discover.js", () => ({
dedupeBeacons: (beacons: unknown[]) => beacons,
parseDiscoverTimeoutMs: () => 2000,
pickBeaconHost: () => null,
pickGatewayPort: () => 18789,
renderBeaconLines: () => [],
}));
describe("gateway register option collisions", () => {
beforeEach(() => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayCli.mockClear();
gatewayStatusCommand.mockClear();
});
it("forwards --token to gateway call when parent and child option names collide", async () => {
const { registerGatewayCli } = await import("./register.js");
const program = new Command();
registerGatewayCli(program);
await program.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], {
from: "user",
});
expect(callGatewayCli).toHaveBeenCalledWith(
"health",
expect.objectContaining({
token: "tok_call",
}),
{},
);
});
it("forwards --token to gateway probe when parent and child option names collide", async () => {
const { registerGatewayCli } = await import("./register.js");
const program = new Command();
registerGatewayCli(program);
await program.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], {
from: "user",
});
expect(gatewayStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({
token: "tok_probe",
}),
defaultRuntime,
);
});
});

View File

@@ -11,6 +11,7 @@ import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { inheritOptionFromParent } from "../command-options.js";
import { addGatewayServiceCommands } from "../daemon-cli.js";
import { formatHelpExamples } from "../help-format.js";
import { withProgress } from "../progress.js";
@@ -46,6 +47,19 @@ function parseDaysOption(raw: unknown, fallback = 30): number {
return fallback;
}
function resolveGatewayRpcOptions<T extends { token?: string; password?: string }>(
opts: T,
command?: Command,
): T {
const parentToken = inheritOptionFromParent<string>(command, "token");
const parentPassword = inheritOptionFromParent<string>(command, "password");
return {
...opts,
token: opts.token ?? parentToken,
password: opts.password ?? parentPassword,
};
}
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
@@ -103,11 +117,12 @@ export function registerGatewayCli(program: Command) {
.description("Call a Gateway method")
.argument("<method>", "Method name (health/status/system-presence/cron.*)")
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
.action(async (method, opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
if (opts.json) {
const result = await callGatewayCli(method, rpcOpts, params);
if (rpcOpts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
@@ -125,11 +140,12 @@ export function registerGatewayCli(program: Command) {
.command("usage-cost")
.description("Fetch usage cost summary from session logs")
.option("--days <days>", "Number of days to include", "30")
.action(async (opts) => {
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", opts, { days });
if (opts.json) {
const result = await callGatewayCli("usage.cost", rpcOpts, { days });
if (rpcOpts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
@@ -146,10 +162,11 @@ export function registerGatewayCli(program: Command) {
gateway
.command("health")
.description("Fetch Gateway health")
.action(async (opts) => {
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const result = await callGatewayCli("health", opts);
if (opts.json) {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const result = await callGatewayCli("health", rpcOpts);
if (rpcOpts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
@@ -180,9 +197,10 @@ export function registerGatewayCli(program: Command) {
.option("--password <password>", "Gateway password (applies to all probes)")
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
.action(async (opts, command) => {
await runGatewayCommand(async () => {
await gatewayStatusCommand(opts, defaultRuntime);
const rpcOpts = resolveGatewayRpcOptions(opts, command);
await gatewayStatusCommand(rpcOpts, defaultRuntime);
});
});

View File

@@ -0,0 +1,143 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const startGatewayServer = vi.fn(async () => ({
close: vi.fn(async () => {}),
}));
const setGatewayWsLogStyle = vi.fn();
const setVerbose = vi.fn();
const forceFreePortAndWait = vi.fn(async () => ({
killed: [],
waitedMs: 0,
escalatedToSigkill: false,
}));
const ensureDevGatewayConfig = vi.fn(async () => {});
const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => {
await start();
});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../../config/config.js", () => ({
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
loadConfig: () => ({}),
readConfigFileSnapshot: async () => ({ exists: false }),
resolveStateDir: () => "/tmp",
resolveGatewayPort: () => 18789,
}));
vi.mock("../../gateway/auth.js", () => ({
resolveGatewayAuth: (params: { authConfig?: { token?: string }; env?: NodeJS.ProcessEnv }) => ({
mode: "token",
token: params.authConfig?.token ?? params.env?.OPENCLAW_GATEWAY_TOKEN,
password: undefined,
allowTailscale: false,
}),
}));
vi.mock("../../gateway/server.js", () => ({
startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts),
}));
vi.mock("../../gateway/ws-logging.js", () => ({
setGatewayWsLogStyle: (style: string) => setGatewayWsLogStyle(style),
}));
vi.mock("../../globals.js", () => ({
setVerbose: (enabled: boolean) => setVerbose(enabled),
}));
vi.mock("../../infra/gateway-lock.js", () => ({
GatewayLockError: class GatewayLockError extends Error {},
}));
vi.mock("../../infra/ports.js", () => ({
formatPortDiagnostics: () => [],
inspectPortUsage: async () => ({ status: "free" }),
}));
vi.mock("../../logging/console.js", () => ({
setConsoleSubsystemFilter: () => undefined,
setConsoleTimestampPrefix: () => undefined,
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
info: () => undefined,
warn: () => undefined,
error: () => undefined,
}),
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../command-format.js", () => ({
formatCliCommand: (cmd: string) => cmd,
}));
vi.mock("../ports.js", () => ({
forceFreePortAndWait: (port: number, opts: unknown) => forceFreePortAndWait(port, opts),
}));
vi.mock("./dev.js", () => ({
ensureDevGatewayConfig: (opts?: unknown) => ensureDevGatewayConfig(opts),
}));
vi.mock("./run-loop.js", () => ({
runGatewayLoop: (params: { start: () => Promise<unknown> }) => runGatewayLoop(params),
}));
describe("gateway run option collisions", () => {
beforeEach(() => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
startGatewayServer.mockClear();
setGatewayWsLogStyle.mockClear();
setVerbose.mockClear();
forceFreePortAndWait.mockClear();
ensureDevGatewayConfig.mockClear();
runGatewayLoop.mockClear();
});
it("forwards parent-captured options to `gateway run` subcommand", async () => {
const { addGatewayRunCommand } = await import("./run.js");
const program = new Command();
const gateway = addGatewayRunCommand(program.command("gateway"));
addGatewayRunCommand(gateway.command("run"));
await program.parseAsync(
[
"gateway",
"run",
"--token",
"tok_run",
"--allow-unconfigured",
"--ws-log",
"full",
"--force",
],
{ from: "user" },
);
expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything());
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
expect(startGatewayServer).toHaveBeenCalledWith(
18789,
expect.objectContaining({
auth: expect.objectContaining({
token: "tok_run",
}),
}),
);
});
});

View File

@@ -1,7 +1,8 @@
import type { Command } from "commander";
import fs from "node:fs";
import path from "node:path";
import type { Command } from "commander";
import type { GatewayAuthMode } from "../../config/config.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import {
CONFIG_PATH,
loadConfig,
@@ -11,7 +12,6 @@ import {
} from "../../config/config.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import { startGatewayServer } from "../../gateway/server.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setVerbose } from "../../globals.js";
import { GatewayLockError } from "../../infra/gateway-lock.js";
@@ -20,6 +20,7 @@ import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logg
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
import { inheritOptionFromParent } from "../command-options.js";
import { forceFreePortAndWait } from "../ports.js";
import { ensureDevGatewayConfig } from "./dev.js";
import { runGatewayLoop } from "./run-loop.js";
@@ -53,6 +54,50 @@ type GatewayRunOpts = {
const gatewayLog = createSubsystemLogger("gateway");
const GATEWAY_RUN_VALUE_KEYS = [
"port",
"bind",
"token",
"auth",
"password",
"tailscale",
"wsLog",
"rawStreamPath",
] as const;
const GATEWAY_RUN_BOOLEAN_KEYS = [
"tailscaleResetOnExit",
"allowUnconfigured",
"dev",
"reset",
"force",
"verbose",
"claudeCliLogs",
"compact",
"rawStream",
] as const;
function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts {
const resolved: GatewayRunOpts = { ...opts };
for (const key of GATEWAY_RUN_VALUE_KEYS) {
const inherited = inheritOptionFromParent(command, key);
if (key === "wsLog") {
// wsLog has a child default ("auto"), so prefer inherited parent CLI value when present.
resolved[key] = inherited ?? resolved[key];
continue;
}
resolved[key] = resolved[key] ?? inherited;
}
for (const key of GATEWAY_RUN_BOOLEAN_KEYS) {
const inherited = inheritOptionFromParent<boolean>(command, key);
resolved[key] = Boolean(resolved[key] || inherited);
}
return resolved;
}
async function runGatewayCommand(opts: GatewayRunOpts) {
const isDevProfile = process.env.OPENCLAW_PROFILE?.trim().toLowerCase() === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
@@ -353,7 +398,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
.option("--compact", 'Alias for "--ws-log compact"', false)
.option("--raw-stream", "Log raw model stream events to jsonl", false)
.option("--raw-stream-path <path>", "Raw stream jsonl path")
.action(async (opts) => {
await runGatewayCommand(opts);
.action(async (opts, command) => {
await runGatewayCommand(resolveGatewayRunOptions(opts, command));
});
}