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;