From 43bbde483046efad36d40720b05189dcddb5c5ec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 10 Jun 2026 17:57:42 +0900 Subject: [PATCH] fix(build): respect PATH-less pnpm environments --- scripts/lib/format-generated-module.mjs | 2 + scripts/pnpm-runner.mjs | 77 ++++++++++++-------- scripts/ui.js | 4 +- test/scripts/format-generated-module.test.ts | 1 + test/scripts/pnpm-runner.test.ts | 72 +++++++++++++++++- 5 files changed, 125 insertions(+), 31 deletions(-) diff --git a/scripts/lib/format-generated-module.mjs b/scripts/lib/format-generated-module.mjs index 497609881c51..7775b0d12460 100644 --- a/scripts/lib/format-generated-module.mjs +++ b/scripts/lib/format-generated-module.mjs @@ -25,6 +25,8 @@ export function resolveGeneratedModuleFormatter(params) { return resolvePnpmRunner({ comSpec: params.comSpec, + cwd: params.repoRoot, + env: params.env, npmExecPath: params.npmExecPath, nodeExecPath: params.nodeExecPath, platform, diff --git a/scripts/pnpm-runner.mjs b/scripts/pnpm-runner.mjs index 50eace24e3e5..2cf1424da7e8 100644 --- a/scripts/pnpm-runner.mjs +++ b/scripts/pnpm-runner.mjs @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs"; import path from "node:path"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolvePathEnvKey } from "./windows-cmd-helpers.mjs"; function getPortableBasename(value) { return value.split(/[/\\]/).at(-1) ?? value; @@ -55,22 +55,47 @@ function isFile(value) { } } -function findExecutableOnPath(command, envPath) { +function findExecutableOnPath(command, envPath, platform, env, cwd) { if (typeof envPath !== "string" || envPath.length === 0) { return null; } - for (const directory of envPath.split(path.delimiter)) { + const extensions = + platform === "win32" + ? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ?? + ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean) + .map((extension) => extension.toLowerCase()) + : [""]; + const pathDelimiter = platform === "win32" ? ";" : path.delimiter; + for (const directory of envPath.split(pathDelimiter)) { if (!directory) { continue; } - const candidate = path.join(directory, command); - if (isExecutableFile(candidate)) { - return candidate; + const resolvedDirectory = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory); + for (const extension of extensions) { + const candidate = path.join(resolvedDirectory, `${command}${extension}`); + if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) { + return candidate; + } } } return null; } +function createWindowsRunner(command, args, comSpec) { + const extension = getPortableExtension(command); + if (extension === ".cmd" || extension === ".bat") { + return { + command: comSpec, + args: ["/d", "/s", "/c", buildCmdExeCommandLine(command, args)], + shell: false, + windowsVerbatimArguments: true, + }; + } + return { command, args, shell: false }; +} + function isNodeRunnablePnpmExecPath(value) { if (!isPnpmExecPath(value)) { return false; @@ -95,7 +120,9 @@ export function resolvePnpmRunner(params = {}) { const nodeExecPath = params.nodeExecPath ?? process.execPath; const platform = params.platform ?? process.platform; const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe"; - const envPath = params.env?.PATH ?? process.env.PATH; + const env = params.env ?? process.env; + const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"]; + const cwd = params.cwd ?? process.cwd(); if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isPnpmExecPath(npmExecPath)) { if (isNodeRunnablePnpmExecPath(npmExecPath)) { @@ -131,30 +158,22 @@ export function resolvePnpmRunner(params = {}) { } } - if (platform === "win32") { - return { - command: comSpec, - args: ["/d", "/s", "/c", buildCmdExeCommandLine("pnpm.cmd", pnpmArgs)], - shell: false, - windowsVerbatimArguments: true, - }; + const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd); + if (pnpmPath) { + return platform === "win32" + ? createWindowsRunner(pnpmPath, pnpmArgs, comSpec) + : { command: pnpmPath, args: pnpmArgs, shell: false }; + } + const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd); + if (corepackPath) { + const args = ["pnpm", ...pnpmArgs]; + return platform === "win32" + ? createWindowsRunner(corepackPath, args, comSpec) + : { command: corepackPath, args, shell: false }; } - const pnpmPath = findExecutableOnPath("pnpm", envPath); - if (pnpmPath) { - return { - command: pnpmPath, - args: pnpmArgs, - shell: false, - }; - } - const corepackPath = findExecutableOnPath("corepack", envPath); - if (corepackPath) { - return { - command: corepackPath, - args: ["pnpm", ...pnpmArgs], - shell: false, - }; + if (platform === "win32") { + return createWindowsRunner("pnpm.cmd", pnpmArgs, comSpec); } return { diff --git a/scripts/ui.js b/scripts/ui.js index 7e8b646306d9..6385c4025966 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -67,7 +67,9 @@ export function resolveSpawnCall(cmd, args, envOverride, params = {}) { export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) { const env = envOverride ?? process.env; const platform = params.platform ?? process.platform; + const cwd = params.cwd ?? uiDir; const runner = resolvePnpmRunner({ + cwd, env, pnpmArgs, nodeExecPath: params.nodeExecPath ?? process.execPath, @@ -79,7 +81,7 @@ export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) { command: runner.command, args: runner.args, options: { - cwd: params.cwd ?? uiDir, + cwd, stdio: "inherit", env, shell: runner.shell, diff --git a/test/scripts/format-generated-module.test.ts b/test/scripts/format-generated-module.test.ts index f974c7d6db0c..725a60de4e1d 100644 --- a/test/scripts/format-generated-module.test.ts +++ b/test/scripts/format-generated-module.test.ts @@ -49,6 +49,7 @@ describe("resolveGeneratedModuleFormatter", () => { expect( resolveGeneratedModuleFormatter({ comSpec: "C:\\Windows\\System32\\cmd.exe", + env: { PATH: "" }, existsSync: () => false, npmExecPath: "", outputPath: "C:\\Users\\test\\AppData\\Local\\Temp\\generated output.ts", diff --git a/test/scripts/pnpm-runner.test.ts b/test/scripts/pnpm-runner.test.ts index d90d1bf6f18f..6268e4ecdc72 100644 --- a/test/scripts/pnpm-runner.test.ts +++ b/test/scripts/pnpm-runner.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createPnpmRunnerSpawnSpec, resolvePnpmRunner } from "../../scripts/pnpm-runner.mjs"; +import { buildCmdExeCommandLine } from "../../scripts/windows-cmd-helpers.mjs"; describe("resolvePnpmRunner", () => { const posixIt = process.platform === "win32" ? it.skip : it; @@ -171,6 +172,7 @@ describe("resolvePnpmRunner", () => { expect( resolvePnpmRunner({ comSpec: "C:\\Windows\\System32\\cmd.exe", + env: { PATH: "" }, npmExecPath: "C:\\Users\\test\\AppData\\Local\\Temp\\cache\\corepack\\v1\\pnpm\\10.32.1\\bin\\pnpm.mjs", nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", @@ -221,6 +223,43 @@ describe("resolvePnpmRunner", () => { }); }); + posixIt("does not resolve executables from the parent PATH for an explicit empty env", () => { + expect( + resolvePnpmRunner({ + npmExecPath: "", + env: {}, + pnpmArgs: ["exec", "vitest", "run"], + platform: "linux", + }), + ).toEqual({ + command: "pnpm", + args: ["exec", "vitest", "run"], + shell: false, + }); + }); + + posixIt("resolves relative PATH entries from the child working directory", () => { + const childDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-child-")); + + try { + expect( + resolvePnpmRunner({ + cwd: childDir, + npmExecPath: "", + env: { PATH: "node_modules/.bin" }, + pnpmArgs: ["exec", "vitest", "run"], + platform: "linux", + }), + ).toEqual({ + command: "pnpm", + args: ["exec", "vitest", "run"], + shell: false, + }); + } finally { + rmSync(childDir, { recursive: true, force: true }); + } + }); + posixIt("uses Corepack when pnpm is not directly available on PATH", () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-corepack-")); const corepackPath = path.join(tempDir, "corepack"); @@ -276,8 +315,8 @@ describe("resolvePnpmRunner", () => { expect( resolvePnpmRunner({ comSpec: "C:\\Windows\\System32\\cmd.exe", - npmExecPath: "", env: { PATH: "" }, + npmExecPath: "", pnpmArgs: ["exec", "vitest", "run", "-t", "path with spaces"], platform: "win32", }), @@ -289,10 +328,41 @@ describe("resolvePnpmRunner", () => { }); }); + it("uses Corepack on Windows when no pnpm shim is available", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-corepack-")); + const corepackPath = path.join(tempDir, "corepack.cmd"); + writeFileSync(corepackPath, "@exit /b 0\r\n"); + + try { + expect( + resolvePnpmRunner({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + npmExecPath: "", + env: { Path: tempDir, PATHEXT: ".CMD;.EXE" }, + pnpmArgs: ["exec", "vitest", "run"], + platform: "win32", + }), + ).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + buildCmdExeCommandLine(corepackPath, ["pnpm", "exec", "vitest", "run"]), + ], + shell: false, + windowsVerbatimArguments: true, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("escapes caret arguments for Windows cmd.exe", () => { expect( resolvePnpmRunner({ comSpec: "C:\\Windows\\System32\\cmd.exe", + env: { PATH: "" }, npmExecPath: "", pnpmArgs: ["exec", "vitest", "-t", "@scope/pkg@^1.2.3"], platform: "win32",