Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/web/spawn.ts

Raw
// Shared spawn helper for running external commands

import { spawn } from "node:child_process";
import { basename, dirname, extname, join } from "node:path";
import { findExecutable, findExecutableFile } from "./src/pi-ext-executable.ts";

export async function spawnToText(
	cmd: string,
	args: string[],
	input?: string,
): Promise<string> {
	const resolved = (await resolveCommand(cmd)) ?? cmd;
	const spawnArgs = commandArgs(resolved, args);
	return new Promise((resolve, reject) => {
		const proc = spawn(spawnArgs.command, spawnArgs.args, {
			windowsHide: true,
			...spawnArgs.options,
		});
		let stdout = "";
		let stderr = "";
		let settled = false;
		const fail = (err: Error) => {
			if (settled) return;
			settled = true;
			reject(err);
		};
		proc.stdout.on("data", (d: Buffer) => (stdout += d));
		proc.stderr.on("data", (d: Buffer) => (stderr += d));
		proc.stdin.on("error", (err: NodeJS.ErrnoException) => {
			if (err.code !== "EPIPE" && err.code !== "EOF") fail(err);
		});
		proc.on("close", (code) => {
			if (settled) return;
			settled = true;
			if (code === 0) resolve(stdout);
			else reject(new Error(stderr || `${cmd}: ${code}`));
		});
		proc.on("error", fail);
		if (input !== undefined) {
			proc.stdin.write(input);
			proc.stdin.end();
		}
	});
}

/**
 * Check if a command is available on PATH.
 * Returns null if available, or an install hint string if not.
 */
const availableCommands = new Set<string>();

function pathEnv(): string {
	const key = Object.keys(process.env).find((k) => k.toLowerCase() === "path");
	return key ? (process.env[key] ?? "") : "";
}

function pathextEnv(): string[] {
	if (process.platform !== "win32") return [""];
	const raw = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD";
	return raw
		.split(";")
		.map((ext) => ext.trim())
		.filter(Boolean);
}

function candidateNames(cmd: string): string[] {
	if (process.platform !== "win32" || extname(cmd)) return [cmd];
	return [
		cmd,
		...pathextEnv().flatMap((ext) => [cmd + ext, cmd + ext.toLowerCase()]),
	];
}

export async function resolveCommand(cmd: string): Promise<string | null> {
	if (cmd.includes("/") || cmd.includes("\\")) {
		for (const name of candidateNames(basename(cmd))) {
			const candidate = findExecutableFile(join(dirname(cmd), name));
			if (candidate) return candidate;
		}
		return null;
	}
	return findExecutable(candidateNames(cmd), { path: pathEnv() }) ?? null;
}

function commandArgs(
	resolved: string,
	args: string[],
): {
	command: string;
	args: string[];
	options?: { windowsVerbatimArguments: boolean };
} {
	if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) {
		return {
			command: process.env.ComSpec || process.env.COMSPEC || "cmd.exe",
			args: [
				"/d",
				"/s",
				"/c",
				`"${[quoteCmdArg(resolved), ...args.map(quoteCmdArg)].join(" ")}"`,
			],
			options: { windowsVerbatimArguments: true },
		};
	}
	return { command: resolved, args };
}

function quoteCmdArg(arg: string): string {
	return `"${arg.replace(/"/g, '""')}"`;
}

export async function checkToolAvailability(
	cmd: string,
	installHint: string,
): Promise<string | null> {
	const cacheKey = `${cmd}\0${pathEnv()}\0${process.env.PATHEXT ?? ""}`;
	if (availableCommands.has(cacheKey)) return null;

	if (await resolveCommand(cmd)) {
		availableCommands.add(cacheKey);
		return null;
	}
	return installHint;
}

// Tool install hints
export const TOOL_HINTS = {
	pandoc:
		"pandoc is not installed. Install with: brew install pandoc / apt install pandoc / conda install pandoc",
	pdftotext:
		"pdftotext (poppler-utils) is not installed. Install with: brew install poppler / apt install poppler-utils",
	identify:
		"identify (ImageMagick) is not installed. Install with: brew install imagemagick / apt install imagemagick",
	ffprobe:
		"ffprobe (ffmpeg) is not installed. Install with: brew install ffmpeg / apt install ffmpeg",
} as const;