// 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 { 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(); 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 { 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 { 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;