diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml new file mode 100644 index 00000000..03a37c59 --- /dev/null +++ b/config/unidesk-cli.yaml @@ -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." diff --git a/scripts/cli.ts b/scripts/cli.ts index 2351d367..dfaa46cf 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -242,7 +242,7 @@ async function main(): Promise { 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 { 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 { 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; } diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 4b83abb9..ff6d3c53 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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 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.", diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 4d906187..a769f5c6 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -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 { 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(command: string, envelope: JsonEnvelope): 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(command: string, envelope: JsonEnvelope): string { } function shouldDumpLargeOutput(command: string, text: string, envelope: JsonEnvelope): 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> = { + 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 { - mkdirSync(OUTPUT_DUMP_DIR, { recursive: true, mode: 0o700 }); +function dumpLargeOutput(command: string, text: string, extension: "json" | "txt", policy: CliOutputPolicy): Record { + 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 = { 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): Record = { ok: envelope.ok, @@ -227,6 +268,9 @@ function summarizeEnvelope(envelope: JsonEnvelope): Record 0) summary.arrayCounts = arrayCounts; const issue = source.issue; if (typeof issue === "object" && issue !== null) { const issueRecord = issue as Record; @@ -240,6 +284,25 @@ function summarizeEnvelope(envelope: JsonEnvelope): Record { + 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): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (Array.isArray(value)) result[key] = value.length; + } + return result; +} + function pickSummary(source: Record, keys: string[]): Record { const result: Record = {}; 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function objectField(obj: Record, key: string, path: string): Record { + return asRecord(obj[key], `${path}.${key}`); +} + +function stringField(obj: Record, 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, 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, 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, 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, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!value.startsWith("/")) throw new Error(`${path}.${key} must be absolute`); + return value; +}