/** * Thin executor around the OneDev `tod` CLI. * * Contract (verified against tod source): every subcommand writes the raw * OneDev server payload to stdout and logs progress to stderr, so stdout is * the tool result and stderr is error/diagnostic context. */ export interface ExecResult { stdout: string; stderr: string; code: number; killed: boolean; } export type CommandExecutor = ( command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }, ) => Promise; export class TodError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "TodError"; } } export interface TodRunOptions { cwd?: string; timeoutMs?: number; signal?: AbortSignal; maxOutputBytes?: number; /** Environment overrides passed through `env`; used for an isolated git index. */ environment?: Readonly>; } export interface TodOutput { text: string; truncated: boolean; originalBytes: number; } export const DEFAULT_TIMEOUT_MS = 30_000; export const BUILD_RUN_TIMEOUT_MS = 10 * 60_000; export const DEFAULT_MAX_OUTPUT_BYTES = 48_000; function tail(value: string, maxChars = 500): string { const cleaned = value.replace(/\s+/g, " ").trim(); return cleaned.length > maxChars ? `…${cleaned.slice(-maxChars)}` : cleaned; } /** * Byte-bounded view keeping the head (metadata) and tail (newest entries, * log ends) of a large payload. */ export function truncateMiddle(text: string, maxBytes: number): TodOutput { const buffer = Buffer.from(text, "utf8"); const originalBytes = buffer.length; if (originalBytes <= maxBytes) { return { text, truncated: false, originalBytes }; } const head = Math.floor(maxBytes * 0.6); const tailBytes = maxBytes - head; const omitted = originalBytes - maxBytes; let headEnd = head; while (headEnd > 0 && (buffer[headEnd] ?? 0) >> 6 === 2) headEnd -= 1; let tailStart = originalBytes - tailBytes; while (tailStart < originalBytes && (buffer[tailStart] ?? 0) >> 6 === 2) tailStart += 1; return { text: buffer.subarray(0, headEnd).toString("utf8") + `\n[… truncated ${omitted} bytes …]\n` + buffer.subarray(tailStart).toString("utf8"), truncated: true, originalBytes, }; } export async function runTod( exec: CommandExecutor, args: readonly string[], options: TodRunOptions = {}, ): Promise { let result: ExecResult; try { const environment = Object.entries(options.environment ?? {}); for (const [name] of environment) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw new Error(`invalid environment variable name '${name}'`); } } const command = environment.length > 0 ? "env" : "tod"; const commandArgs = environment.length > 0 ? [ ...environment.map(([name, value]) => `${name}=${value}`), "tod", ...args, ] : [...args]; result = await exec(command, commandArgs, { cwd: options.cwd, timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal: options.signal, }); } catch (cause) { throw new TodError( `tod is not usable: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }, ); } if (result.killed) { const scope = args.slice(0, 2).join(" "); throw new TodError( options.signal?.aborted ? `tod ${scope} aborted by the caller` : `tod ${scope} timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, ); } if (result.code !== 0) { const scope = args.slice(0, 2).join(" "); const detail = tail(result.stderr || result.stdout); throw new TodError( `tod ${scope} failed (exit ${result.code})${detail ? `: ${detail}` : ""}`, ); } return truncateMiddle( result.stdout.trimEnd(), options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, ); }