Files
pikasTech-unidesk/scripts/src/output.ts
T
2026-06-21 15:24:17 +00:00

394 lines
15 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");
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));
}
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`;
if (!shouldDumpLargeOutput(command, fullText, envelope)) return fullText;
const policy = cliOutputPolicy();
const dump = dumpLargeOutput(command, fullText, "json", policy);
const compactPayload = {
outputTruncated: true,
reason: "stdout-json-exceeded-threshold",
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),
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 shouldDumpLargeOutput(command: string, text: string, envelope: JsonEnvelope<unknown>): boolean {
void command;
void envelope;
if (process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return false;
const threshold = cliOutputPolicy().maxStdoutBytes;
return Buffer.byteLength(text, "utf8") > threshold;
}
function renderTextOutput(command: string, text: string): string {
const fullText = text.endsWith("\n") ? text : `${text}\n`;
if (process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return fullText;
const policy = cliOutputPolicy();
if (Buffer.byteLength(fullText, "utf8") <= policy.maxStdoutBytes) return fullText;
const dump = dumpLargeOutput(command, fullText, "txt", policy);
const payload: JsonEnvelope<Record<string, unknown>> = {
ok: true,
command,
data: {
outputTruncated: true,
reason: "stdout-text-exceeded-threshold",
warning: policy.warning,
message: "Full text output was written to a temporary file; stdout contains only file metadata.",
disclosurePolicy: disclosurePolicy(policy),
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 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 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;
}