Merge pull request #607 from pikasTech/fix/cli-output-dump-yaml-20260621
fix: 按 YAML 策略 dump 超大 CLI 输出
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
version: 1
|
||||
kind: unidesk-cli
|
||||
output:
|
||||
maxStdoutBytes: 10240
|
||||
dumpDir: /tmp/unidesk-cli-output
|
||||
includePreview: false
|
||||
warning: "CLI stdout exceeded YAML-configured limit; full output was dumped to /tmp. Improve this command to use concise tables and progressive disclosure."
|
||||
+3
-3
@@ -242,7 +242,7 @@ async function main(): Promise<void> {
|
||||
const result = await runAgentRunCommand(config, agentRunArgs);
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText);
|
||||
emitText(result.renderedText, result.command || commandName);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -341,7 +341,7 @@ async function main(): Promise<void> {
|
||||
const result = await runPlatformInfraCommand(readConfig(), args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText);
|
||||
emitText(result.renderedText, result.command || commandName);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -525,7 +525,7 @@ async function main(): Promise<void> {
|
||||
const result = await runCodeQueueCommand(config, args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText);
|
||||
emitText(result.renderedText, result.command || commandName);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -7741,7 +7741,7 @@ export function ghHelp(): unknown {
|
||||
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; full body is included only when requested with --json body, --full, or --raw, and unsupported fields fail structurally.",
|
||||
"issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
|
||||
"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.",
|
||||
"CLI output larger than config/unidesk-cli.yaml output.maxStdoutBytes is automatically written to /tmp/unidesk-cli-output; stdout stays bounded with outputTruncated=true, warning text, dump file metadata, and drill-down read commands.",
|
||||
"issue create accepts --body-stdin or --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
|
||||
"--body-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin 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.",
|
||||
|
||||
+172
-37
@@ -1,7 +1,8 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { rootPath } from "./config";
|
||||
|
||||
export interface JsonEnvelope<T> {
|
||||
ok: boolean;
|
||||
@@ -17,9 +18,21 @@ export interface RenderedCliResult {
|
||||
contentType: "text/plain" | "application/json" | "application/yaml";
|
||||
}
|
||||
|
||||
const GH_OUTPUT_DUMP_THRESHOLD_BYTES = 20 * 1024;
|
||||
const OUTPUT_DUMP_PREVIEW_CHARS = 2000;
|
||||
const OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output");
|
||||
const CLI_OUTPUT_CONFIG_RELATIVE_PATH = "config/unidesk-cli.yaml";
|
||||
const CLI_OUTPUT_CONFIG_PATH = rootPath("config", "unidesk-cli.yaml");
|
||||
const EMERGENCY_OUTPUT_DUMP_THRESHOLD_BYTES = 10 * 1024;
|
||||
const EMERGENCY_OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output");
|
||||
|
||||
interface CliOutputPolicy {
|
||||
maxStdoutBytes: number;
|
||||
dumpDir: string;
|
||||
includePreview: boolean;
|
||||
warning: string;
|
||||
configPath: string;
|
||||
configError?: string;
|
||||
}
|
||||
|
||||
let cachedOutputPolicy: CliOutputPolicy | null = null;
|
||||
|
||||
function isEpipe(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE";
|
||||
@@ -48,8 +61,8 @@ export function isRenderedCliResult(value: unknown): value is RenderedCliResult
|
||||
&& typeof (value as { ok?: unknown }).ok === "boolean";
|
||||
}
|
||||
|
||||
export function emitText(text: string): void {
|
||||
safeStdoutWrite(text.endsWith("\n") ? text : `${text}\n`);
|
||||
export function emitText(text: string, command = "text"): void {
|
||||
safeStdoutWrite(renderTextOutput(command, text));
|
||||
}
|
||||
|
||||
export function emitError(command: string, error: unknown): void {
|
||||
@@ -130,11 +143,14 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
const fullText = `${JSON.stringify(envelope, null, 2)}\n`;
|
||||
if (!shouldDumpLargeOutput(command, fullText, envelope)) return fullText;
|
||||
|
||||
const dump = dumpLargeOutput(command, fullText);
|
||||
const policy = cliOutputPolicy();
|
||||
const dump = dumpLargeOutput(command, fullText, "json", policy);
|
||||
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.",
|
||||
warning: policy.warning,
|
||||
message: "Full JSON output was written to a temporary file; stdout contains only file metadata and a compact summary.",
|
||||
disclosurePolicy: disclosurePolicy(policy),
|
||||
dump,
|
||||
summary: summarizeEnvelope(envelope),
|
||||
};
|
||||
@@ -145,53 +161,67 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
}
|
||||
|
||||
function shouldDumpLargeOutput(command: string, text: string, envelope: JsonEnvelope<unknown>): boolean {
|
||||
if (!isLargeOutputDumpCommand(command)) return false;
|
||||
if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1" || process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return false;
|
||||
if (typeof envelope.data === "object" && envelope.data !== null && (envelope.data as { noDump?: unknown }).noDump === true) return false;
|
||||
const threshold = configuredDumpThresholdBytes();
|
||||
void command;
|
||||
void envelope;
|
||||
if (process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return false;
|
||||
const threshold = cliOutputPolicy().maxStdoutBytes;
|
||||
return Buffer.byteLength(text, "utf8") > threshold;
|
||||
}
|
||||
|
||||
function isLargeOutputDumpCommand(command: string): boolean {
|
||||
return command === "gh" || command.startsWith("gh ") || command === "agentrun" || command.startsWith("agentrun ");
|
||||
function renderTextOutput(command: string, text: string): string {
|
||||
const fullText = text.endsWith("\n") ? text : `${text}\n`;
|
||||
if (process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return fullText;
|
||||
const policy = cliOutputPolicy();
|
||||
if (Buffer.byteLength(fullText, "utf8") <= policy.maxStdoutBytes) return fullText;
|
||||
const dump = dumpLargeOutput(command, fullText, "txt", policy);
|
||||
const payload: JsonEnvelope<Record<string, unknown>> = {
|
||||
ok: true,
|
||||
command,
|
||||
data: {
|
||||
outputTruncated: true,
|
||||
reason: "stdout-text-exceeded-threshold",
|
||||
warning: policy.warning,
|
||||
message: "Full text output was written to a temporary file; stdout contains only file metadata.",
|
||||
disclosurePolicy: disclosurePolicy(policy),
|
||||
dump,
|
||||
},
|
||||
};
|
||||
return `${JSON.stringify(payload, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function configuredDumpThresholdBytes(): number {
|
||||
const genericRaw = process.env.UNIDESK_CLI_OUTPUT_DUMP_THRESHOLD_BYTES;
|
||||
if (genericRaw !== undefined && genericRaw.trim().length > 0) {
|
||||
const genericValue = Number(genericRaw);
|
||||
if (Number.isInteger(genericValue) && genericValue > 0) return genericValue;
|
||||
}
|
||||
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 });
|
||||
function dumpLargeOutput(command: string, text: string, extension: "json" | "txt", policy: CliOutputPolicy): Record<string, unknown> {
|
||||
mkdirSync(policy.dumpDir, { 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`);
|
||||
const path = join(policy.dumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${extension}`);
|
||||
writeFileSync(path, text, { encoding: "utf8", mode: 0o600 });
|
||||
return {
|
||||
const dump: Record<string, unknown> = {
|
||||
path,
|
||||
thresholdBytes: configuredDumpThresholdBytes(),
|
||||
configPath: policy.configPath,
|
||||
thresholdBytes: policy.maxStdoutBytes,
|
||||
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)),
|
||||
sha256: createHash("sha256").update(text).digest("hex"),
|
||||
contentType: extension === "json" ? "application/json" : "text/plain",
|
||||
readCommands: {
|
||||
full: `cat ${JSON.stringify(path)}`,
|
||||
head: `sed -n '1,80p' ${JSON.stringify(path)}`,
|
||||
tail: `tail -80 ${JSON.stringify(path)}`,
|
||||
},
|
||||
};
|
||||
if (policy.configError !== undefined) dump.configWarning = policy.configError;
|
||||
if (policy.includePreview) {
|
||||
const previewChars = Math.min(1200, Math.max(200, Math.floor(policy.maxStdoutBytes / 8)));
|
||||
dump.preview = {
|
||||
headChars: previewChars,
|
||||
tailChars: previewChars,
|
||||
head: text.slice(0, previewChars),
|
||||
tail: text.slice(Math.max(0, text.length - previewChars)),
|
||||
};
|
||||
}
|
||||
return dump;
|
||||
}
|
||||
|
||||
function countLines(text: string): number {
|
||||
@@ -219,6 +249,17 @@ function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unkn
|
||||
"limit",
|
||||
"degradedReason",
|
||||
"runnerDisposition",
|
||||
"target",
|
||||
"path",
|
||||
"status",
|
||||
"statusCode",
|
||||
"lookbackMinutes",
|
||||
"candidateTraceCount",
|
||||
"scannedTraceCount",
|
||||
"matchedTraceCount",
|
||||
"traceCount",
|
||||
"errorSpanCount",
|
||||
"serviceCount",
|
||||
];
|
||||
const summary: Record<string, unknown> = {
|
||||
ok: envelope.ok,
|
||||
@@ -227,6 +268,9 @@ function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unkn
|
||||
for (const key of summaryKeys) {
|
||||
if (key in source) summary[key] = source[key];
|
||||
}
|
||||
summary.topLevelKeys = Object.keys(source).slice(0, 40);
|
||||
const arrayCounts = summarizeArrayCounts(source);
|
||||
if (Object.keys(arrayCounts).length > 0) summary.arrayCounts = arrayCounts;
|
||||
const issue = source.issue;
|
||||
if (typeof issue === "object" && issue !== null) {
|
||||
const issueRecord = issue as Record<string, unknown>;
|
||||
@@ -240,6 +284,25 @@ function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unkn
|
||||
return summary;
|
||||
}
|
||||
|
||||
function disclosurePolicy(policy: CliOutputPolicy): Record<string, unknown> {
|
||||
return {
|
||||
configPath: policy.configPath,
|
||||
maxStdoutBytes: policy.maxStdoutBytes,
|
||||
dumpDir: policy.dumpDir,
|
||||
includePreview: policy.includePreview,
|
||||
recommendation: "Prefer k8s-style concise summaries/tables by default; expose full data through explicit --full/--raw/id-specific drill-down commands instead of large stdout.",
|
||||
...(policy.configError === undefined ? {} : { configWarning: policy.configError }),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeArrayCounts(source: Record<string, unknown>): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (Array.isArray(value)) result[key] = value.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickSummary(source: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
@@ -256,3 +319,75 @@ function safeStdoutWrite(text: string): void {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function cliOutputPolicy(): CliOutputPolicy {
|
||||
if (cachedOutputPolicy !== null) return cachedOutputPolicy;
|
||||
try {
|
||||
const raw = readFileSync(CLI_OUTPUT_CONFIG_PATH, "utf8");
|
||||
const parsed = Bun.YAML.parse(raw) as unknown;
|
||||
const root = asRecord(parsed, CLI_OUTPUT_CONFIG_RELATIVE_PATH);
|
||||
const version = integerField(root, "version", CLI_OUTPUT_CONFIG_RELATIVE_PATH);
|
||||
if (version !== 1) throw new Error(`${CLI_OUTPUT_CONFIG_RELATIVE_PATH}.version must be 1`);
|
||||
const kind = stringField(root, "kind", CLI_OUTPUT_CONFIG_RELATIVE_PATH);
|
||||
if (kind !== "unidesk-cli") throw new Error(`${CLI_OUTPUT_CONFIG_RELATIVE_PATH}.kind must be unidesk-cli`);
|
||||
const output = objectField(root, "output", CLI_OUTPUT_CONFIG_RELATIVE_PATH);
|
||||
cachedOutputPolicy = {
|
||||
maxStdoutBytes: positiveIntegerField(output, "maxStdoutBytes", `${CLI_OUTPUT_CONFIG_RELATIVE_PATH}.output`),
|
||||
dumpDir: absolutePathField(output, "dumpDir", `${CLI_OUTPUT_CONFIG_RELATIVE_PATH}.output`),
|
||||
includePreview: booleanField(output, "includePreview", `${CLI_OUTPUT_CONFIG_RELATIVE_PATH}.output`),
|
||||
warning: stringField(output, "warning", `${CLI_OUTPUT_CONFIG_RELATIVE_PATH}.output`),
|
||||
configPath: CLI_OUTPUT_CONFIG_RELATIVE_PATH,
|
||||
};
|
||||
return cachedOutputPolicy;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cachedOutputPolicy = {
|
||||
maxStdoutBytes: EMERGENCY_OUTPUT_DUMP_THRESHOLD_BYTES,
|
||||
dumpDir: EMERGENCY_OUTPUT_DUMP_DIR,
|
||||
includePreview: false,
|
||||
warning: "CLI output policy YAML could not be loaded; emergency dump guard is active and this YAML configuration must be fixed.",
|
||||
configPath: CLI_OUTPUT_CONFIG_RELATIVE_PATH,
|
||||
configError: message,
|
||||
};
|
||||
return cachedOutputPolicy;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
||||
return asRecord(obj[key], `${path}.${key}`);
|
||||
}
|
||||
|
||||
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveIntegerField(obj: Record<string, unknown>, key: string, path: string): number {
|
||||
const value = integerField(obj, key, path);
|
||||
if (value <= 0) throw new Error(`${path}.${key} must be positive`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function absolutePathField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (!value.startsWith("/")) throw new Error(`${path}.${key} must be absolute`);
|
||||
return value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user