From 80cf8c7f1959cac4abcea558f1abe3eeff1c579b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 22:34:16 +0800 Subject: [PATCH] fix(cli): make group help side-effect free --- tools/hwlab-cli/client.test.ts | 21 ++++++ tools/src/hwlab-cli-lib.ts | 119 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 28f6370e..c33a2f7d 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -111,6 +111,27 @@ test("hwlab-cli client auth status exposes local session state and next commands assert.equal(JSON.stringify(mismatch.payload).includes("session-old"), false); }); +test("hwlab-cli client group help is visible and does not issue HTTP requests", async () => { + let fetchCount = 0; + const fetchImpl = async () => { + fetchCount += 1; + return new Response(JSON.stringify({ ok: false }), { status: 500 }); + }; + + const auth = await runHwlabCli(["client", "auth", "--help", "--base-url", "http://web.test"], { fetchImpl }); + assert.equal(auth.exitCode, 0); + assert.equal(auth.payload.action, "client.auth.help"); + assert.equal(auth.payload.serviceRuntime, false); + assert.ok(auth.payload.commands.some((command: string) => command.startsWith("login "))); + + const agent = await runHwlabCli(["client", "agent", "--help", "--base-url", "http://web.test"], { fetchImpl }); + assert.equal(agent.exitCode, 0); + assert.equal(agent.payload.action, "client.agent.help"); + assert.equal(agent.payload.serviceRuntime, false); + assert.ok(agent.payload.commands.some((command: string) => command.startsWith("send "))); + assert.equal(fetchCount, 0); +}); + test("hwlab-cli client device-pods events compacts event stream by default", async () => { const result = await runHwlabCli(["client", "device-pods", "events", "pod-1", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl: async () => new Response(JSON.stringify({ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 9a5b4be3..39f4865d 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -62,6 +62,10 @@ async function clientCommand(context: any) { const group = context.rest[0] || "help"; const next = { ...context, rest: context.rest.slice(1) }; if (["help", "--help", "-h"].includes(group)) return help(); + if (context.parsed.help === true) { + const groupHelp = clientSubcommandHelp(group); + if (groupHelp) return groupHelp; + } if (group === "auth") return authCommand(next); if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next); if (group === "runtime") return runtimeCommand(next); @@ -74,6 +78,19 @@ async function clientCommand(context: any) { throw cliError("unsupported_client_command", `unsupported client command: ${group}`, { group }); } +function clientSubcommandHelp(group: string) { + if (group === "auth") return authHelp(); + if (group === "device-pods" || group === "device-pod") return devicePodsHelp(); + if (group === "runtime") return runtimeHelp(); + if (group === "gateway") return gatewayHelp(); + if (group === "agent") return agentHelp(); + if (["harness", "harness-ops", "harness-opt"].includes(group)) return harnessHelp(); + if (group === "workbench") return workbenchHelp(); + if (group === "rpc") return rpcHelp(); + if (group === "request") return requestHelp(); + return null; +} + function help() { return ok("help", { version: VERSION, @@ -116,6 +133,7 @@ function help() { async function authCommand(context: any) { const subcommand = context.rest[0] || "session"; + if (wantsHelp(context)) return authHelp(); if (subcommand === "login") return authLogin(context); if (subcommand === "status") return authStatus(context); if (subcommand === "profiles") return authProfiles(context); @@ -132,6 +150,20 @@ async function authCommand(context: any) { throw cliError("unsupported_auth_command", `unsupported auth command: ${subcommand}`, { subcommand }); } +function authHelp() { + return ok("client.auth.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "login --base-url URL --username USER --password-env HWLAB_PASSWORD [--profile NAME]", + "status [--base-url URL] [--profile NAME]", + "session [--base-url URL] [--profile NAME]", + "profiles [--base-url URL]", + "logout [--base-url URL] [--profile NAME]" + ] + }); +} + async function authStatus(context: any) { const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })); return ok("client.auth.status", { @@ -190,6 +222,7 @@ async function authLogin(context: any) { async function devicePodsCommand(context: any) { const subcommand = context.rest[0] || "list"; + if (wantsHelp(context)) return devicePodsHelp(); if (subcommand === "list") { const response = await requestJson({ ...context, method: "GET", path: "/v1/device-pods" }); return responsePayload("client.device-pods.list", response, context, { route: route("GET", "/v1/device-pods"), body: responseBodyForCli(response.body, context.parsed) }); @@ -210,8 +243,22 @@ async function devicePodsCommand(context: any) { throw cliError("unsupported_device_pods_command", `unsupported device-pods command: ${subcommand}`, { subcommand }); } +function devicePodsHelp() { + return ok("client.device-pods.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "list [--base-url URL]", + "status POD_ID [--base-url URL]", + "events POD_ID [--limit N]", + "probe POD_ID [--max-bytes N]" + ] + }); +} + async function runtimeCommand(context: any) { const subcommand = context.rest[0] || "routes"; + if (wantsHelp(context)) return runtimeHelp(); if (subcommand !== "routes" && subcommand !== "pods") { throw cliError("unsupported_runtime_command", `unsupported runtime command: ${subcommand}`, { subcommand }); } @@ -237,6 +284,17 @@ async function runtimeCommand(context: any) { }); } +function runtimeHelp() { + return ok("client.runtime.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "routes [--service-id SERVICE]", + "pods [--service-id SERVICE]" + ] + }); +} + function liveBuildServices(body: any) { const builds = Array.isArray(body?.builds) ? body.builds : []; if (builds.length > 0) return builds; @@ -523,6 +581,7 @@ function gatewayScenarioPass({ scenario, response, dispatch, stdout, stderr, gat async function agentCommand(context: any) { const subcommand = context.rest[0] || "send"; + if (wantsHelp(context)) return agentHelp(); if (subcommand === "send") return agentSend(context); if (subcommand === "result") { const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId); @@ -550,6 +609,20 @@ async function agentCommand(context: any) { throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand }); } +function agentHelp() { + return ok("client.agent.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [--no-wait]", + "result TRACE_ID [--full]", + "trace TRACE_ID [--render web] [--limit N] [--full]", + "inspect --trace-id TRACE_ID|--conversation-id ID|--session-id ID|--thread-id ID", + "cancel TRACE_ID" + ] + }); +} + function agentInspectPath(context: any) { const params = new URLSearchParams(); const traceId = text(context.rest[1] ?? context.parsed.traceId); @@ -892,6 +965,7 @@ async function pollAgentResult(context: any, traceId: string, acceptedBody: any) async function workbenchCommand(context: any) { const subcommand = context.rest[0] || "summary"; + if (wantsHelp(context)) return workbenchHelp(); if (subcommand === "restore" || subcommand === "status") return workbenchRestoreCommand(context, subcommand); if (subcommand === "watch") return workbenchWatchCommand(context); if (subcommand === "reset") return workbenchResetCommand(context); @@ -921,6 +995,20 @@ async function workbenchCommand(context: any) { return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: baseUrl(context.parsed, context.env), devicePodId: podId, probes }, failed ? "degraded" : "succeeded"); } +function workbenchHelp() { + return ok("client.workbench.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "summary [--pod-id POD_ID]", + "restore [--profile NAME]", + "status [--profile NAME]", + "watch [--after-revision REVISION]", + "reset --confirm" + ] + }); +} + async function workbenchRestoreCommand(context: any, subcommand: string) { const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); return ok(`client.workbench.${subcommand}`, { @@ -954,6 +1042,7 @@ async function workbenchResetCommand(context: any) { } async function requestCommand(context: any) { + if (wantsHelp(context)) return requestHelp(); const method = requiredText(context.rest[0], "method").toUpperCase(); const pathName = normalizeRequestPath(requiredText(context.rest[1], "path")); const body = await requestBodyValue(context); @@ -976,7 +1065,20 @@ async function requestCommand(context: any) { }); } +function requestHelp() { + return ok("client.request.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "GET /path [--full]", + "POST /path --body-json '{...}' [--full]", + "METHOD /path --body-file FILE" + ] + }); +} + async function rpcCommand(context: any) { + if (wantsHelp(context)) return rpcHelp(); const method = requiredText(context.rest[0] ?? context.parsed.method, "method"); const params = await rpcParamsValue(context); const traceId = text(context.parsed.traceId) || makeId("trc"); @@ -1006,6 +1108,23 @@ async function rpcCommand(context: any) { }); } +function rpcHelp() { + return ok("client.rpc.help", { + serviceRuntime: false, + imagePublished: false, + commands: [ + "METHOD [--params-json '{...}']", + "METHOD --params-file FILE", + "METHOD --full" + ] + }); +} + +function wantsHelp(context: any) { + const token = text(context.rest?.[0]); + return context.parsed.help === true || token === "help" || token === "--help" || token === "-h"; +} + async function rpcParamsValue(context: any) { const { parsed } = context; const raw = typeof parsed.paramsJson === "string"