fix(cli): 补齐显式机器输出边界

This commit is contained in:
Codex
2026-07-12 02:17:05 +02:00
parent 80edc6c08b
commit bcc7807c3d
5 changed files with 181 additions and 38 deletions
+45 -2
View File
@@ -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);
+10 -7
View File
@@ -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<string, unknown> {
...(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"
+21 -10
View File
@@ -146,10 +146,21 @@ export function giteaHelp(scope?: string): Record<string, unknown> {
};
}
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 <command> --target <node>",
});
}
return resolveTarget(gitea, targetId);
}
function plan(options: CommonOptions): Record<string, unknown> {
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<string, unknown> {
async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
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<Reco
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
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<Re
async function validate(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
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<string, unknown> {
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<string, unknown> {
async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { repoKey?: string | null }): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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);
+37
View File
@@ -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<string, string>; 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("<route> <operation>");
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,
+68 -19
View File
@@ -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 ?? "<missing>"}`, {
code: "unknown-help-scope",
argument: third ?? "",
usage: ["trans --help", "trans --help <operation>"],
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<void> {
const [top, sub, third] = args;
const [top, sub] = args;
if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || "<empty>"}`);
if (sub === undefined) {
@@ -34,22 +96,9 @@ async function main(): Promise<void> {
return;
}
if (isHelpToken(sub)) {
const scope = sshHelpScope(args);
if (args.length > 2 && scope === undefined) {
throw new CliInputError(`Unknown trans help scope: ${third ?? "<missing>"}`, {
code: "unknown-help-scope",
argument: third ?? "",
usage: ["trans --help", "trans --help <operation>"],
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;
}