import { existsSync, readFileSync } from "node:fs"; export type CliChildJsonStdoutKind = "json" | "empty" | "dump-wrapper" | "ssh-stdout-truncated" | "ssh-truncation-summary" | "non-json"; export type CliChildJsonSource = "stdout" | "dump" | "artifact" | null; export interface CliChildJsonArtifactFallback { path: string | null; nextCommand: string | null; read: () => CliChildJsonArtifactReadResult; } export type CliChildJsonArtifactReadResult = | { ok: true; value: Record; path?: string | null; reason?: string | null } | { ok: false; reason: string; path?: string | null; error?: string | null }; export interface CliChildJsonResolution { parsed: Record | null; source: CliChildJsonSource; diagnostics: Record; } export function resolveCliChildJsonObject(options: { stdout: string; stderr?: string; requestedStdoutType: string; acceptParsed?: (value: Record) => boolean; artifactFallback?: CliChildJsonArtifactFallback | null; forceArtifactFallbackReason?: string | null; }): CliChildJsonResolution { const stderr = options.stderr ?? ""; const parsedStdout = parseStdoutCandidate(options.stdout, stderr); const accepted = parsedStdout.parsed !== null && (options.acceptParsed?.(parsedStdout.parsed) ?? true); const contractFallbackReason = parsedStdout.parsed !== null && !accepted ? "stdout-json-contract-invalid" : null; const fallbackReason = options.forceArtifactFallbackReason ?? contractFallbackReason ?? (parsedStdout.parsed === null ? fallbackReasonForStdout(parsedStdout.stdoutKind) : null); let parsed = parsedStdout.parsed; let source: CliChildJsonSource = parsedStdout.source; let artifactDiagnostics: Record | null = null; if (fallbackReason !== null && options.artifactFallback) { const artifact = options.artifactFallback.read(); artifactDiagnostics = { path: artifact.path ?? options.artifactFallback.path, requestedPath: options.artifactFallback.path, nextCommand: options.artifactFallback.nextCommand, ok: artifact.ok, reason: artifact.ok ? artifact.reason ?? fallbackReason : artifact.reason, error: artifact.ok ? null : artifact.error ?? null, valuesRedacted: true, }; if (artifact.ok) { parsed = artifact.value; source = "artifact"; } } return { parsed, source, diagnostics: { requestedStdoutType: options.requestedStdoutType, stdoutKind: parsedStdout.stdoutKind, source, fallbackReason, parsedFromStdout: parsedStdout.source === "stdout", parsedFromDump: parsedStdout.source === "dump", dumpPath: parsedStdout.dumpPath, dumpReason: parsedStdout.dumpReason, dumpReadOk: parsedStdout.dumpReadOk, dumpJsonOk: parsedStdout.dumpJsonOk, sshTruncation: parsedStdout.sshTruncation, artifact: artifactDiagnostics, valuesRedacted: true, }, }; } function parseStdoutCandidate(stdout: string, stderr: string): { parsed: Record | null; source: CliChildJsonSource; stdoutKind: CliChildJsonStdoutKind; dumpPath: string | null; dumpReason: string | null; dumpReadOk: boolean | null; dumpJsonOk: boolean | null; sshTruncation: Record | null; } { const trimmed = stdout.trim(); const sshTruncation = parseSshTruncation(stdout, stderr); if (trimmed.length === 0) { return emptyCandidate("empty", sshTruncation); } if (trimmed.startsWith("UNIDESK_SSH_STDOUT_TRUNCATED ")) { const dumpPath = stringValue(parseMarkerJson(trimmed, "UNIDESK_SSH_STDOUT_TRUNCATED "), "dumpPath"); return dumpPath ? parseDumpCandidate("ssh-stdout-truncated", dumpPath, "ssh-stdout-truncated", sshTruncation) : emptyCandidate("ssh-stdout-truncated", sshTruncation); } if (trimmed.startsWith("UNIDESK_SSH_TRUNCATION_SUMMARY ")) { const dumpPath = stdoutDumpPathFromSshSummary(parseMarkerJson(trimmed, "UNIDESK_SSH_TRUNCATION_SUMMARY ")); return dumpPath ? parseDumpCandidate("ssh-truncation-summary", dumpPath, "ssh-truncation-summary", sshTruncation) : emptyCandidate("ssh-truncation-summary", sshTruncation); } const parsed = parseJsonObject(trimmed); if (parsed !== null) { const dumpPayload = cliDumpPayload(parsed); if (dumpPayload !== null) { return parseDumpCandidate("dump-wrapper", dumpPayload.path, dumpPayload.reason, sshTruncation); } return { parsed, source: "stdout", stdoutKind: "json", dumpPath: null, dumpReason: null, dumpReadOk: null, dumpJsonOk: null, sshTruncation, }; } const summaryDumpPath = stdoutDumpPathFromSshSummary(sshTruncation); if (summaryDumpPath !== null) { return parseDumpCandidate("ssh-truncation-summary", summaryDumpPath, "ssh-truncation-summary", sshTruncation); } return emptyCandidate("non-json", sshTruncation); } function parseDumpCandidate(stdoutKind: CliChildJsonStdoutKind, dumpPath: string, reason: string | null, sshTruncation: Record | null): { parsed: Record | null; source: CliChildJsonSource; stdoutKind: CliChildJsonStdoutKind; dumpPath: string | null; dumpReason: string | null; dumpReadOk: boolean | null; dumpJsonOk: boolean | null; sshTruncation: Record | null; } { if (!existsSync(dumpPath)) { return { ...emptyCandidate(stdoutKind, sshTruncation), dumpPath, dumpReason: reason, dumpReadOk: false, dumpJsonOk: null, }; } const parsed = parseJsonObject(readFileSync(dumpPath, "utf8")); return { parsed, source: parsed === null ? null : "dump", stdoutKind, dumpPath, dumpReason: reason, dumpReadOk: true, dumpJsonOk: parsed !== null, sshTruncation, }; } function emptyCandidate(stdoutKind: CliChildJsonStdoutKind, sshTruncation: Record | null) { return { parsed: null, source: null, stdoutKind, dumpPath: null, dumpReason: null, dumpReadOk: null, dumpJsonOk: null, sshTruncation, }; } function fallbackReasonForStdout(kind: CliChildJsonStdoutKind): string { if (kind === "empty") return "stdout-empty"; if (kind === "dump-wrapper") return "stdout-dump-wrapper-unreadable"; if (kind === "ssh-stdout-truncated") return "stdout-ssh-truncated"; if (kind === "ssh-truncation-summary") return "stdout-trans-truncated"; return "stdout-not-json"; } function cliDumpPayload(parsed: Record): { path: string; reason: string | null } | null { for (const candidate of [parsed, record(parsed.data), record(parsed.error)]) { if (candidate.outputTruncated !== true) continue; const path = stringValue(record(candidate.dump), "path"); if (path !== null) return { path, reason: stringValue(candidate, "reason") }; } return null; } function parseSshTruncation(stdout: string, stderr: string): Record | null { const text = `${stdout}\n${stderr}`; const markers = ["UNIDESK_SSH_TRUNCATION_SUMMARY ", "UNIDESK_SSH_STDOUT_TRUNCATED "]; for (const marker of markers) { const lines = text.split(/\r?\n/u).filter((line) => line.trim().startsWith(marker)); const last = lines.at(-1)?.trim(); const parsed = last ? parseMarkerJson(last, marker) : null; if (parsed !== null) return parsed; } return null; } function parseMarkerJson(line: string, marker: string): Record | null { if (!line.startsWith(marker)) return null; return parseJsonObject(line.slice(marker.length)); } function stdoutDumpPathFromSshSummary(value: Record | null): string | null { const root = record(value); const stdout = record(root.stdout); return stringValue(stdout, "dumpPath") ?? stringValue(root, "dumpPath"); } function parseJsonObject(text: string): Record | null { const trimmed = text.trim(); if (trimmed.length === 0) return null; try { return record(JSON.parse(trimmed) as unknown); } catch { const objectText = firstJsonObjectText(trimmed); if (objectText === null) return null; try { return record(JSON.parse(objectText) as unknown); } catch { return null; } } } function firstJsonObjectText(text: string): string | null { const start = text.indexOf("{"); if (start < 0) return null; let depth = 0; let inString = false; let escaped = false; for (let index = start; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === "\"") inString = false; continue; } if (char === "\"") { inString = true; continue; } if (char === "{") depth += 1; else if (char === "}") { depth -= 1; if (depth === 0) return text.slice(start, index + 1); } } return null; } function record(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function stringValue(value: Record, key: string): string | null { const item = value[key]; return typeof item === "string" && item.length > 0 ? item : null; }