diff --git a/packages/workspace-helper/package.json b/packages/workspace-helper/package.json index b1d97ccea9..893236887a 100644 --- a/packages/workspace-helper/package.json +++ b/packages/workspace-helper/package.json @@ -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", diff --git a/packages/workspace-helper/src/command-resolution.mjs b/packages/workspace-helper/src/command-resolution.mjs deleted file mode 100644 index ff33243225..0000000000 --- a/packages/workspace-helper/src/command-resolution.mjs +++ /dev/null @@ -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; - } -} diff --git a/packages/workspace-helper/src/executable.mjs b/packages/workspace-helper/src/executable.mjs index 6d8ceeba1f..0931b4769d 100755 --- a/packages/workspace-helper/src/executable.mjs +++ b/packages/workspace-helper/src/executable.mjs @@ -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); diff --git a/packages/workspace-helper/test/command-resolution.test.mjs b/packages/workspace-helper/test/command-resolution.test.mjs index 083f7e43eb..adfa21dbd2 100644 --- a/packages/workspace-helper/test/command-resolution.test.mjs +++ b/packages/workspace-helper/test/command-resolution.test.mjs @@ -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 }); + } + }, +);