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
+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 {