264 lines
9.1 KiB
TypeScript
264 lines
9.1 KiB
TypeScript
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<string, unknown>; path?: string | null; reason?: string | null }
|
|
| { ok: false; reason: string; path?: string | null; error?: string | null };
|
|
|
|
export interface CliChildJsonResolution {
|
|
parsed: Record<string, unknown> | null;
|
|
source: CliChildJsonSource;
|
|
diagnostics: Record<string, unknown>;
|
|
}
|
|
|
|
export function resolveCliChildJsonObject(options: {
|
|
stdout: string;
|
|
stderr?: string;
|
|
requestedStdoutType: string;
|
|
acceptParsed?: (value: Record<string, unknown>) => 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<string, unknown> | 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<string, unknown> | null;
|
|
source: CliChildJsonSource;
|
|
stdoutKind: CliChildJsonStdoutKind;
|
|
dumpPath: string | null;
|
|
dumpReason: string | null;
|
|
dumpReadOk: boolean | null;
|
|
dumpJsonOk: boolean | null;
|
|
sshTruncation: Record<string, unknown> | 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<string, unknown> | null): {
|
|
parsed: Record<string, unknown> | null;
|
|
source: CliChildJsonSource;
|
|
stdoutKind: CliChildJsonStdoutKind;
|
|
dumpPath: string | null;
|
|
dumpReason: string | null;
|
|
dumpReadOk: boolean | null;
|
|
dumpJsonOk: boolean | null;
|
|
sshTruncation: Record<string, unknown> | 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<string, unknown> | 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<string, unknown>): { 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<string, unknown> | 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<string, unknown> | null {
|
|
if (!line.startsWith(marker)) return null;
|
|
return parseJsonObject(line.slice(marker.length));
|
|
}
|
|
|
|
function stdoutDumpPathFromSshSummary(value: Record<string, unknown> | null): string | null {
|
|
const root = record(value);
|
|
const stdout = record(root.stdout);
|
|
return stringValue(stdout, "dumpPath") ?? stringValue(root, "dumpPath");
|
|
}
|
|
|
|
function parseJsonObject(text: string): Record<string, unknown> | 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<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function stringValue(value: Record<string, unknown>, key: string): string | null {
|
|
const item = value[key];
|
|
return typeof item === "string" && item.length > 0 ? item : null;
|
|
}
|