fix(workspaces): resolve Windows runtime commands

This commit is contained in:
Mohamed Boudra
2026-08-15 00:24:34 +02:00
parent 16e0967408
commit 6d16961d58
4 changed files with 91 additions and 30 deletions
+2 -2
View File
@@ -21,10 +21,10 @@
},
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false && node -e \"const fs=require('node:fs');fs.copyFileSync('src/executable.mjs','dist/executable.mjs');fs.chmodSync('dist/executable.mjs',0o755)\"",
"build": "tsc -p tsconfig.json --incremental false && node -e \"const fs=require('node:fs');fs.copyFileSync('src/executable.mjs','dist/executable.mjs');fs.copyFileSync('src/command-resolution.mjs','dist/command-resolution.mjs');fs.chmodSync('dist/executable.mjs',0o755)\"",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"test": "npm run build && vitest run test/workspace-helper.posix.test.ts --bail=1",
"test": "npm run build && vitest run test/command-resolution.test.mjs test/workspace-helper.posix.test.ts --bail=1",
"test:standalone": "node --test test/standalone.test.mjs",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
@@ -0,0 +1,55 @@
import { constants } from "node:fs";
import { access, realpath } from "node:fs/promises";
import path from "node:path";
export async function resolveCommand(
workspaceRoot,
name,
{
environment = process.env,
executablePath = process.execPath,
platform = process.platform,
} = {},
) {
if (!name) throw new Error("--name is required");
if (name.includes("/") || (platform === "win32" && name.includes("\\"))) {
const candidate = path.isAbsolute(name) ? name : path.resolve(workspaceRoot, name);
return resolveFirstExecutable(executableCandidates(candidate, platform, environment));
}
const searchPath = environment.PATH ?? "/usr/local/bin:/usr/bin:/bin";
const delimiter = platform === "win32" ? ";" : path.delimiter;
for (const directory of new Set([path.dirname(executablePath), ...searchPath.split(delimiter)])) {
const resolved = await resolveFirstExecutable(
executableCandidates(path.join(directory, name), platform, environment),
);
if (resolved) return resolved;
}
return null;
}
function executableCandidates(candidate, platform, environment) {
if (platform !== "win32") return [candidate];
const extensions = (environment.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
const candidateExtension = path.extname(candidate).toLowerCase();
if (extensions.some((extension) => extension.toLowerCase() === candidateExtension)) {
return [candidate];
}
return [candidate, ...extensions.map((extension) => `${candidate}${extension}`)];
}
async function resolveFirstExecutable(candidates) {
for (const candidate of candidates) {
const resolved = await resolveExecutable(candidate);
if (resolved) return resolved;
}
return null;
}
async function resolveExecutable(candidate) {
try {
await access(candidate, constants.X_OK);
return await realpath(candidate);
} catch {
return null;
}
}
+3 -28
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { constants, watch } from "node:fs";
import { access, open, realpath, readdir, rename, stat, unlink } from "node:fs/promises";
import { open, realpath, readdir, rename, stat, unlink } from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { resolveCommand } from "./command-resolution.mjs";
const outsideMessage = "Access outside of workspace is not allowed";
const imageTypes = new Map([
[".png", "image/png"],
@@ -62,33 +64,6 @@ function allowedArguments(command) {
return [];
}
async function resolveCommand(workspaceRoot, name) {
if (!name) throw new Error("--name is required");
if (name.includes("/") || (process.platform === "win32" && name.includes("\\"))) {
const candidate = path.isAbsolute(name) ? name : path.resolve(workspaceRoot, name);
try {
await access(candidate, constants.X_OK);
return await realpath(candidate);
} catch {
return null;
}
}
const searchPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
for (const directory of new Set([
path.dirname(process.execPath),
...searchPath.split(path.delimiter),
])) {
const candidate = path.join(directory, name);
try {
await access(candidate, constants.X_OK);
return await realpath(candidate);
} catch {
// Continue through the runtime's own search path.
}
}
return null;
}
async function fileStat(workspaceRoot, relativePath) {
try {
const scoped = await resolveScoped(workspaceRoot, relativePath);
@@ -0,0 +1,31 @@
import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { resolveCommand } from "../src/command-resolution.mjs";
const cleanupRoots = [];
afterEach(async () => {
await Promise.all(
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("Windows command resolution honors PATHEXT", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-helper-windows-command-"));
cleanupRoots.push(root);
const executable = path.join(root, "git.EXE");
await writeFile(executable, "");
await chmod(executable, 0o755);
await expect(
resolveCommand(root, "git", {
environment: { PATH: root, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
executablePath: path.join(root, "node.EXE"),
platform: "win32",
}),
).resolves.toBe(await realpath(executable));
});