11import child_process from "child_process" ;
2+ import spawn from "cross-spawn" ;
3+ import { dirname , join } from "path" ;
24import { promisify } from "util" ;
35const 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 */
9092export 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}
0 commit comments