Skip to content

Commit 43bbde4

Browse files
committed
fix(build): respect PATH-less pnpm environments
1 parent 7d3e8dc commit 43bbde4

5 files changed

Lines changed: 124 additions & 30 deletions

File tree

scripts/lib/format-generated-module.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export function resolveGeneratedModuleFormatter(params) {
2525

2626
return resolvePnpmRunner({
2727
comSpec: params.comSpec,
28+
cwd: params.repoRoot,
29+
env: params.env,
2830
npmExecPath: params.npmExecPath,
2931
nodeExecPath: params.nodeExecPath,
3032
platform,

scripts/pnpm-runner.mjs

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { spawn } from "node:child_process";
33
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
44
import path from "node:path";
5-
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
5+
import { buildCmdExeCommandLine, resolvePathEnvKey } from "./windows-cmd-helpers.mjs";
66

77
function getPortableBasename(value) {
88
return value.split(/[/\\]/).at(-1) ?? value;
@@ -55,22 +55,47 @@ function isFile(value) {
5555
}
5656
}
5757

58-
function findExecutableOnPath(command, envPath) {
58+
function findExecutableOnPath(command, envPath, platform, env, cwd) {
5959
if (typeof envPath !== "string" || envPath.length === 0) {
6060
return null;
6161
}
62-
for (const directory of envPath.split(path.delimiter)) {
62+
const extensions =
63+
platform === "win32"
64+
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
65+
".COM;.EXE;.BAT;.CMD")
66+
.split(";")
67+
.filter(Boolean)
68+
.map((extension) => extension.toLowerCase())
69+
: [""];
70+
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
71+
for (const directory of envPath.split(pathDelimiter)) {
6372
if (!directory) {
6473
continue;
6574
}
66-
const candidate = path.join(directory, command);
67-
if (isExecutableFile(candidate)) {
68-
return candidate;
75+
const resolvedDirectory = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory);
76+
for (const extension of extensions) {
77+
const candidate = path.join(resolvedDirectory, `${command}${extension}`);
78+
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
79+
return candidate;
80+
}
6981
}
7082
}
7183
return null;
7284
}
7385

86+
function createWindowsRunner(command, args, comSpec) {
87+
const extension = getPortableExtension(command);
88+
if (extension === ".cmd" || extension === ".bat") {
89+
return {
90+
command: comSpec,
91+
args: ["/d", "/s", "/c", buildCmdExeCommandLine(command, args)],
92+
shell: false,
93+
windowsVerbatimArguments: true,
94+
};
95+
}
96+
return { command, args, shell: false };
97+
}
98+
7499
function isNodeRunnablePnpmExecPath(value) {
75100
if (!isPnpmExecPath(value)) {
76101
return false;
@@ -95,7 +120,9 @@ export function resolvePnpmRunner(params = {}) {
95120
const nodeExecPath = params.nodeExecPath ?? process.execPath;
96121
const platform = params.platform ?? process.platform;
97122
const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
98-
const envPath = params.env?.PATH ?? process.env.PATH;
123+
const env = params.env ?? process.env;
124+
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
125+
const cwd = params.cwd ?? process.cwd();
99126

100127
if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isPnpmExecPath(npmExecPath)) {
101128
if (isNodeRunnablePnpmExecPath(npmExecPath)) {
@@ -131,30 +158,22 @@ export function resolvePnpmRunner(params = {}) {
131158
}
132159
}
133160

134-
if (platform === "win32") {
135-
return {
136-
command: comSpec,
137-
args: ["/d", "/s", "/c", buildCmdExeCommandLine("pnpm.cmd", pnpmArgs)],
138-
shell: false,
139-
windowsVerbatimArguments: true,
140-
};
141-
}
142-
143-
const pnpmPath = findExecutableOnPath("pnpm", envPath);
161+
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
144162
if (pnpmPath) {
145-
return {
146-
command: pnpmPath,
147-
args: pnpmArgs,
148-
shell: false,
149-
};
163+
return platform === "win32"
164+
? createWindowsRunner(pnpmPath, pnpmArgs, comSpec)
165+
: { command: pnpmPath, args: pnpmArgs, shell: false };
150166
}
151-
const corepackPath = findExecutableOnPath("corepack", envPath);
167+
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
152168
if (corepackPath) {
153-
return {
154-
command: corepackPath,
155-
args: ["pnpm", ...pnpmArgs],
156-
shell: false,
157-
};
169+
const args = ["pnpm", ...pnpmArgs];
170+
return platform === "win32"
171+
? createWindowsRunner(corepackPath, args, comSpec)
172+
: { command: corepackPath, args, shell: false };
173+
}
174+
175+
if (platform === "win32") {
176+
return createWindowsRunner("pnpm.cmd", pnpmArgs, comSpec);
158177
}
159178

160179
return {

scripts/ui.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ export function resolveSpawnCall(cmd, args, envOverride, params = {}) {
6767
export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) {
6868
const env = envOverride ?? process.env;
6969
const platform = params.platform ?? process.platform;
70+
const cwd = params.cwd ?? uiDir;
7071
const runner = resolvePnpmRunner({
72+
cwd,
7173
env,
7274
pnpmArgs,
7375
nodeExecPath: params.nodeExecPath ?? process.execPath,
@@ -79,7 +81,7 @@ export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) {
7981
command: runner.command,
8082
args: runner.args,
8183
options: {
82-
cwd: params.cwd ?? uiDir,
84+
cwd,
8385
stdio: "inherit",
8486
env,
8587
shell: runner.shell,

test/scripts/format-generated-module.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe("resolveGeneratedModuleFormatter", () => {
4949
expect(
5050
resolveGeneratedModuleFormatter({
5151
comSpec: "C:\\Windows\\System32\\cmd.exe",
52+
env: { PATH: "" },
5253
existsSync: () => false,
5354
npmExecPath: "",
5455
outputPath: "C:\\Users\\test\\AppData\\Local\\Temp\\generated output.ts",

test/scripts/pnpm-runner.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { describe, expect, it } from "vitest";
66
import { createPnpmRunnerSpawnSpec, resolvePnpmRunner } from "../../scripts/pnpm-runner.mjs";
7+
import { buildCmdExeCommandLine } from "../../scripts/windows-cmd-helpers.mjs";
78

89
describe("resolvePnpmRunner", () => {
910
const posixIt = process.platform === "win32" ? it.skip : it;
@@ -171,6 +172,7 @@ describe("resolvePnpmRunner", () => {
171172
expect(
172173
resolvePnpmRunner({
173174
comSpec: "C:\\Windows\\System32\\cmd.exe",
175+
env: { PATH: "" },
174176
npmExecPath:
175177
"C:\\Users\\test\\AppData\\Local\\Temp\\cache\\corepack\\v1\\pnpm\\10.32.1\\bin\\pnpm.mjs",
176178
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
@@ -221,6 +223,43 @@ describe("resolvePnpmRunner", () => {
221223
});
222224
});
223225

226+
posixIt("does not resolve executables from the parent PATH for an explicit empty env", () => {
227+
expect(
228+
resolvePnpmRunner({
229+
npmExecPath: "",
230+
env: {},
231+
pnpmArgs: ["exec", "vitest", "run"],
232+
platform: "linux",
233+
}),
234+
).toEqual({
235+
command: "pnpm",
236+
args: ["exec", "vitest", "run"],
237+
shell: false,
238+
});
239+
});
240+
241+
posixIt("resolves relative PATH entries from the child working directory", () => {
242+
const childDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-child-"));
243+
244+
try {
245+
expect(
246+
resolvePnpmRunner({
247+
cwd: childDir,
248+
npmExecPath: "",
249+
env: { PATH: "node_modules/.bin" },
250+
pnpmArgs: ["exec", "vitest", "run"],
251+
platform: "linux",
252+
}),
253+
).toEqual({
254+
command: "pnpm",
255+
args: ["exec", "vitest", "run"],
256+
shell: false,
257+
});
258+
} finally {
259+
rmSync(childDir, { recursive: true, force: true });
260+
}
261+
});
262+
224263
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
225264
const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-corepack-"));
226265
const corepackPath = path.join(tempDir, "corepack");
@@ -276,8 +315,8 @@ describe("resolvePnpmRunner", () => {
276315
expect(
277316
resolvePnpmRunner({
278317
comSpec: "C:\\Windows\\System32\\cmd.exe",
279-
npmExecPath: "",
280318
env: { PATH: "" },
319+
npmExecPath: "",
281320
pnpmArgs: ["exec", "vitest", "run", "-t", "path with spaces"],
282321
platform: "win32",
283322
}),
@@ -289,10 +328,41 @@ describe("resolvePnpmRunner", () => {
289328
});
290329
});
291330

331+
it("uses Corepack on Windows when no pnpm shim is available", () => {
332+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-corepack-"));
333+
const corepackPath = path.join(tempDir, "corepack.cmd");
334+
writeFileSync(corepackPath, "@exit /b 0\r\n");
335+
336+
try {
337+
expect(
338+
resolvePnpmRunner({
339+
comSpec: "C:\\Windows\\System32\\cmd.exe",
340+
npmExecPath: "",
341+
env: { Path: tempDir, PATHEXT: ".CMD;.EXE" },
342+
pnpmArgs: ["exec", "vitest", "run"],
343+
platform: "win32",
344+
}),
345+
).toEqual({
346+
command: "C:\\Windows\\System32\\cmd.exe",
347+
args: [
348+
"/d",
349+
"/s",
350+
"/c",
351+
buildCmdExeCommandLine(corepackPath, ["pnpm", "exec", "vitest", "run"]),
352+
],
353+
shell: false,
354+
windowsVerbatimArguments: true,
355+
});
356+
} finally {
357+
rmSync(tempDir, { recursive: true, force: true });
358+
}
359+
});
360+
292361
it("escapes caret arguments for Windows cmd.exe", () => {
293362
expect(
294363
resolvePnpmRunner({
295364
comSpec: "C:\\Windows\\System32\\cmd.exe",
365+
env: { PATH: "" },
296366
npmExecPath: "",
297367
pnpmArgs: ["exec", "vitest", "-t", "@scope/pkg@^1.2.3"],
298368
platform: "win32",

0 commit comments

Comments
 (0)