mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
187 lines
5.4 KiB
TypeScript
187 lines
5.4 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import { existsSync } from "node:fs";
|
|
import { extname } from "node:path";
|
|
|
|
type Which = (command: string, options: { all: true }) => Promise<string[]>;
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const which = require("which") as Which;
|
|
const PROBE_TIMEOUT_MS = 2000;
|
|
|
|
function hasPathSeparator(value: string): boolean {
|
|
return value.includes("/") || value.includes("\\");
|
|
}
|
|
|
|
async function enumerateCandidates(name: string): Promise<string[]> {
|
|
let candidates: string[];
|
|
try {
|
|
candidates = await which(name, { all: true });
|
|
} catch (error) {
|
|
// `which` throws ENOENT when the command is absent from PATH.
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
return candidates.filter((candidate) => {
|
|
if (seen.has(candidate)) {
|
|
return false;
|
|
}
|
|
seen.add(candidate);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function isWindowsCommandScript(executablePath: string): boolean {
|
|
const extension = extname(executablePath).toLowerCase();
|
|
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
|
|
}
|
|
|
|
export function isWindowsPowerShellScript(executablePath: string): boolean {
|
|
return process.platform === "win32" && extname(executablePath).toLowerCase() === ".ps1";
|
|
}
|
|
|
|
export function windowsPowerShellScriptArgs(scriptPath: string, args: string[]): string[] {
|
|
return ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args];
|
|
}
|
|
|
|
async function probeExecutable(executablePath: string): Promise<boolean> {
|
|
return await new Promise((resolve) => {
|
|
let settled = false;
|
|
let started = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const settle = (result: boolean) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
let child: ChildProcess;
|
|
try {
|
|
const isPowerShellScript = isWindowsPowerShellScript(executablePath);
|
|
child = spawn(
|
|
isPowerShellScript ? "powershell.exe" : executablePath,
|
|
isPowerShellScript
|
|
? windowsPowerShellScriptArgs(executablePath, ["--version"])
|
|
: ["--version"],
|
|
{
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
// Windows batch shims (.cmd/.bat) require cmd.exe; native binaries do not.
|
|
shell: isWindowsCommandScript(executablePath),
|
|
},
|
|
);
|
|
} catch {
|
|
settle(false);
|
|
return;
|
|
}
|
|
|
|
timer = setTimeout(() => {
|
|
if (started) {
|
|
child.kill();
|
|
settle(true);
|
|
return;
|
|
}
|
|
settle(false);
|
|
}, PROBE_TIMEOUT_MS);
|
|
timer.unref?.();
|
|
|
|
child.once("spawn", () => {
|
|
started = true;
|
|
});
|
|
child.once("error", () => {
|
|
// ENOENT/EACCES/EPERM/UNKNOWN here means the OS could not start the candidate.
|
|
settle(started);
|
|
});
|
|
child.once("exit", () => {
|
|
settle(started);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check a literal executable path. PATH search is handled by findExecutable().
|
|
*/
|
|
export function executableExists(
|
|
executablePath: string,
|
|
exists: typeof existsSync = existsSync,
|
|
): string | null {
|
|
if (exists(executablePath)) return executablePath;
|
|
if (process.platform === "win32" && !extname(executablePath)) {
|
|
for (const ext of [".exe", ".cmd", ".ps1"]) {
|
|
const candidate = executablePath + ext;
|
|
if (exists(candidate)) return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function findExecutable(name: string): Promise<string | null> {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (hasPathSeparator(trimmed)) {
|
|
return (await probeExecutable(trimmed)) ? trimmed : null;
|
|
}
|
|
|
|
const candidates = await enumerateCandidates(trimmed);
|
|
for (const candidate of candidates) {
|
|
if (await probeExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function isCommandAvailable(command: string): Promise<boolean> {
|
|
return (await findExecutable(command)) !== null;
|
|
}
|
|
|
|
function escapeWindowsCmdValue(value: string): string {
|
|
if (process.platform !== "win32") return value;
|
|
|
|
const isQuoted = value.startsWith('"') && value.endsWith('"');
|
|
const unquoted = isQuoted ? value.slice(1, -1) : value;
|
|
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
|
|
|
|
if (isQuoted || /[\s"]/u.test(unquoted)) {
|
|
const quoted = escaped
|
|
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
|
|
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
|
|
return `"${quoted}"`;
|
|
}
|
|
|
|
return escaped;
|
|
}
|
|
|
|
/**
|
|
* When spawning with `shell: true` on Windows, the command is passed to
|
|
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
|
|
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
|
|
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
|
|
*/
|
|
export function quoteWindowsCommand(command: string): string {
|
|
return escapeWindowsCmdValue(command);
|
|
}
|
|
|
|
/**
|
|
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
|
|
* Any argument containing spaces must be quoted or it will be split before the
|
|
* child process sees it.
|
|
*/
|
|
export function quoteWindowsArgument(argument: string): string {
|
|
return escapeWindowsCmdValue(argument);
|
|
}
|