202 lines
7.6 KiB
TypeScript
202 lines
7.6 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
export interface JsonEnvelope<T> {
|
|
ok: boolean;
|
|
command: string;
|
|
data?: T;
|
|
error?: unknown;
|
|
}
|
|
|
|
const GH_OUTPUT_DUMP_THRESHOLD_BYTES = 20 * 1024;
|
|
const OUTPUT_DUMP_PREVIEW_CHARS = 2000;
|
|
const OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output");
|
|
|
|
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 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 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, stack: error.stack ?? null, ...(debug ? { debug: true } : {}) };
|
|
}
|
|
return { message };
|
|
}
|
|
|
|
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 dump = dumpLargeOutput(command, fullText);
|
|
const compactPayload = {
|
|
outputTruncated: true,
|
|
reason: "stdout-json-exceeded-threshold",
|
|
message: "Full JSON output was written to a temporary file; stdout contains only bounded head/tail previews.",
|
|
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 {
|
|
if (!(command === "gh" || command.startsWith("gh "))) return false;
|
|
if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1") return false;
|
|
if (typeof envelope.data === "object" && envelope.data !== null && (envelope.data as { noDump?: unknown }).noDump === true) return false;
|
|
const threshold = configuredDumpThresholdBytes();
|
|
return Buffer.byteLength(text, "utf8") > threshold;
|
|
}
|
|
|
|
function configuredDumpThresholdBytes(): number {
|
|
const raw = process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_THRESHOLD_BYTES;
|
|
if (raw === undefined || raw.trim().length === 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES;
|
|
return value;
|
|
}
|
|
|
|
function dumpLargeOutput(command: string, text: string): Record<string, unknown> {
|
|
mkdirSync(OUTPUT_DUMP_DIR, { 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(OUTPUT_DUMP_DIR, `${timestamp}-${process.pid}-${suffix}-${slug}.json`);
|
|
writeFileSync(path, text, { encoding: "utf8", mode: 0o600 });
|
|
return {
|
|
path,
|
|
thresholdBytes: configuredDumpThresholdBytes(),
|
|
bytes: Buffer.byteLength(text, "utf8"),
|
|
chars: text.length,
|
|
lines: countLines(text),
|
|
headChars: OUTPUT_DUMP_PREVIEW_CHARS,
|
|
tailChars: OUTPUT_DUMP_PREVIEW_CHARS,
|
|
head: text.slice(0, OUTPUT_DUMP_PREVIEW_CHARS),
|
|
tail: text.slice(Math.max(0, text.length - OUTPUT_DUMP_PREVIEW_CHARS)),
|
|
readCommands: {
|
|
full: `cat ${JSON.stringify(path)}`,
|
|
head: `sed -n '1,80p' ${JSON.stringify(path)}`,
|
|
tail: `tail -80 ${JSON.stringify(path)}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
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",
|
|
];
|
|
const summary: Record<string, unknown> = {
|
|
ok: envelope.ok,
|
|
command: envelope.command,
|
|
};
|
|
for (const key of summaryKeys) {
|
|
if (key in source) summary[key] = source[key];
|
|
}
|
|
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 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;
|
|
}
|
|
}
|