fix(cli): bound large gh output

This commit is contained in:
Codex
2026-05-23 10:48:24 +00:00
parent 152ded3a7b
commit 431d6bd25b
4 changed files with 158 additions and 4 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
@@ -218,6 +218,16 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
body: "# 普通任务\n\n## 背景\n\n- 不是指挥简报。\n",
html_url: "https://github.com/pikasTech/unidesk/issues/47",
};
const largeIssueBody = Array.from({ length: 900 }, (_, index) => `large-output-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(60)}`).join("\n");
const largeIssue = {
...issue,
id: 2090,
number: 90,
title: "large issue output fixture",
body: largeIssueBody,
html_url: "https://github.com/pikasTech/unidesk/issues/90",
updated_at: "2026-05-20T09:00:00Z",
};
const issueList = [
{
id: 2001,
@@ -459,6 +469,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, nonBriefIssue);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90") {
sendJson(res, 200, largeIssue);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60") {
sendJson(res, 200, legacyBoardIssue);
return;
@@ -471,6 +485,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, comments);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90/comments?per_page=100") {
sendJson(res, 200, []);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7/comments?per_page=100") {
sendJson(res, 200, [{ id: 7001, body: "shorthand comment", html_url: "https://github.com/pikasTech/HWLAB/issues/7#issuecomment-7001", user: { login: "tester" }, created_at: "2026-05-20T03:10:00Z", updated_at: "2026-05-20T03:10:00Z" }]);
return;
@@ -645,6 +663,19 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData);
assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData);
const largeRead = await runCli(["gh", "issue", "read", "90", "--repo", "pikasTech/unidesk", "--full"], env);
assertCondition(largeRead.status === 0, "large issue read should succeed", largeRead.json ?? { stdout: largeRead.stdout });
assertCondition(largeRead.stdout.length < 20_000, "large issue read stdout should stay bounded", { bytes: largeRead.stdout.length });
const largeReadData = dataOf(largeRead.json ?? {});
assertCondition(largeReadData.outputTruncated === true, "large issue read should be dumped instead of printed fully", largeReadData);
const dump = largeReadData.dump as JsonRecord;
assertCondition(typeof dump.path === "string" && existsSync(String(dump.path)), "large issue dump file should exist", dump);
assertCondition(Number(dump.bytes ?? 0) > 20_000, "dump should record full output size", dump);
assertCondition(Number(dump.lines ?? 0) > 20, "dump should record total line count", dump);
assertCondition(String(dump.head ?? "").length > 0 && String(dump.tail ?? "").length > 0, "dump should include head and tail previews", dump);
const dumpText = readFileSync(String(dump.path), "utf8");
assertCondition(dumpText.includes("large-output-line-0900"), "dump file should contain full original JSON", { path: dump.path, tail: dumpText.slice(-500) });
const scanEscape = await runCli(["gh", "issue", "scan-escape", "--repo", "pikasTech/unidesk", "--limit", "4", "--dry-run"], env);
assertCondition(scanEscape.status === 0, "issue scan-escape dry-run should succeed", scanEscape.json ?? { stdout: scanEscape.stdout });
const scanData = dataOf(scanEscape.json ?? {});
@@ -1465,6 +1496,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue list supports state/limit/json with stable selected fields",
"acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests",
"large gh issue read output is dumped to a temp file with bounded stdout and head/tail metadata",
"issue scan-escape classifies pollution, explanatory mentions, and body risks",
"issue cleanup-plan remains dry-run with body/comment cleanup suggestions",
"issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes",
+1
View File
@@ -5575,6 +5575,7 @@ export function ghHelp(): unknown {
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
"issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--raw and --full are explicit full-disclosure aliases for gh issue read/view and gh pr read/view. They request the full supported read/view JSON field set while keeping default command output structured JSON.",
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
"issue create accepts repeatable --label values and comma-separated labels; dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
+123 -2
View File
@@ -1,3 +1,8 @@
import { randomBytes } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export interface JsonEnvelope<T> {
ok: boolean;
command: string;
@@ -5,6 +10,10 @@ export interface JsonEnvelope<T> {
error?: unknown;
}
const GH_OUTPUT_DUMP_THRESHOLD_BYTES = 20 * 1024;
const OUTPUT_DUMP_PREVIEW_CHARS = 2000;
const OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output");
function isEpipe(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE";
}
@@ -21,7 +30,7 @@ process.stderr.on("error", (error) => {
export function emitJson<T>(command: string, data: T, ok = true): void {
const envelope: JsonEnvelope<T> = { ok, command, data };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
safeStdoutWrite(renderEnvelope(command, envelope));
}
export function emitError(command: string, error: unknown): void {
@@ -29,7 +38,119 @@ export function emitError(command: string, error: unknown): void {
? { name: error.name, message: error.message, stack: error.stack ?? null }
: { message: String(error) };
const envelope: JsonEnvelope<never> = { ok: false, command, error: payload };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
safeStdoutWrite(renderEnvelope(command, envelope));
}
function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
const fullText = `${JSON.stringify(envelope, null, 2)}\n`;
if (!shouldDumpLargeOutput(command, fullText)) return fullText;
const dump = dumpLargeOutput(command, fullText);
const compactPayload = {
outputTruncated: true,
reason: "stdout-json-exceeded-threshold",
message: "Full JSON output was written to a temporary file; stdout contains only bounded head/tail previews.",
dump,
summary: summarizeEnvelope(envelope),
};
const compactEnvelope: JsonEnvelope<Record<string, unknown>> = envelope.ok
? { ok: envelope.ok, command: envelope.command, data: compactPayload }
: { ok: envelope.ok, command: envelope.command, error: compactPayload };
return `${JSON.stringify(compactEnvelope, null, 2)}\n`;
}
function shouldDumpLargeOutput(command: string, text: string): boolean {
if (!(command === "gh" || command.startsWith("gh "))) return false;
if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1") return false;
const threshold = configuredDumpThresholdBytes();
return Buffer.byteLength(text, "utf8") > threshold;
}
function configuredDumpThresholdBytes(): number {
const raw = process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_THRESHOLD_BYTES;
if (raw === undefined || raw.trim().length === 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES;
return value;
}
function dumpLargeOutput(command: string, text: string): Record<string, unknown> {
mkdirSync(OUTPUT_DUMP_DIR, { recursive: true, mode: 0o700 });
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
const suffix = randomBytes(4).toString("hex");
const slug = command.replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "command";
const path = join(OUTPUT_DUMP_DIR, `${timestamp}-${process.pid}-${suffix}-${slug}.json`);
writeFileSync(path, text, { encoding: "utf8", mode: 0o600 });
return {
path,
thresholdBytes: configuredDumpThresholdBytes(),
bytes: Buffer.byteLength(text, "utf8"),
chars: text.length,
lines: countLines(text),
headChars: OUTPUT_DUMP_PREVIEW_CHARS,
tailChars: OUTPUT_DUMP_PREVIEW_CHARS,
head: text.slice(0, OUTPUT_DUMP_PREVIEW_CHARS),
tail: text.slice(Math.max(0, text.length - OUTPUT_DUMP_PREVIEW_CHARS)),
readCommands: {
full: `cat ${JSON.stringify(path)}`,
head: `sed -n '1,80p' ${JSON.stringify(path)}`,
tail: `tail -80 ${JSON.stringify(path)}`,
},
};
}
function countLines(text: string): number {
if (text.length === 0) return 0;
const lineBreaks = text.match(/\r\n|\r|\n/gu)?.length ?? 0;
return lineBreaks + (/(\r\n|\r|\n)$/u.test(text) ? 0 : 1);
}
function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unknown> {
const source = typeof envelope.data === "object" && envelope.data !== null
? envelope.data as Record<string, unknown>
: typeof envelope.error === "object" && envelope.error !== null
? envelope.error as Record<string, unknown>
: {};
const summaryKeys = [
"ok",
"command",
"repo",
"number",
"state",
"stateDetail",
"title",
"url",
"count",
"limit",
"degradedReason",
"runnerDisposition",
];
const summary: Record<string, unknown> = {
ok: envelope.ok,
command: envelope.command,
};
for (const key of summaryKeys) {
if (key in source) summary[key] = source[key];
}
const issue = source.issue;
if (typeof issue === "object" && issue !== null) {
const issueRecord = issue as Record<string, unknown>;
summary.issue = pickSummary(issueRecord, ["number", "title", "state", "url", "bodyChars", "commentCount"]);
}
const pullRequest = source.pullRequest;
if (typeof pullRequest === "object" && pullRequest !== null) {
const prRecord = pullRequest as Record<string, unknown>;
summary.pullRequest = pickSummary(prRecord, ["number", "title", "state", "stateDetail", "url", "draft", "merged", "mergedAt"]);
}
return summary;
}
function pickSummary(source: Record<string, unknown>, keys: string[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of keys) {
if (key in source) result[key] = source[key];
}
return result;
}
function safeStdoutWrite(text: string): void {