593 lines
23 KiB
TypeScript
593 lines
23 KiB
TypeScript
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<T> {
|
|
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<T>(command: string, data: T, ok = true): void {
|
|
const envelope: JsonEnvelope<T> = { 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<never> = { ok: false, command, error: payload };
|
|
safeStdoutWrite(renderEnvelope(command, envelope));
|
|
}
|
|
|
|
export function readCliOutputPolicy(): CliOutputPolicy {
|
|
return cliOutputPolicy();
|
|
}
|
|
|
|
function normalizeErrorPayload(command: string, error: unknown): Record<string, unknown> {
|
|
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<string, unknown> | null {
|
|
const record = error as Error & Record<string, unknown>;
|
|
if (!Object.prototype.hasOwnProperty.call(record, "replacementExamples") && !Object.prototype.hasOwnProperty.call(record, "migrationHint")) return null;
|
|
const payload: Record<string, unknown> = {
|
|
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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
|
|
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<T>(command: string, envelope: JsonEnvelope<T>): 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<Record<string, unknown>> = 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<string, unknown> | 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<Record<string, unknown>> = {
|
|
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<string, unknown> {
|
|
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<string, unknown> = {
|
|
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<unknown>): Record<string, unknown> {
|
|
const source = typeof envelope.data === "object" && envelope.data !== null
|
|
? envelope.data as Record<string, unknown>
|
|
: typeof envelope.error === "object" && envelope.error !== null
|
|
? envelope.error as Record<string, unknown>
|
|
: {};
|
|
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<string, unknown> = {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
summary.pullRequest = pickSummary(prRecord, ["number", "title", "state", "stateDetail", "url", "draft", "merged", "mergedAt"]);
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
function disclosurePolicy(policy: CliOutputPolicy): Record<string, unknown> {
|
|
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<string, unknown>): Record<string, number> {
|
|
const result: Record<string, number> = {};
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (Array.isArray(value)) result[key] = value.length;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function summarizeDebugDispatch(source: Record<string, unknown>): Record<string, unknown> | 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<string, unknown> = {
|
|
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<string, unknown> | null): Record<string, unknown> | 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<string, unknown> = {
|
|
...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<string, unknown>): Record<string, unknown> | 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<string, unknown> => 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<string, unknown>[], returned: number): Record<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown>, keys: string[]): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
|
return asRecord(obj[key], `${path}.${key}`);
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (!value.startsWith("/")) throw new Error(`${path}.${key} must be absolute`);
|
|
return value;
|
|
}
|