Fix coding-agent terminal shortcuts not working on Windows (#1509)
* Fix coding-agent terminal shortcuts not working on Windows On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did nothing, and afterwards even a plain New Terminal stopped working until restart. Three Windows-specific problems combined: - A failed node-pty conpty spawn completes asynchronously on its conout worker thread; the uncaught exception escaped the per-request try/catch and crashed the whole terminal worker, severing every terminal. Add uncaughtException / unhandledRejection guards so a single bad spawn keeps the worker alive. - Profile commands were passed to conpty unresolved. conpty's CreateProcess ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the real path and route .cmd/.bat through cmd.exe /c on Windows. - winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir and aren't on PATH. findExecutable() now falls back to those known install locations, so all providers and terminal profiles benefit. Also surface a failed terminal create to the user via a toast instead of silently dropping the error. * Address review: simplify winget scan, narrow worker exception guard - Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and map package dirs directly to the root <name>.exe candidate. - Remove the unhandledRejection handler in the terminal worker (only the uncaughtException path is exercised by conpty's async spawn failure) and expand the comment to explain the keep-alive trade-off and the absence of a worker restart path. * Refactor executable resolution out of utils * Serialize terminal create requests in worker --------- Co-authored-by: danniel <liminfhu@gmail.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
danniel
Mohamed Boudra
parent
bf8f2510c8
commit
f352072dac
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { probeExecutable } from "./executable-resolution.js";
|
||||
|
||||
const timeoutMs = 1000;
|
||||
const timeoutSlackMs = 500;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
interface ProbeFixture {
|
||||
name: string;
|
||||
expected: boolean;
|
||||
create: (dir: string) => { executablePath: string; pidFile?: string };
|
||||
}
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-probe-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeExecutable(filePath: string, content: string | Buffer): string {
|
||||
writeFileSync(filePath, content);
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function scriptPath(dir: string, name: string): string {
|
||||
return process.platform === "win32" ? path.join(dir, `${name}.cmd`) : path.join(dir, name);
|
||||
}
|
||||
|
||||
function createHangingFixture(dir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return writeExecutable(
|
||||
scriptPath(dir, "hangs"),
|
||||
"@echo off\r\n:loop\r\ntimeout /T 5 /NOBREAK > NUL\r\ngoto loop\r\n",
|
||||
);
|
||||
}
|
||||
return writeExecutable(
|
||||
scriptPath(dir, "hangs"),
|
||||
`#!/bin/sh\ntrap '' TERM\necho $$ > "${path.join(dir, "hangs.pid")}"\nwhile :; do :; done\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function createNoVersionFixture(dir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return writeExecutable(scriptPath(dir, "no-version"), "@echo off\r\nexit /b 0\r\n");
|
||||
}
|
||||
return writeExecutable(scriptPath(dir, "no-version"), "#!/bin/sh\nexit 0\n");
|
||||
}
|
||||
|
||||
function createNonZeroFixture(dir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return writeExecutable(
|
||||
scriptPath(dir, "non-zero"),
|
||||
"@echo off\r\necho oops 1>&2\r\nexit /b 1\r\n",
|
||||
);
|
||||
}
|
||||
return writeExecutable(scriptPath(dir, "non-zero"), "#!/bin/sh\necho oops 1>&2\nexit 1\n");
|
||||
}
|
||||
|
||||
function createSlowSuccessFixture(dir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return writeExecutable(
|
||||
scriptPath(dir, "slow-success"),
|
||||
"@echo off\r\nping -n 1 127.0.0.1 > NUL\r\nexit /b 0\r\n",
|
||||
);
|
||||
}
|
||||
return writeExecutable(scriptPath(dir, "slow-success"), "#!/bin/sh\nsleep 0.05\nexit 0\n");
|
||||
}
|
||||
|
||||
function createDirectoryFixture(dir: string): string {
|
||||
const directoryPath = path.join(dir, "candidate-directory");
|
||||
mkdirSync(directoryPath);
|
||||
return directoryPath;
|
||||
}
|
||||
|
||||
function missingAbsolutePath(): string {
|
||||
return process.platform === "win32" ? "C:\\no\\such\\path.exe" : "/no/such/path";
|
||||
}
|
||||
|
||||
async function waitForFile(filePath: string): Promise<void> {
|
||||
const deadline = performance.now() + timeoutSlackMs;
|
||||
while (!existsSync(filePath) && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
const fixtures: ProbeFixture[] = [
|
||||
{
|
||||
name: "hangs forever after starting",
|
||||
expected: true,
|
||||
create: (dir) => ({
|
||||
executablePath: createHangingFixture(dir),
|
||||
pidFile: process.platform === "win32" ? undefined : path.join(dir, "hangs.pid"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "does not know --version and exits zero",
|
||||
expected: true,
|
||||
create: (dir) => ({ executablePath: createNoVersionFixture(dir) }),
|
||||
},
|
||||
{
|
||||
name: "exits non-zero immediately",
|
||||
expected: true,
|
||||
create: (dir) => ({ executablePath: createNonZeroFixture(dir) }),
|
||||
},
|
||||
{
|
||||
name: "starts slowly and exits zero",
|
||||
expected: true,
|
||||
create: (dir) => ({ executablePath: createSlowSuccessFixture(dir) }),
|
||||
},
|
||||
{
|
||||
name: "points at a directory",
|
||||
expected: false,
|
||||
create: (dir) => ({ executablePath: createDirectoryFixture(dir) }),
|
||||
},
|
||||
{
|
||||
name: "does not exist at an absolute path",
|
||||
expected: false,
|
||||
create: () => ({ executablePath: missingAbsolutePath() }),
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("probeExecutable", () => {
|
||||
// POSIX-only: positive fixtures rely on direct script probing; Windows command-script probing has separate coverage.
|
||||
test.skipIf(isPlatform("win32")).each(fixtures.filter((fixture) => fixture.expected))(
|
||||
"$name",
|
||||
async ({ create, expected }) => {
|
||||
const { executablePath, pidFile } = create(makeTempDir());
|
||||
const startedAt = performance.now();
|
||||
|
||||
const result = await probeExecutable(executablePath, timeoutMs);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
|
||||
if (pidFile) {
|
||||
await waitForFile(pidFile);
|
||||
const pid = Number(readFileSync(pidFile, "utf8"));
|
||||
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(fixtures.filter((fixture) => !fixture.expected))(
|
||||
"$name",
|
||||
async ({ create, expected }) => {
|
||||
const { executablePath, pidFile } = create(makeTempDir());
|
||||
const startedAt = performance.now();
|
||||
|
||||
const result = await probeExecutable(executablePath, timeoutMs);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
|
||||
if (pidFile) {
|
||||
await waitForFile(pidFile);
|
||||
const pid = Number(readFileSync(pidFile, "utf8"));
|
||||
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
executableExists,
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
} from "./executable-resolution.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const originalEnv = {
|
||||
PATH: process.env.PATH,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
};
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-executable-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function prependPath(...dirs: string[]): void {
|
||||
process.env.PATH = [...dirs, originalEnv.PATH].filter(Boolean).join(path.delimiter);
|
||||
}
|
||||
|
||||
function writeExecutable(filePath: string, content: string): string {
|
||||
writeFileSync(filePath, content);
|
||||
if (!isPlatform("win32")) {
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function writeBrokenAbsoluteFixture(dir: string): string {
|
||||
const filePath = isPlatform("win32") ? path.join(dir, "broken.exe") : path.join(dir, "broken");
|
||||
writeFileSync(filePath, "not executable");
|
||||
if (!isPlatform("win32")) {
|
||||
chmodSync(filePath, 0o644);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function expectWindowsPathsEqual(actual: string | null, expected: string): void {
|
||||
expect(actual?.toLowerCase()).toBe(expected.toLowerCase());
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("findExecutable", () => {
|
||||
describe.skipIf(isPlatform("win32"))("POSIX", () => {
|
||||
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
|
||||
const executableDir = makeTempDir();
|
||||
const nonExecutableDir = makeTempDir();
|
||||
const executable = writeExecutable(path.join(executableDir, "foo"), "#!/bin/sh\necho 0.1\n");
|
||||
const nonExecutable = path.join(nonExecutableDir, "foo");
|
||||
writeFileSync(nonExecutable, "#!/bin/sh\necho broken\n");
|
||||
chmodSync(nonExecutable, 0o644);
|
||||
prependPath(nonExecutableDir, executableDir);
|
||||
|
||||
await expect(findExecutable("foo")).resolves.toBe(executable);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(isPlatform("win32"))("Windows", () => {
|
||||
test("returns a working .cmd when an invalid .exe candidate appears first", async () => {
|
||||
const dir = makeTempDir();
|
||||
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
|
||||
const brokenExe = path.join(dir, "foo.exe");
|
||||
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
|
||||
writeFileSync(brokenExe, "");
|
||||
prependPath(dir);
|
||||
|
||||
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
|
||||
});
|
||||
|
||||
test("returns null when the only candidate is a broken .exe", async () => {
|
||||
const dir = makeTempDir();
|
||||
process.env.PATHEXT = ".EXE";
|
||||
writeFileSync(path.join(dir, "foo.exe"), "");
|
||||
prependPath(dir);
|
||||
|
||||
await expect(findExecutable("foo")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("returns a .cmd when it is the only candidate", async () => {
|
||||
const dir = makeTempDir();
|
||||
process.env.PATHEXT = ".CMD";
|
||||
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
|
||||
prependPath(dir);
|
||||
|
||||
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
|
||||
});
|
||||
|
||||
test("finds a .cmd for an extensionless absolute path", async () => {
|
||||
const dir = makeTempDir();
|
||||
const command = path.join(dir, "codex");
|
||||
const cmd = writeExecutable(`${command}.cmd`, "@echo off\r\necho 0.1\r\n");
|
||||
|
||||
expectWindowsPathsEqual(await findExecutable(command), cmd);
|
||||
});
|
||||
|
||||
test("finds a winget portable executable outside PATH", async () => {
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
const localAppData = makeTempDir();
|
||||
const packageDir = path.join(
|
||||
localAppData,
|
||||
"Microsoft",
|
||||
"WinGet",
|
||||
"Packages",
|
||||
"Anthropic.ClaudeCode_abc",
|
||||
);
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
const claude = path.join(packageDir, "claude.exe");
|
||||
copyFileSync(process.execPath, claude);
|
||||
process.env.LOCALAPPDATA = localAppData;
|
||||
process.env.PATH = makeTempDir();
|
||||
try {
|
||||
expectWindowsPathsEqual(await findExecutable("claude"), claude);
|
||||
} finally {
|
||||
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an invokable absolute path", async () => {
|
||||
await expect(findExecutable(process.execPath)).resolves.toBe(process.execPath);
|
||||
});
|
||||
|
||||
test("returns null for an absolute path that cannot spawn", async () => {
|
||||
const dir = makeTempDir();
|
||||
const fixture = writeBrokenAbsoluteFixture(dir);
|
||||
|
||||
await expect(findExecutable(fixture)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the command is not on PATH", async () => {
|
||||
const dir = makeTempDir();
|
||||
prependPath(dir);
|
||||
|
||||
await expect(findExecutable("paseo-definitely-missing-command")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("executableExists", () => {
|
||||
test("returns the path when it already exists", () => {
|
||||
const exists = (candidate: string) => candidate === "/usr/local/bin/codex";
|
||||
|
||||
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
||||
});
|
||||
|
||||
test("on Windows, falls back to .exe, then .cmd for extensionless paths", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
try {
|
||||
const exists = (candidate: string) => candidate === "C:\\tools\\codex.cmd";
|
||||
|
||||
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("on Windows, ignores PowerShell scripts for extensionless paths", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
try {
|
||||
const exists = (candidate: string) => candidate === "C:\\tools\\codex.ps1";
|
||||
|
||||
expect(executableExists("C:\\tools\\codex", exists)).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when no matching path exists", () => {
|
||||
expect(executableExists("/missing/codex", () => false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsCommand", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("escapes ampersands", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
||||
});
|
||||
|
||||
test("escapes pipes", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
||||
});
|
||||
|
||||
test("does not double percent signs", () => {
|
||||
setPlatform("win32");
|
||||
// cmd.exe only collapses %% → % inside batch files; on the command line
|
||||
// it stays literal, which corrupts git --format atoms etc.
|
||||
expect(quoteWindowsCommand("100%")).toBe("100%");
|
||||
});
|
||||
|
||||
test("preserves git --format atoms verbatim", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe(
|
||||
"--format=%^(refname^)%09%^(committerdate:unix^)",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes carets", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
||||
});
|
||||
|
||||
test("escapes multiple metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
||||
"build^&^(test^|deploy^)^!^<output^>",
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes commands with spaces after escaping metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
||||
'"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsArgument", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import { isWindowsCommandScript } from "../utils/windows-command.js";
|
||||
import { windowsExecutableResolution } from "./windows.js";
|
||||
|
||||
export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js";
|
||||
|
||||
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[]> {
|
||||
if (process.platform !== "win32" && existsSync("/usr/bin/which")) {
|
||||
return enumerateCandidatesViaSystemWhich(name);
|
||||
}
|
||||
return enumerateCandidatesViaLibrary(name);
|
||||
}
|
||||
|
||||
async function enumerateCandidatesViaSystemWhich(name: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execCommand("/usr/bin/which", ["-a", name], {
|
||||
timeout: 3000,
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
return Array.from(new Set(stdout.trim().split("\n").filter(Boolean)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function enumerateCandidatesViaLibrary(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 async function probeExecutable(
|
||||
executablePath: string,
|
||||
timeoutMs = PROBE_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await execCommand(executablePath, ["--version"], {
|
||||
timeout: timeoutMs,
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 64 * 1024,
|
||||
shell: isWindowsCommandScript(executablePath),
|
||||
});
|
||||
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 async function findExecutable(
|
||||
name: string,
|
||||
probeTimeoutMs = PROBE_TIMEOUT_MS,
|
||||
): Promise<string | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return windowsExecutableResolution.find(trimmed, {
|
||||
enumeratePathCandidates: enumerateCandidates,
|
||||
probeExecutable,
|
||||
exists: existsSync,
|
||||
probeTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPathSeparator(trimmed)) {
|
||||
return (await probeExecutable(trimmed, probeTimeoutMs)) ? trimmed : null;
|
||||
}
|
||||
|
||||
const candidates = await enumerateCandidates(trimmed);
|
||||
for (const candidate of candidates) {
|
||||
if (await probeExecutable(candidate, probeTimeoutMs)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isCommandAvailable(command: string): Promise<boolean> {
|
||||
return (await findExecutable(command)) !== null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
interface WindowsFindExecutableOptions {
|
||||
enumeratePathCandidates: (name: string) => Promise<string[]>;
|
||||
probeExecutable: (executablePath: string, timeoutMs: number) => Promise<boolean>;
|
||||
exists: (path: string) => boolean;
|
||||
localAppData?: string;
|
||||
probeTimeoutMs: number;
|
||||
}
|
||||
|
||||
interface WindowsExecutableExistsOptions {
|
||||
exists: (path: string) => boolean;
|
||||
}
|
||||
|
||||
function hasPathSeparator(value: string): boolean {
|
||||
return value.includes("/") || value.includes("\\");
|
||||
}
|
||||
|
||||
function enumerateLiteralPathCandidates(executablePath: string): string[] {
|
||||
if (extname(executablePath)) {
|
||||
return [executablePath];
|
||||
}
|
||||
return [executablePath, `${executablePath}.exe`, `${executablePath}.cmd`];
|
||||
}
|
||||
|
||||
function enumerateWingetPackageCandidates(
|
||||
name: string,
|
||||
localAppData: string | undefined,
|
||||
): string[] {
|
||||
if (!localAppData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const wingetPackages = join(localAppData, "Microsoft", "WinGet", "Packages");
|
||||
let packageDirs: string[];
|
||||
try {
|
||||
packageDirs = readdirSync(wingetPackages, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const exeName = `${name}.exe`;
|
||||
return packageDirs.map((packageDir) => join(wingetPackages, packageDir, exeName));
|
||||
}
|
||||
|
||||
async function find(input: string, options: WindowsFindExecutableOptions): Promise<string | null> {
|
||||
if (hasPathSeparator(input)) {
|
||||
return findFirstProbeable(enumerateLiteralPathCandidates(input), options);
|
||||
}
|
||||
|
||||
const pathCandidates = await options.enumeratePathCandidates(input);
|
||||
const wingetCandidates = enumerateWingetPackageCandidates(
|
||||
input,
|
||||
options.localAppData ?? process.env.LOCALAPPDATA,
|
||||
).filter(options.exists);
|
||||
|
||||
return findFirstProbeable([...pathCandidates, ...wingetCandidates], options);
|
||||
}
|
||||
|
||||
async function findFirstProbeable(
|
||||
candidates: string[],
|
||||
options: WindowsFindExecutableOptions,
|
||||
): Promise<string | null> {
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
if (await options.probeExecutable(candidate, options.probeTimeoutMs)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function exists(executablePath: string, options: WindowsExecutableExistsOptions): string | null {
|
||||
for (const candidate of enumerateLiteralPathCandidates(executablePath)) {
|
||||
if (options.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const windowsExecutableResolution = {
|
||||
exists,
|
||||
find,
|
||||
};
|
||||
Reference in New Issue
Block a user