Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/git-safe/spawn.ts

Raw
// spawn.ts — Shared spawn helper with timeout and git safety env vars

import { spawn } from "node:child_process";

export interface SpawnResult {
	stdout: string;
	stderr: string;
	code: number | null;
	killed: boolean;
}

/**
 * Spawn a command with timeout and structured error output.
 * Kills the process on timeout with SIGKILL.
 *
 * GIT_TERMINAL_PROMPT=0 is always set — prevents credential prompts from hanging.
 */
export function spawnWithTimeout(
	cmd: string,
	args: string[],
	opts: { cwd?: string; timeout?: number; env?: Record<string, string> } = {},
): Promise<SpawnResult> {
	const { cwd, timeout = 60_000, env: extraEnv } = opts;

	return new Promise((resolve) => {
		const env = {
			...process.env,
			GIT_TERMINAL_PROMPT: "0",
			GIT_TERMINAL_PROMPT_AUTOCHECK: "0",
			...extraEnv,
		};

		const proc = spawn(cmd, args, { cwd, env: env as Record<string, string> });
		let stdout = "";
		let stderr = "";
		let timedOut = false;
		let settled = false;

		const finish = (result: SpawnResult) => {
			if (settled) return;
			settled = true;
			clearTimeout(timer);
			resolve(result);
		};

		const timer = setTimeout(() => {
			timedOut = true;
			proc.kill("SIGKILL");
		}, timeout);

		proc.stdout.on("data", (d: Buffer) => (stdout += d));
		proc.stderr.on("data", (d: Buffer) => (stderr += d));
		proc.on("close", (code) => {
			finish({ stdout, stderr, code, killed: timedOut });
		});
		proc.on("error", (err) => {
			finish({ stdout, stderr: err.message, code: null, killed: timedOut });
		});
	});
}

/**
 * Spawn and expect exit code 0. Throws structured error on failure.
 */
export async function spawnExpect(
	cmd: string,
	args: string[],
	opts: { cwd?: string; timeout?: number; env?: Record<string, string> } = {},
): Promise<string> {
	const result = await spawnWithTimeout(cmd, args, opts);

	if (result.killed) {
		throw new SpawnTimeoutError(cmd, args, opts.timeout ?? 60_000);
	}

	if (result.code !== 0) {
		throw new SpawnExitError(
			cmd,
			args,
			result.code,
			result.stderr,
			result.stdout,
		);
	}

	return result.stdout;
}

// ── Error types ────────────────────────────────────────────────────────────

export class SpawnTimeoutError extends Error {
	constructor(
		public readonly cmd: string,
		public readonly args: string[],
		public readonly timeoutMs: number,
	) {
		super(`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(" ")}`);
		this.name = "SpawnTimeoutError";
	}
}

export class SpawnExitError extends Error {
	constructor(
		public readonly cmd: string,
		public readonly args: string[],
		public readonly exitCode: number | null,
		public readonly stderr: string,
		public readonly stdout: string,
	) {
		const stderrLine = stderr.trim().split("\n").pop() ?? "";
		super(
			`Command failed (exit ${exitCode}): ${cmd} ${args.join(" ")}\n${stderrLine}`,
		);
		this.name = "SpawnExitError";
	}
}