Skip to content

Commit fb465ac

Browse files
use parallel pnpmexec
1 parent 8a64f11 commit fb465ac

7 files changed

Lines changed: 234 additions & 29 deletions

File tree

.github/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"@apidevtools/json-schema-ref-parser": "catalog:",
1111
"@azure-tools/specs-shared": "workspace:*",
1212
"@js-temporal/polyfill": "0.5.1",
13+
"cross-spawn": "catalog:",
1314
"debug": "catalog:",
1415
"js-yaml": "catalog:",
1516
"marked": "catalog:",
@@ -31,6 +32,7 @@
3132
"@octokit/types": "^16.0.0",
3233
"@octokit/webhooks-types": "^7.5.1",
3334
"@tsconfig/node20": "^20.1.4",
35+
"@types/cross-spawn": "catalog:",
3436
"@types/debug": "catalog:",
3537
"@types/js-yaml": "catalog:",
3638
"@types/node": "catalog:",

.github/shared/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
},
3737
"dependencies": {
3838
"@apidevtools/json-schema-ref-parser": "catalog:",
39+
"cross-spawn": "catalog:",
3940
"debug": "catalog:",
4041
"js-yaml": "catalog:",
4142
"marked": "catalog:",
@@ -45,6 +46,7 @@
4546
"devDependencies": {
4647
"@eslint/js": "catalog:",
4748
"@tsconfig/node20": "^20.1.4",
49+
"@types/cross-spawn": "catalog:",
4850
"@types/debug": "catalog:",
4951
"@types/js-yaml": "catalog:",
5052
"@types/node": "catalog:",

.github/shared/readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ Single source of truth for breaking-change and versioning approval label names a
6767
- `execFile(file, args, options)` — promisified `child_process.execFile`.
6868
- `execNpm(args, options)` — run an `npm` command.
6969
- `execNpmExec(args, options)` — run an `npm exec` command.
70+
- `execPnpm(args, options)` — run a `pnpm` command (via `cross-spawn`).
71+
- `execPnpmExec(args, options)` — run a `pnpm exec` command (via `cross-spawn`).
7072

7173
### `git` — git helpers
7274

.github/shared/src/exec.js

Lines changed: 130 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import child_process from "child_process";
2+
import spawn from "cross-spawn";
3+
import { dirname, join } from "path";
24
import { promisify } from "util";
35
const execFileImpl = promisify(child_process.execFile);
46

@@ -11,7 +13,7 @@ const execFileImpl = promisify(child_process.execFile);
1113

1214
/**
1315
* @typedef {Object} NpmPrefixOptions
14-
* @property {string} [prefix] Prefix to pass to pnpm via "--prefix".
16+
* @property {string} [prefix] Prefix to pass to npm/pnpm via "--prefix".
1517
*/
1618

1719
/**
@@ -80,57 +82,157 @@ export async function execFile(file, args, options = {}) {
8082
}
8183

8284
/**
83-
* Calls `execFile()` with appropriate arguments to run `pnpm` on all platforms
85+
* Calls `execFile()` with appropriate arguments to run `npm` on all platforms
8486
*
8587
* @param {string[]} args
8688
* @param {ExecNpmOptions} [options]
8789
* @returns {Promise<ExecResult>}
8890
* @throws {ExecError}
8991
*/
9092
export async function execNpm(args, options = {}) {
91-
const { prefix, cwd, logger, maxBuffer = 16 * 1024 * 1024 } = options;
93+
const { prefix } = options;
94+
95+
// Exclude platform-specific code from coverage
96+
/* v8 ignore start */
97+
const { file, defaultArgs } =
98+
process.platform === "win32"
99+
? {
100+
// Only way I could find to run "npm" on Windows, without using the shell (e.g. "cmd /c npm ...")
101+
//
102+
// "node.exe", ["--", "npm-cli.js", ...args]
103+
//
104+
// The "--" MUST come BEFORE "npm-cli.js", to ensure args are sent to the script unchanged.
105+
// If the "--" comes after "npm-cli.js", the args sent to the script will be ["--", ...args],
106+
// which is NOT equivalent, and can break if args itself contains another "--".
107+
108+
// example: "C:\Program Files\nodejs\node.exe"
109+
file: process.execPath,
110+
111+
// example: "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js"
112+
defaultArgs: [
113+
"--",
114+
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
115+
],
116+
}
117+
: { file: "npm", defaultArgs: [] };
118+
/* v8 ignore stop */
92119

93120
const prefixArgs = prefix ? ["--prefix", prefix] : [];
94-
const allArgs = [...prefixArgs, ...args];
95121

96-
logger?.info(`execNpm(${JSON.stringify(allArgs)})`);
122+
return await execFile(file, [...defaultArgs, ...prefixArgs, ...args], options);
123+
}
97124

98-
try {
99-
const isWindows = process.platform === "win32";
125+
/**
126+
* Calls `execNpm()` with arguments ["exec", "--no", "--"] prepended.
127+
*
128+
* @param {string[]} args
129+
* @param {ExecNpmOptions} [options]
130+
* @returns {Promise<ExecResult>}
131+
* @throws {ExecError}
132+
*/
133+
export async function execNpmExec(args, options = {}) {
134+
return await execNpm(["exec", "--no", "--", ...args], options);
135+
}
100136

101-
// On Windows, pnpm is installed as the "pnpm.cmd" batch shim, which can only be
102-
// launched through a shell: since the fix for CVE-2024-27980, Node refuses to
103-
// spawn .cmd/.bat files unless shell is enabled. On other platforms, call the
104-
// "pnpm" binary directly with shell disabled to avoid shell quoting/parsing risks.
105-
// Exclude the platform-specific branch from coverage (only one side runs per OS).
106-
/* v8 ignore next */
107-
const file = isWindows ? "pnpm.cmd" : "pnpm";
137+
/**
138+
* Calls `cross-spawn` with appropriate arguments to run `pnpm` on all platforms.
139+
*
140+
* Uses `cross-spawn` instead of `child_process.execFile()` so that the `pnpm.cmd`
141+
* batch shim can be resolved and launched safely on Windows without enabling a
142+
* shell. Enabling a shell (e.g. `shell: true`) would reintroduce quoting and
143+
* shell-parsing risks, so it is intentionally avoided.
144+
*
145+
* @param {string[]} args
146+
* @param {ExecNpmOptions} [options]
147+
* @returns {Promise<ExecResult>}
148+
* @throws {ExecError}
149+
*/
150+
export async function execPnpm(args, options = {}) {
151+
const { prefix, cwd, logger, maxBuffer = 16 * 1024 * 1024 } = options;
108152

109-
const result = await execFileImpl(file, allArgs, {
110-
cwd,
111-
maxBuffer,
112-
shell: isWindows,
153+
const prefixArgs = prefix ? ["--prefix", prefix] : [];
154+
const allArgs = [...prefixArgs, ...args];
155+
156+
logger?.info(`execPnpm(${JSON.stringify(allArgs)})`);
157+
158+
return await new Promise((resolve, reject) => {
159+
// cross-spawn resolves "pnpm" to the "pnpm.cmd" shim on Windows and spawns it
160+
// directly (shell disabled), avoiding shell quoting/parsing risks while still
161+
// working cross-platform.
162+
const child = spawn("pnpm", allArgs, { cwd });
163+
164+
let stdout = "";
165+
let stderr = "";
166+
let settled = false;
167+
168+
/**
169+
* Ensures the promise is settled at most once, even though several events
170+
* (data overflow, error, close) may fire after the result is known.
171+
*
172+
* @param {() => void} action
173+
*/
174+
const settle = (action) => {
175+
if (settled) return;
176+
settled = true;
177+
action();
178+
};
179+
180+
/** @param {ExecError} error */
181+
const fail = (error) =>
182+
settle(() => {
183+
error.stdout = stdout;
184+
error.stderr = stderr;
185+
logger?.debug(`error: '${JSON.stringify(error)}'`);
186+
reject(error);
187+
});
188+
189+
/** @param {"stdout" | "stderr"} streamName */
190+
const failMaxBuffer = (streamName) => {
191+
child.kill();
192+
const error = /** @type {ExecError} */ (new Error(`${streamName} maxBuffer length exceeded`));
193+
error.code = /** @type {any} */ ("ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
194+
fail(error);
195+
};
196+
197+
child.stdout?.on("data", (data) => {
198+
stdout += data;
199+
if (Buffer.byteLength(stdout) > maxBuffer) failMaxBuffer("stdout");
113200
});
114201

115-
logger?.debug(`stdout: '${result.stdout}'`);
116-
logger?.debug(`stderr: '${result.stderr}'`);
202+
child.stderr?.on("data", (data) => {
203+
stderr += data;
204+
if (Buffer.byteLength(stderr) > maxBuffer) failMaxBuffer("stderr");
205+
});
117206

118-
return result;
119-
} catch (error) {
207+
// Only fires if the process could not be spawned at all (e.g. "pnpm" missing).
120208
/* v8 ignore next */
121-
logger?.debug(`error: '${JSON.stringify(error)}'`);
122-
throw error;
123-
}
209+
child.on("error", (error) => fail(/** @type {ExecError} */ (error)));
210+
211+
child.on("close", (code) => {
212+
logger?.debug(`stdout: '${stdout}'`);
213+
logger?.debug(`stderr: '${stderr}'`);
214+
215+
if (code === 0) {
216+
settle(() => resolve({ stdout, stderr }));
217+
} else {
218+
const error = /** @type {ExecError} */ (
219+
new Error(`pnpm ${allArgs.join(" ")} exited with code ${code}`)
220+
);
221+
error.code = /** @type {any} */ (code);
222+
fail(error);
223+
}
224+
});
225+
});
124226
}
125227

126228
/**
127-
* Calls `pnpm exec` with the given arguments.
229+
* Calls `execPnpm()` with arguments ["exec", ...] prepended.
128230
*
129231
* @param {string[]} args
130232
* @param {ExecNpmOptions} [options]
131233
* @returns {Promise<ExecResult>}
132234
* @throws {ExecError}
133235
*/
134-
export async function execNpmExec(args, options = {}) {
135-
return await execNpm(["exec", ...args], options);
236+
export async function execPnpmExec(args, options = {}) {
237+
return await execPnpm(["exec", ...args], options);
136238
}

.github/shared/test/exec.test.js

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { dirname } from "path";
22
import semver from "semver";
33
import { fileURLToPath } from "url";
44
import { describe, expect, it } from "vitest";
5-
import { execFile, execNpm, execNpmExec, isExecError } from "../src/exec.js";
5+
import {
6+
execFile,
7+
execNpm,
8+
execNpmExec,
9+
execPnpm,
10+
execPnpmExec,
11+
isExecError,
12+
} from "../src/exec.js";
613
import { debugLogger } from "../src/logger.js";
714

815
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -77,6 +84,69 @@ describe("execNpmExec", () => {
7784
});
7885
});
7986

87+
describe("execPnpm", () => {
88+
it("succeeds with --version", async () => {
89+
await expect(execPnpm(["--version"], options)).resolves.toMatchObject({
90+
stdout: /** @type {unknown} */ (expect.toSatisfy((v) => semver.valid(String(v)) !== null)),
91+
stderr: "",
92+
});
93+
});
94+
95+
it("succeeds with root", async () => {
96+
// "pnpm root" in this dir returns the node_modules path of the nearest workspace root
97+
const result = await execPnpm(["root"], { ...options, cwd: __dirname });
98+
expect(result.stdout.trim()).toContain("node_modules");
99+
});
100+
101+
it("succeeds with prefix option", async () => {
102+
const result = await execPnpm(["--version"], { ...options, prefix: __dirname });
103+
expect(semver.valid(result.stdout.trim())).not.toBeNull();
104+
});
105+
106+
it("fails with invalid command", async () => {
107+
await expect(execPnpm(["invalid-command-xyz"], options)).rejects.toMatchObject({
108+
code: /** @type {unknown} */ (expect.toSatisfy((v) => v !== 0)),
109+
});
110+
});
111+
112+
it("fails when stdout exceeds maxBuffer", async () => {
113+
await expect(execPnpm(["--version"], { ...options, maxBuffer: 1 })).rejects.toMatchObject({
114+
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
115+
});
116+
});
117+
118+
it("captures stderr", async () => {
119+
const result = await execPnpm(
120+
["exec", "node", "-e", "process.stderr.write('hello-stderr')"],
121+
options,
122+
);
123+
expect(result.stderr).toContain("hello-stderr");
124+
});
125+
126+
it("fails when stderr exceeds maxBuffer", async () => {
127+
await expect(
128+
execPnpm(["exec", "node", "-e", "process.stderr.write('x'.repeat(100))"], {
129+
...options,
130+
maxBuffer: 1,
131+
}),
132+
).rejects.toMatchObject({
133+
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
134+
});
135+
});
136+
});
137+
138+
describe("execPnpmExec", () => {
139+
// A command run in the context of "pnpm exec ___" needs to call
140+
// something referenced in package.json. In this case, prettier is present
141+
// so it is used.
142+
it("runs prettier", async () => {
143+
await expect(execPnpmExec(["prettier", "--version"], options)).resolves.toMatchObject({
144+
stdout: /** @type {unknown} */ (expect.toSatisfy((v) => semver.valid(String(v)) !== null)),
145+
stderr: "",
146+
});
147+
});
148+
});
149+
80150
describe("isExecError", () => {
81151
it("isExecError", () => {
82152
expect(isExecError("test")).toBe(false);

0 commit comments

Comments
 (0)