mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* fix(providers): launch plugin CLIs on Windows and tolerate Muse startup Plugin providers bypassed the core Windows launcher and the plugin host exposed an empty server SDK module. Share the launcher through the SDK and bind it in the host. Give Muse initialization its own deadline because native startup traverses session history before answering MSP. * fix(providers): terminate Windows launcher process trees A command-script launcher leaves the CLI behind cmd.exe. Expose forced process-tree termination in the server SDK, use it for Muse and SDK ACP teardown, and bound Muse closing so inherited streams cannot leave callers waiting forever. * test(muse): keep platform setup outside the teardown assertion * refactor(plugin): expose process capabilities without quoting helpers * fix(providers): close process trees on uncorrelated protocol errors
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import { existsSync } from "node:fs";
|
|
import type { ProcessEnvRecord } from "../server/paseo-env.js";
|
|
import { execCommand } from "../utils/spawn.js";
|
|
import { windowsExecutableResolution } from "./windows.js";
|
|
|
|
type Which = (
|
|
command: string,
|
|
options: { all: true; path?: string; pathExt?: string },
|
|
) => 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, env?: ProcessEnvRecord): Promise<string[]> {
|
|
if (process.platform !== "win32" && existsSync("/usr/bin/which")) {
|
|
return enumerateCandidatesViaSystemWhich(name, env);
|
|
}
|
|
return enumerateCandidatesViaLibrary(name, env);
|
|
}
|
|
|
|
async function enumerateCandidatesViaSystemWhich(
|
|
name: string,
|
|
env?: ProcessEnvRecord,
|
|
): Promise<string[]> {
|
|
try {
|
|
const { stdout } = await execCommand("/usr/bin/which", ["-a", name], {
|
|
baseEnv: env,
|
|
timeout: 3000,
|
|
killSignal: "SIGKILL",
|
|
});
|
|
return Array.from(new Set(stdout.trim().split("\n").filter(Boolean)));
|
|
} catch (error) {
|
|
// which exits 1 for a missing command. A failed lookup is not evidence of absence.
|
|
if (error instanceof Error && "code" in error && error.code === 1) return [];
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function enumerateCandidatesViaLibrary(
|
|
name: string,
|
|
env?: ProcessEnvRecord,
|
|
): Promise<string[]> {
|
|
let candidates: string[];
|
|
try {
|
|
candidates = await which(name, { all: true, path: env?.PATH, pathExt: env?.PATHEXT });
|
|
} 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 async function probeExecutable(
|
|
executablePath: string,
|
|
options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS,
|
|
): Promise<boolean> {
|
|
const { probeTimeoutMs: timeoutMs = PROBE_TIMEOUT_MS, env } =
|
|
typeof options === "number" ? { probeTimeoutMs: options } : options;
|
|
try {
|
|
await execCommand(executablePath, ["--version"], {
|
|
baseEnv: env,
|
|
timeout: timeoutMs,
|
|
killSignal: "SIGKILL",
|
|
maxBuffer: 64 * 1024,
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
return classifyProbeError(error);
|
|
}
|
|
}
|
|
|
|
function classifyProbeError(error: unknown): boolean {
|
|
const err = error as NodeJS.ErrnoException & {
|
|
killed?: boolean;
|
|
};
|
|
if (err.killed) {
|
|
return true;
|
|
}
|
|
if (typeof err.code === "number") {
|
|
return true;
|
|
}
|
|
if (
|
|
err.code === "ENOENT" ||
|
|
err.code === "EACCES" ||
|
|
err.code === "ENOEXEC" ||
|
|
err.code === "UNKNOWN"
|
|
) {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check a literal executable path. PATH search is handled by findExecutable().
|
|
*/
|
|
export function executableExists(
|
|
executablePath: string,
|
|
exists: typeof existsSync = existsSync,
|
|
): string | null {
|
|
if (process.platform === "win32") {
|
|
return windowsExecutableResolution.exists(executablePath, { exists });
|
|
}
|
|
return exists(executablePath) ? executablePath : null;
|
|
}
|
|
|
|
export interface ExecutableResolutionOptions {
|
|
probeTimeoutMs?: number;
|
|
env?: ProcessEnvRecord;
|
|
}
|
|
|
|
export async function findExecutable(
|
|
name: string,
|
|
options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS,
|
|
): Promise<string | null> {
|
|
const { probeTimeoutMs = PROBE_TIMEOUT_MS, env } =
|
|
typeof options === "number" ? { probeTimeoutMs: options } : options;
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
return windowsExecutableResolution.find(trimmed, {
|
|
enumeratePathCandidates: (command) => enumerateCandidates(command, env),
|
|
probeExecutable: (command, timeout) =>
|
|
probeExecutable(command, { probeTimeoutMs: timeout, env }),
|
|
exists: existsSync,
|
|
probeTimeoutMs,
|
|
});
|
|
}
|
|
|
|
if (hasPathSeparator(trimmed)) {
|
|
return (await probeExecutable(trimmed, { probeTimeoutMs, env })) ? trimmed : null;
|
|
}
|
|
|
|
const candidates = await enumerateCandidates(trimmed, env);
|
|
for (const candidate of candidates) {
|
|
if (await probeExecutable(candidate, { probeTimeoutMs, env })) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function isCommandAvailable(command: string): Promise<boolean> {
|
|
return (await findExecutable(command)) !== null;
|
|
}
|