From 6d16961d5863a6a4153b6f21699a10a4e9d8f75d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 15 Aug 2026 00:24:34 +0200 Subject: [PATCH] fix(workspaces): resolve Windows runtime commands --- packages/workspace-helper/package.json | 4 +- .../src/command-resolution.mjs | 55 +++++++++++++++++++ packages/workspace-helper/src/executable.mjs | 31 +---------- .../test/command-resolution.test.mjs | 31 +++++++++++ 4 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 packages/workspace-helper/src/command-resolution.mjs create mode 100644 packages/workspace-helper/test/command-resolution.test.mjs diff --git a/packages/workspace-helper/package.json b/packages/workspace-helper/package.json index 9174d5d059..b1d97ccea9 100644 --- a/packages/workspace-helper/package.json +++ b/packages/workspace-helper/package.json @@ -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" }, diff --git a/packages/workspace-helper/src/command-resolution.mjs b/packages/workspace-helper/src/command-resolution.mjs new file mode 100644 index 0000000000..ff33243225 --- /dev/null +++ b/packages/workspace-helper/src/command-resolution.mjs @@ -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; + } +} diff --git a/packages/workspace-helper/src/executable.mjs b/packages/workspace-helper/src/executable.mjs index 0bcf91fc74..6d8ceeba1f 100755 --- a/packages/workspace-helper/src/executable.mjs +++ b/packages/workspace-helper/src/executable.mjs @@ -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); diff --git a/packages/workspace-helper/test/command-resolution.test.mjs b/packages/workspace-helper/test/command-resolution.test.mjs new file mode 100644 index 0000000000..083f7e43eb --- /dev/null +++ b/packages/workspace-helper/test/command-resolution.test.mjs @@ -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)); +});