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; command: string; data?: T; error?: unknown; } export interface RenderedCliResult { ok: boolean; command: string; renderedText: string; contentType: "text/plain" | "application/json" | "application/yaml"; } 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"); export 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"; } 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, command = "text"): void { safeStdoutWrite(renderTextOutput(command, text)); } 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)); } export function readCliOutputPolicy(): CliOutputPolicy { return cliOutputPolicy(); } 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 structured = structuredCliErrorPayload(error, message); if (structured !== null) return structured; 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 structuredCliErrorPayload(error: Error, message: string): Record | null { const record = error as Error & Record; if (!Object.prototype.hasOwnProperty.call(record, "replacementExamples") && !Object.prototype.hasOwnProperty.call(record, "migrationHint")) return null; const payload: Record = { name: error.name, message, }; for (const key of ["code", "level", "entrypoint", "route", "operation", "replacementExamples", "migrationHint", "note"]) { const value = record[key]; if (value !== undefined) payload[key] = value; } return payload; } 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`; const policy = cliOutputPolicy(); const trigger = outputDumpTrigger(fullText, policy, "json"); if (trigger === null) return fullText; const dump = dumpLargeOutput(command, fullText, "json", policy); const compactPayload = { outputTruncated: true, reason: trigger.reason, 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), trigger, 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 outputDumpTrigger(text: string, policy: CliOutputPolicy, content: "json" | "text"): Record | null { if (process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return null; const bytes = Buffer.byteLength(text, "utf8"); if (bytes > policy.maxStdoutBytes) { return { reason: `stdout-${content}-bytes-exceeded-threshold`, thresholdBytes: policy.maxStdoutBytes, observedBytes: bytes, }; } return null; } function renderTextOutput(command: string, text: string): string { const fullText = text.endsWith("\n") ? text : `${text}\n`; const policy = cliOutputPolicy(); const trigger = outputDumpTrigger(fullText, policy, "text"); if (trigger === null) return fullText; const dump = dumpLargeOutput(command, fullText, "txt", policy); const payload: JsonEnvelope> = { ok: true, command, data: { outputTruncated: true, reason: trigger.reason, warning: policy.warning, message: "Full text output was written to a temporary file; stdout contains only file metadata.", disclosurePolicy: disclosurePolicy(policy), trigger, dump, }, }; return `${JSON.stringify(payload, null, 2)}\n`; } 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(policy.dumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${extension}`); writeFileSync(path, text, { encoding: "utf8", mode: 0o600 }); const dump: Record = { path, configPath: policy.configPath, thresholdBytes: policy.maxStdoutBytes, bytes: Buffer.byteLength(text, "utf8"), chars: text.length, lines: countLines(text), 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 { 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", "target", "path", "status", "statusCode", "lookbackMinutes", "candidateTraceCount", "scannedTraceCount", "matchedTraceCount", "traceCount", "errorSpanCount", "serviceCount", ]; const summary: Record = { ok: envelope.ok, command: envelope.command, }; 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 debugDispatch = summarizeDebugDispatch(source); if (debugDispatch !== null) summary.debugDispatch = debugDispatch; const providerTriage = summarizeProviderTriage(source); if (providerTriage !== null) summary.providerTriage = providerTriage; 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 disclosurePolicy(policy: CliOutputPolicy): 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 summarizeDebugDispatch(source: Record): Record | null { const dispatch = recordOrNull(source.dispatch); const wait = recordOrNull(source.wait); if (dispatch === null && wait === null) return null; const dispatchBody = recordOrNull(dispatch?.body); const waitTask = recordOrNull(wait?.task); const waitPayload = recordOrNull(waitTask?.payload); const waitResult = recordOrNull(waitTask?.result); const result: Record = { dispatch: dispatch === null ? null : { ...pickSummary(dispatch, ["ok", "status", "responseBytesRead", "responseContentLength", "responseTruncated"]), body: dispatchBody === null ? null : pickSummary(dispatchBody, ["ok", "providerOnline", "status", "taskId"]), }, wait: wait === null ? null : pickSummary(wait, ["ok", "timeoutMs"]), task: waitTask === null ? null : { ...pickSummary(waitTask, ["id", "status", "command", "providerId", "createdAt", "updatedAt"]), payload: waitPayload === null ? null : pickSummary(waitPayload, ["mode", "source"]), }, }; const providerUpgrade = summarizeProviderUpgradeResult(waitResult); if (providerUpgrade !== null) result.providerUpgrade = providerUpgrade; return result; } function summarizeProviderUpgradeResult(result: Record | null): Record | null { if (result === null) return null; const schedulerResult = recordOrNull(result.schedulerResult); const plan = recordOrNull(result.plan) ?? recordOrNull(schedulerResult?.plan); const finalPromotedContainer = recordOrNull(result.finalPromotedContainer) ?? recordOrNull(schedulerResult?.finalPromotedContainer) ?? recordOrNull(plan?.finalPromotedContainer); const heartbeat = recordOrNull(result.heartbeat); const heartbeatConfirmation = recordOrNull(plan?.finalPromotedHeartbeatConfirmation) ?? recordOrNull(schedulerResult?.finalPromotedHeartbeatConfirmation); const summary: Record = { ...pickSummary(result, ["ok", "reason", "taskId", "providerId", "targetProviderGatewayVersion", "candidateValidationWasTerminal"]), }; const mode = result.mode ?? schedulerResult?.mode; if (mode !== undefined) summary.mode = mode; const message = stringOrNull(result.message) ?? stringOrNull(schedulerResult?.message); if (message !== null) summary.message = shortText(message, 240); const providerGatewayVersion = result.providerGatewayVersion ?? schedulerResult?.providerGatewayVersion ?? plan?.providerGatewayVersion; if (providerGatewayVersion !== undefined) summary.providerGatewayVersion = providerGatewayVersion; if (schedulerResult !== null) { summary.schedulerResult = { ...pickSummary(schedulerResult, [ "mode", "message", "providerId", "providerName", "providerGatewayVersion", "targetProviderGatewayVersion", "updaterContainerId", "candidateValidationIsNotTerminalSuccess", "terminalStatusManagedByBackendCore", ]), }; } if (plan !== null) { summary.plan = { ...pickSummary(plan, [ "taskId", "providerId", "providerName", "policy", "hostProjectRoot", "workspace", "workspaceStateMount", "providerGatewayName", "providerGatewayVersion", "targetProviderGatewayName", "targetProviderGatewayVersion", "runnerImage", "updaterName", "candidateName", ]), compose: pickSummary(recordOrNull(plan.compose) ?? {}, ["composeFile", "envFile", "project", "service"]), heartbeatConfirmation: heartbeatConfirmation === null ? null : pickSummary(heartbeatConfirmation, [ "providerId", "required", "heartbeatTimeoutMs", "targetProviderGatewayVersion", "finalContainerMustNotEqualCandidate", ]), }; } if (finalPromotedContainer !== null) { summary.finalPromotedContainer = pickSummary(finalPromotedContainer, [ "containerId", "containerName", "version", "restartPolicy", "pidMode", "heartbeatTimestamp", "containerIdUnavailableReason", "versionUnavailableReason", "restartPolicyUnavailableReason", "pidModeUnavailableReason", "heartbeatTimestampUnavailableReason", ]); } if (heartbeat !== null) { const heartbeatSummary = pickSummary(heartbeat, ["providerId", "version", "containerId", "containerName", "restartPolicy", "pidMode", "timestamp"]); if (Object.keys(heartbeatSummary).length > 0) summary.heartbeat = heartbeatSummary; } return summary; } function summarizeProviderTriage(source: Record): Record | null { if (!("decision" in source) || !("providerId" in source) || !Array.isArray(source.signals)) return null; const signals = source.signals.map(recordOrNull).filter((item): item is Record => item !== null); const issueSignals = signals .filter((item) => item.status === "failed" || item.status === "degraded" || item.status === "unknown") .sort((left, right) => signalStatusRank(left.status) - signalStatusRank(right.status)); const previewSignals = (issueSignals.length > 0 ? issueSignals : signals).slice(0, 8); return { ...pickSummary(source, ["ok", "providerId", "decision", "scope", "retryable", "blockingDisposition", "observedAt"]), failedScopes: boundedStringArray(source.failedScopes, 12), degradedScopes: boundedStringArray(source.degradedScopes, 12), healthyScopes: boundedStringArray(source.healthyScopes, 12), failedIndependentScopes: boundedStringArray(source.failedIndependentScopes, 12), healthyIndependentScopes: boundedStringArray(source.healthyIndependentScopes, 12), signalCounts: providerSignalCounts(signals, previewSignals.length), recommendedCrossChecks: boundedStringArray(source.recommendedCrossChecks, 8), rationale: boundedStringArray(source.rationale, 8), signalsPreview: previewSignals.map((item) => ({ ...pickSummary(item, ["id", "scope", "status", "independentPath", "observedAt"]), summary: shortText(stringOrNull(item.summary) ?? "", 220), })), }; } function providerSignalCounts(signals: Record[], returned: number): Record { const counts = { total: signals.length, returned, ok: 0, degraded: 0, failed: 0, unknown: 0, }; for (const signal of signals) { if (signal.status === "ok") counts.ok += 1; else if (signal.status === "degraded") counts.degraded += 1; else if (signal.status === "failed") counts.failed += 1; else counts.unknown += 1; } return { ...counts, omittedSignals: Math.max(0, signals.length - returned), }; } function signalStatusRank(status: unknown): number { if (status === "failed") return 0; if (status === "degraded") return 1; if (status === "unknown") return 2; if (status === "ok") return 3; return 4; } function boundedStringArray(value: unknown, limit: number): Record { const items = Array.from(new Set((Array.isArray(value) ? value : []).map((item) => String(item ?? "")).filter(Boolean))); return { items: items.slice(0, limit), count: items.length, truncated: items.length > limit, omitted: Math.max(0, items.length - limit), }; } function recordOrNull(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function stringOrNull(value: unknown): string | null { return typeof value === "string" ? value : null; } function shortText(value: string, maxChars: number): string { return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 3))}...`; } 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; } } 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 for one-off drill-down only. Fix config/unidesk-cli.yaml, then improve the noisy command itself to print concise tables/summaries and id-specific progressive disclosure instead of repeatedly depending on dump extraction.", 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; }