import { randomBytes } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; export interface JsonEnvelope { ok: boolean; command: string; data?: T; error?: unknown; } export interface RenderedCliResult { ok: boolean; command: string; renderedText: string; 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"); function isEpipe(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE"; } process.stdout.on("error", (error) => { if (isEpipe(error)) process.exit(0); throw error; }); process.stderr.on("error", (error) => { if (isEpipe(error)) process.exit(0); throw error; }); export function emitJson(command: string, data: T, ok = true): void { const envelope: JsonEnvelope = { ok, command, data }; safeStdoutWrite(renderEnvelope(command, envelope)); } export function isRenderedCliResult(value: unknown): value is RenderedCliResult { return typeof value === "object" && value !== null && typeof (value as { renderedText?: unknown }).renderedText === "string" && typeof (value as { command?: unknown }).command === "string" && typeof (value as { ok?: unknown }).ok === "boolean"; } export function emitText(text: string): void { safeStdoutWrite(text.endsWith("\n") ? text : `${text}\n`); } export function emitError(command: string, error: unknown): void { const payload = normalizeErrorPayload(command, error); const envelope: JsonEnvelope = { ok: false, command, error: payload }; safeStdoutWrite(renderEnvelope(command, envelope)); } function normalizeErrorPayload(command: string, error: unknown): Record { const message = error instanceof Error ? error.message : String(error); const parsed = parseStructuredErrorMessage(command, message); if (parsed !== null) return parsed; if (shouldSuppressStack(command) || shouldSuppressStack(commandPrefixFromMessage(message))) { return { message }; } if (error instanceof Error) { const debug = process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1"; return { name: error.name, message, ...(sshFileTransferErrorDetails(error) ?? {}), stack: error.stack ?? null, ...(debug ? { debug: true } : {}), }; } return { message }; } function sshFileTransferErrorDetails(error: Error): Record | null { if (error.name !== "SshFileTransferError") return null; const details = (error as Error & { details?: unknown }).details; if (typeof details !== "object" || details === null || Array.isArray(details)) return null; return { details }; } function parseStructuredErrorMessage(command: string, message: string): Record | null { if (!shouldSuppressStack(command) && !shouldSuppressStack(commandPrefixFromMessage(message))) return null; const jsonStart = message.indexOf("{"); if (jsonStart === -1) return null; try { const parsed = JSON.parse(message.slice(jsonStart)) as unknown; if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { const record = parsed as Record; return { message: message.slice(0, jsonStart).trim().replace(/[;:]$/u, ""), ...record }; } } catch { return null; } return null; } function commandPrefixFromMessage(message: string): string { return message.split(/\s+/u).slice(0, 2).join(" "); } function shouldSuppressStack(prefix: string): boolean { if (process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1") return false; return prefix === "codex tasks" || prefix.startsWith("codex tasks ") || prefix === "codex unread" || prefix.startsWith("codex unread "); } 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 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> = 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, 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(); return Buffer.byteLength(text, "utf8") > threshold; } function isLargeOutputDumpCommand(command: string): boolean { return command === "gh" || command.startsWith("gh ") || command === "agentrun" || command.startsWith("agentrun "); } 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 }); 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): Record { const source = typeof envelope.data === "object" && envelope.data !== null ? envelope.data as Record : typeof envelope.error === "object" && envelope.error !== null ? envelope.error as Record : {}; const summaryKeys = [ "ok", "command", "repo", "number", "state", "stateDetail", "title", "url", "count", "limit", "degradedReason", "runnerDisposition", ]; const summary: Record = { 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; 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; summary.pullRequest = pickSummary(prRecord, ["number", "title", "state", "stateDetail", "url", "draft", "merged", "mergedAt"]); } return summary; } function pickSummary(source: Record, keys: string[]): Record { const result: Record = {}; for (const key of keys) { if (key in source) result[key] = source[key]; } return result; } function safeStdoutWrite(text: string): void { try { process.stdout.write(text); } catch (error) { if (isEpipe(error)) process.exit(0); throw error; } }