fix(cli): 补齐显式机器输出边界
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user