fix(workspaces): keep helper executable standalone

This commit is contained in:
Mohamed Boudra
2026-08-15 00:31:22 +02:00
parent 6d16961d58
commit 3a8824ed99
4 changed files with 73 additions and 85 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
},
"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.copyFileSync('src/command-resolution.mjs','dist/command-resolution.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.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/command-resolution.test.mjs test/workspace-helper.posix.test.ts --bail=1",
@@ -1,55 +0,0 @@
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;
}
}
+44 -3
View File
@@ -1,12 +1,10 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { constants, watch } from "node:fs";
import { open, realpath, readdir, rename, stat, unlink } from "node:fs/promises";
import { access, 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"],
@@ -64,6 +62,49 @@ function allowedArguments(command) {
return [];
}
async function resolveCommand(workspaceRoot, name) {
if (!name) throw new Error("--name is required");
const candidates = commandCandidates(name);
if (name.includes("/") || (process.platform === "win32" && name.includes("\\"))) {
for (const candidateName of candidates) {
const candidate = path.isAbsolute(candidateName)
? candidateName
: path.resolve(workspaceRoot, candidateName);
const resolved = await resolveExecutable(candidate);
if (resolved) return resolved;
}
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),
])) {
for (const candidateName of candidates) {
const resolved = await resolveExecutable(path.join(directory, candidateName));
if (resolved) return resolved;
}
}
return null;
}
function commandCandidates(name) {
if (process.platform !== "win32") return [name];
const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
const nameExtension = path.extname(name).toLowerCase();
if (extensions.some((extension) => extension.toLowerCase() === nameExtension)) return [name];
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
async function resolveExecutable(candidate) {
try {
await access(candidate, constants.X_OK);
return await realpath(candidate);
} catch {
return null;
}
}
async function fileStat(workspaceRoot, relativePath) {
try {
const scoped = await resolveScoped(workspaceRoot, relativePath);
@@ -1,31 +1,33 @@
import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest";
import { expect, test } from "vitest";
import { resolveCommand } from "../src/command-resolution.mjs";
const run = promisify(execFile);
const workspaceHelper = fileURLToPath(new URL("../src/executable.mjs", import.meta.url));
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));
});
test.runIf(process.platform === "win32")(
"Windows command resolution honors PATHEXT",
async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-helper-windows-command-"));
try {
const executable = path.join(root, "paseo-pathext-fixture.CMD");
await writeFile(executable, "@exit /b 0\r\n");
const result = await run(
process.execPath,
[workspaceHelper, "resolve-command", "--name", "paseo-pathext-fixture"],
{
cwd: root,
env: { ...process.env, PATH: root, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
},
);
expect(JSON.parse(result.stdout)).toEqual({ path: await realpath(executable) });
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
},
);