fix(cli): bound large gh output
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user