From bcc7807c3d8f20a569d58c12fbb9ac94034dcd92 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 02:17:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E8=A1=A5=E9=BD=90=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=9C=BA=E5=99=A8=E8=BE=93=E5=87=BA=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/cli-input-errors.test.ts | 47 ++++++++++++++- scripts/src/output.ts | 17 +++--- scripts/src/platform-infra-gitea.ts | 31 ++++++---- scripts/src/ssh-help.test.ts | 37 ++++++++++++ scripts/ssh-cli.ts | 87 ++++++++++++++++++++++------ 5 files changed, 181 insertions(+), 38 deletions(-) diff --git a/scripts/src/cli-input-errors.test.ts b/scripts/src/cli-input-errors.test.ts index 54f9ec5b..0ce1b70e 100644 --- a/scripts/src/cli-input-errors.test.ts +++ b/scripts/src/cli-input-errors.test.ts @@ -14,17 +14,60 @@ describe("CLI input error hierarchy", () => { expect(result.stdout).not.toContain(" at "); }); + test("explicit machine output returns a JSON input-error envelope without a stack", () => { + for (const args of [["definitely-not-a-command", "--json"], ["definitely-not-a-command", "-o", "json"]]) { + const result = runCli(...args); + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + const payload = JSON.parse(result.stdout) as { + ok: boolean; + error: { kind: string; code: string; debug: boolean; stack?: string }; + }; + expect(payload.ok).toBe(false); + expect(payload.error.kind).toBe("cli-input"); + expect(payload.error.code).toBe("unknown-command"); + expect(payload.error.debug).toBe(false); + expect(payload.error.stack).toBeUndefined(); + expect(result.stdout).not.toContain("stackOmitted"); + } + }); + test("predictable Gitea option validation is compact and stack-free", () => { - const result = runCli("platform-infra", "gitea", "status", "--target", "NC01", "--json"); + const result = runCli("platform-infra", "gitea", "status", "--target", "NC01", "--definitely-unsupported"); expect(result.status).toBe(1); expect(result.stderr).toBe(""); expect(result.stdout.trim().split("\n").length).toBeLessThanOrEqual(8); expect(result.stdout).toContain("ERROR cli-input/unsupported-option"); - expect(result.stdout).toContain("argument: --json"); + expect(result.stdout).toContain("argument: --definitely-unsupported"); expect(result.stdout).not.toContain("stack"); expect(result.stdout).not.toContain("platform-infra-gitea.ts:"); }); + test("only an explicitly unknown command target is converted to a compact input error", () => { + const unknownTarget = runCli("platform-infra", "gitea", "status", "--target", "definitely-nope"); + expect(unknownTarget.status).toBe(1); + expect(unknownTarget.stderr).toBe(""); + expect(unknownTarget.stdout.trim().split("\n").length).toBeLessThanOrEqual(8); + expect(unknownTarget.stdout).toContain("ERROR cli-input/unknown-target"); + expect(unknownTarget.stdout).toContain("argument: definitely-nope"); + expect(unknownTarget.stdout).not.toContain("stack"); + + const internalConfig = spawnSync("bun", ["-e", [ + "import { emitError } from './scripts/src/output.ts';", + "import { resolveTarget } from './scripts/src/platform-infra-gitea-config.ts';", + "try { resolveTarget({ defaults: { targetId: 'NC01' }, targets: [] } as any, 'NC01'); }", + "catch (error) { emitError('internal-gitea-config-probe', error); }", + ].join(" ")], { + cwd: repoRoot, + env: process.env, + encoding: "utf8", + }); + expect(internalConfig.status).toBe(0); + const internalPayload = JSON.parse(internalConfig.stdout) as { error: { message: string; stack: string } }; + expect(internalPayload.error.message).toContain("unknown gitea target NC01"); + expect(internalPayload.error.stack).toContain("platform-infra-gitea-config.ts"); + }); + test("explicit debug retains stack while internal exceptions remain observable", () => { const debug = runCliWithEnv({ UNIDESK_CLI_DEBUG: "1" }, "definitely-not-a-command"); expect(debug.status).toBe(1); diff --git a/scripts/src/output.ts b/scripts/src/output.ts index e6785f2a..c4d2787e 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -97,7 +97,7 @@ export function emitText(text: string, command = "text"): void { export function emitError(command: string, error: unknown): void { const safeCommand = redactSensitiveCommandArgs(command); - if (error instanceof CliInputError && !cliDebugEnabled()) { + if (error instanceof CliInputError && !cliDebugEnabled() && !cliJsonOutputRequested()) { safeStdoutWrite(renderTextOutput(safeCommand, renderCliInputErrorText(safeCommand, error))); return; } @@ -171,15 +171,18 @@ function cliInputErrorPayload(error: CliInputError): Record { ...(error.usage !== undefined ? { usage: error.usage } : {}), ...(error.supported !== undefined ? { supported: error.supported } : {}), ...(error.hint !== undefined ? { hint: error.hint } : {}), - ...(debug - ? { debug: true, stack: error.stack ?? null } - : { - stackOmitted: true, - diagnosticHint: "Set UNIDESK_CLI_DEBUG=1 only when an input error needs a stack trace.", - }), + ...(debug ? { debug: true, stack: error.stack ?? null } : { debug: false }), }; } +function cliJsonOutputRequested(argv = process.argv.slice(2)): boolean { + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--json") return true; + if (argv[index] === "-o" && argv[index + 1] === "json") return true; + } + return false; +} + function cliDebugEnabled(): boolean { return process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 35639ea9..4ece87e8 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -146,10 +146,21 @@ export function giteaHelp(scope?: string): Record { }; } +function resolveCommandTarget(gitea: GiteaConfig, targetId: string | null): GiteaTarget { + if (targetId !== null && !gitea.targets.some((target) => target.id.toLowerCase() === targetId.toLowerCase())) { + throw new CliInputError(`Unknown Gitea target: ${targetId}`, { + code: "unknown-target", + argument: targetId, + supported: gitea.targets.map((target) => target.id), + usage: "bun scripts/cli.ts platform-infra gitea --target ", + }); + } + return resolveTarget(gitea, targetId); +} function plan(options: CommonOptions): Record { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); const manifest = renderManifest(gitea, target); const policy = policyChecks(gitea, target, manifest); return { @@ -168,7 +179,7 @@ function plan(options: CommonOptions): Record { async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); const manifest = renderManifest(gitea, target); const policy = policyChecks(gitea, target, manifest); if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy }; @@ -199,7 +210,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); const result = await capture(config, target.route, ["sh"], remoteScript("status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script); const parsed = parseJsonOutput(result.stdout); const summary = parsed === null ? null : statusSummary(parsed); @@ -217,7 +228,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); const result = await capture(config, target.route, ["sh"], remoteScript("validate", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script); const parsed = parseJsonOutput(result.stdout); return { @@ -292,7 +303,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom function mirrorPlan(options: CommonOptions): Record { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); return { ok: true, action: "platform-infra-gitea-mirror-plan", @@ -308,7 +319,7 @@ function mirrorPlan(options: CommonOptions): Record { async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { repoKey?: string | null }): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); const health = await validate(config, options); const healthValidation = record(health.validation); const credentials = credentialSummaries(gitea); @@ -344,7 +355,7 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" }; const repos = selectedRepositories(gitea, target, options.repoKey); const secrets = ensureMirrorSecrets(gitea, true, true); @@ -364,7 +375,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); if (gitea.sourceAuthority.webhookSync.enabled) { return { ok: false, @@ -395,7 +406,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); if (!gitea.sourceAuthority.webhookSync.enabled) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, error: "sourceAuthority.webhookSync.enabled is false" }; if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" }; const repos = selectedRepositories(gitea, target, options.repoKey); @@ -416,7 +427,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions) async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhookStatusOptions): Promise> { const gitea = readGiteaConfig(); - const target = resolveTarget(gitea, options.targetId); + const target = resolveCommandTarget(gitea, options.targetId); const repos = selectedRepositories(gitea, target, options.repoKey); const secrets = ensureMirrorSecrets(gitea, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script); diff --git a/scripts/src/ssh-help.test.ts b/scripts/src/ssh-help.test.ts index 2234358c..4b0a9cee 100644 --- a/scripts/src/ssh-help.test.ts +++ b/scripts/src/ssh-help.test.ts @@ -109,6 +109,43 @@ describe("ssh bounded progressive help", () => { expect(download).not.toContain("outputTruncated"); }); + test("returns the existing bounded JSON model only for explicit machine output", () => { + const top = JSON.parse(runTransHelp("--help", "--json")) as { + ok: boolean; + command: string; + data: { command: string; output: string; routeSyntax: Record; dump?: unknown }; + }; + expect(top.ok).toBe(true); + expect(top.command).toBe("trans --help --json"); + expect(top.data.command).toBe("trans --help"); + expect(top.data.output).toBe("json"); + expect(top.data.routeSyntax.general).toContain(" "); + expect(top.data.dump).toBeUndefined(); + + const prefixed = JSON.parse(runTransHelp("--json", "--help")) as typeof top; + expect(prefixed.ok).toBe(true); + expect(prefixed.data.command).toBe("trans --help"); + + const topOutput = JSON.parse(runTransHelp("--help", "-o", "json")) as typeof top; + expect(topOutput.ok).toBe(true); + expect(topOutput.data.output).toBe("json"); + + const scoped = JSON.parse(runTransHelp("--help", "download", "-o", "json")) as { + ok: boolean; + data: { command: string; output: string; scope: string; runtime: { repoRoot: string }; dump?: unknown }; + }; + expect(scoped.ok).toBe(true); + expect(scoped.data.command).toBe("trans --help download"); + expect(scoped.data.output).toBe("json"); + expect(scoped.data.scope).toBe("download"); + expect(scoped.data.runtime.repoRoot).toBe(repoRoot); + expect(scoped.data.dump).toBeUndefined(); + + const routeTop = JSON.parse(runTransHelp("NC01:k3s", "--help", "--json")) as typeof top; + expect(routeTop.ok).toBe(true); + expect(routeTop.data.command).toBe("trans --help"); + }); + test("returns compact no-stack input errors for unknown help scopes", () => { const result = spawnSync("bun", ["scripts/ssh-cli.ts", "--help", "unknown-operation"], { cwd: repoRoot, diff --git a/scripts/ssh-cli.ts b/scripts/ssh-cli.ts index 897b59ab..e3662bd0 100644 --- a/scripts/ssh-cli.ts +++ b/scripts/ssh-cli.ts @@ -1,6 +1,6 @@ import { readConfig } from "./src/config"; -import { isHelpToken, renderSshHelpText, sshHelpScope } from "./src/help"; -import { CliInputError, emitError, emitText } from "./src/output"; +import { isHelpToken, renderSshHelpText, sshHelp, sshHelpScope } from "./src/help"; +import { CliInputError, emitError, emitJson, emitText } from "./src/output"; import { extractRemoteCliOptions } from "./src/remote-options"; import { runRemoteSshCli } from "./src/remote-ssh"; import { runSsh } from "./src/ssh"; @@ -25,8 +25,70 @@ function isGhContentRouteTarget(target: string | undefined): boolean { return typeof target === "string" && target.startsWith("gh:"); } +interface SshHelpRequest { + args: string[]; + json: boolean; +} + +function parseSshHelpRequest(normalizedArgs: string[]): SshHelpRequest | null { + const helpArgs = [normalizedArgs[0]]; + let json = false; + let outputError: CliInputError | null = null; + for (let index = 1; index < normalizedArgs.length; index += 1) { + const arg = normalizedArgs[index]; + if (arg === "--json") { + json = true; + continue; + } + if (arg === "-o") { + const format = normalizedArgs[index + 1]; + if (format === undefined) { + outputError = new CliInputError("-o requires an output format", { + code: "missing-option-value", + argument: "-o", + usage: ["trans --help --json", "trans --help -o json"], + }); + } else if (format !== "json") { + outputError = new CliInputError(`Unsupported trans help output format: ${format}`, { + code: "unsupported-output-format", + argument: format, + supported: ["json"], + usage: ["trans --help --json", "trans --help -o json"], + }); + } else { + json = true; + } + index += format === undefined ? 0 : 1; + continue; + } + helpArgs.push(arg); + } + + const [, sub, third] = helpArgs; + const helpFirst = isHelpToken(sub); + const routeFirst = helpArgs.length === 3 && isHelpToken(third); + if (!helpFirst && !routeFirst) return null; + if (outputError !== null) throw outputError; + return { args: helpArgs, json }; +} + +function emitSshHelp(request: SshHelpRequest): void { + const [, sub, third] = request.args; + const scope = isHelpToken(sub) ? sshHelpScope(request.args) : undefined; + if (isHelpToken(sub) && request.args.length > 2 && scope === undefined) { + throw new CliInputError(`Unknown trans help scope: ${third ?? ""}`, { + code: "unknown-help-scope", + argument: third ?? "", + usage: ["trans --help", "trans --help "], + hint: "Use trans --help to list the bounded operation groups before opening scoped help.", + }); + } + if (request.json) emitJson(commandName, sshHelp(scope)); + else emitText(renderSshHelpText(scope), commandName); +} + async function main(): Promise { - const [top, sub, third] = args; + const [top, sub] = args; if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || ""}`); if (sub === undefined) { @@ -34,22 +96,9 @@ async function main(): Promise { return; } - if (isHelpToken(sub)) { - const scope = sshHelpScope(args); - if (args.length > 2 && scope === undefined) { - throw new CliInputError(`Unknown trans help scope: ${third ?? ""}`, { - code: "unknown-help-scope", - argument: third ?? "", - usage: ["trans --help", "trans --help "], - hint: "Use trans --help to list the bounded operation groups before opening scoped help.", - }); - } - emitText(renderSshHelpText(scope), commandName); - return; - } - - if (isHelpToken(third) && args.length === 3) { - emitText(renderSshHelpText(), commandName); + const helpRequest = parseSshHelpRequest(args); + if (helpRequest !== null) { + emitSshHelp(helpRequest); return; }