Skip to content

Commit 478ade4

Browse files
authored
fix(node-module-collector): suppress expected npm list stderr from user-visible warnings when collecting dependencies for Yarn projects using resolutions (#9810)
1 parent bb8cb67 commit 478ade4

3 files changed

Lines changed: 125 additions & 24 deletions

File tree

.changeset/many-lamps-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
fix(node-module-collector): suppress expected `npm list` stderr from user-visible warnings when collecting dependencies for Yarn projects using resolutions

packages/app-builder-lib/src/node-module-collector/nodeModulesCollector.ts

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -357,29 +357,15 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
357357
* @throws {Error} If the child process spawn fails or exits with a non-zero code
358358
*/
359359
protected async streamCollectorCommandToFile(command: string, args: string[], cwd: string, tempOutputFile: string) {
360+
// Capture execName from the original command before resolveWindowsCommand may rewrite it to
361+
// cmd.exe — shouldIgnore must key off the original invocation (e.g. "npm", not "cmd").
360362
const execName = path.basename(command, path.extname(command))
361-
const ext = path.extname(command).toLowerCase()
362-
// Wrap .cmd files in a .bat file for correct cmd.exe execution.
363-
// Also wrap any Windows executable path that contains spaces (e.g. extensionless shims
364-
// from tools like Volta installed at "C:\Program Files\Volta\") to ensure the path is
365-
// quoted correctly when passed to `spawn(..., { shell: true })`.
366-
const isWindowsScriptFile = process.platform === "win32" && (ext === ".cmd" || command.includes(" "))
367-
if (isWindowsScriptFile) {
368-
// We need to wrap it in a .bat file to ensure it runs correctly with cmd.exe
369-
// This is necessary because some files (like .cmd) are not directly executable in the same way as .bat files.
370-
// We create a temporary .bat file that calls the script-like file with the provided arguments. The .bat file will be executed by cmd.exe.
371-
// Note: This is a workaround for Windows command execution quirks when using `shell: true`
372-
const tempBatFile = await this.tempDirManager.getTempFile({
373-
prefix: execName + "-" + randomBytes(8).toString("hex"),
374-
suffix: ".bat",
375-
})
376-
const escapedCommand = command.replace(/"/g, `""`)
377-
const batScript = `@echo off\r\n"${escapedCommand}" %*\r\n` // <-- CRLF required for .bat
378-
await fs.writeFile(tempBatFile, batScript, { encoding: "utf8", mode: 0o600 })
379-
command = "cmd.exe"
380-
args = ["/c", `"${tempBatFile}"`, ...args]
381-
}
382363

364+
if (process.platform === "win32") {
365+
const resolved = await this.resolveWindowsCommand(command, args)
366+
command = resolved.command
367+
args = resolved.args
368+
}
383369
const isWindows = process.platform === "win32"
384370

385371
// shell: true is required on Windows — see https://github.com/electron-userland/electron-builder/issues/9488
@@ -424,11 +410,47 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
424410
}
425411
if (stderr.length > 0) {
426412
log.debug({ stderr }, "note: there was node module collector output on stderr")
427-
this.cache.logSummary[LogMessageByKey.PKG_COLLECTOR_OUTPUT].push(stderr)
413+
// Only surface stderr as a user-visible warning when the exit code itself is unexpected.
414+
// When shouldIgnore is true (npm list exit code 1) the stderr is an anticipated side
415+
// effect of package-manager features like yarn resolutions or npm overrides that cause
416+
// npm to report ELSPROBLEMS for aliased packages it considers "invalid".
417+
if (!shouldIgnore) {
418+
this.cache.logSummary[LogMessageByKey.PKG_COLLECTOR_OUTPUT].push(stderr)
419+
}
428420
}
429421
const shouldResolve = code === 0 || shouldIgnore
430422
return shouldResolve ? resolve() : reject(new Error(`Node module collector process exited with code ${code}:\n${stderr}`))
431423
})
432424
})
433425
}
426+
427+
/**
428+
* Windows-only. Wraps .cmd files and executables at paths containing spaces in a temporary .bat
429+
* file so that cmd.exe spawns them correctly. Returns the original command/args unchanged when
430+
* no wrapping is required. Must only be called on win32.
431+
*/
432+
private async resolveWindowsCommand(command: string, args: string[]): Promise<{ command: string; args: string[] }> {
433+
if (process.platform !== "win32") {
434+
throw new Error("resolveWindowsCommand should only be called on Windows platforms")
435+
}
436+
437+
const ext = path.extname(command).toLowerCase()
438+
// Wrap .cmd files and any Windows executable path that contains spaces (e.g. extensionless
439+
// shims from tools like Volta installed at "C:\Program Files\Volta\") to ensure the path is
440+
// quoted correctly when passed to `spawn(..., { shell: true })`.
441+
const needsWrap = ext === ".cmd" || command.includes(" ")
442+
if (!needsWrap) {
443+
return { command, args }
444+
}
445+
446+
const execName = path.basename(command, ext)
447+
const tempBatFile = await this.tempDirManager.getTempFile({
448+
prefix: execName + "-" + randomBytes(8).toString("hex"),
449+
suffix: ".bat",
450+
})
451+
const escapedCommand = command.replace(/"/g, `""`)
452+
const batScript = `@echo off\r\n"${escapedCommand}" %*\r\n` // <-- CRLF required for .bat
453+
await fs.writeFile(tempBatFile, batScript, { encoding: "utf8", mode: 0o600 })
454+
return { command: "cmd.exe", args: ["/c", `"${tempBatFile}"`, ...args] }
455+
}
434456
}

test/src/node-module-collector/streamCollectorTest.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, test, vi } from "vitest"
22
import { NodeModulesCollector } from "app-builder-lib/src/node-module-collector/nodeModulesCollector"
3+
import { LogMessageByKey } from "app-builder-lib/src/node-module-collector/moduleManager"
34
import { PM } from "app-builder-lib/src/node-module-collector/packageManager"
45
import * as childProcess from "child_process"
56
import * as fsExtra from "fs-extra"
@@ -19,10 +20,13 @@ class TestCollector extends NodeModulesCollector<any, any> {
1920
}
2021
protected async extractProductionDependencyGraph() {}
2122
protected async collectAllDependencies() {}
22-
// expose protected method for testing
23+
// expose protected members for testing
2324
streamCollectorCommandToFile(command: string, args: string[], cwd: string, tempOutputFile: string) {
2425
return super.streamCollectorCommandToFile(command, args, cwd, tempOutputFile)
2526
}
27+
get logSummary() {
28+
return this.cache.logSummary
29+
}
2630
}
2731

2832
const BAT_PATH = "C:\\Temp\\pnpm-deadbeef.bat"
@@ -36,6 +40,7 @@ function setPlatform(p: NodeJS.Platform) {
3640

3741
let collector: TestCollector
3842
let closeCb: ((code: number) => void) | undefined
43+
let stderrDataCb: ((chunk: string) => void) | undefined
3944

4045
async function waitForCloseCb() {
4146
for (let i = 0; i < 50 && closeCb === undefined; i++) {
@@ -48,9 +53,14 @@ async function waitForCloseCb() {
4853

4954
beforeEach(() => {
5055
closeCb = undefined
56+
stderrDataCb = undefined
5157
const mockChild = {
5258
stdout: { pipe: vi.fn() },
53-
stderr: { on: vi.fn() },
59+
stderr: {
60+
on: vi.fn((ev: string, cb: (chunk: string) => void) => {
61+
if (ev === "data") stderrDataCb = cb
62+
}),
63+
},
5464
on: vi.fn((ev: string, cb: (code: number) => void) => {
5565
if (ev === "close") closeCb = cb
5666
}),
@@ -142,6 +152,7 @@ describe.sequential("streamCollectorCommandToFile", () => {
142152
setPlatform("win32")
143153
const cmd = "C:\\tools\\pnpm.exe"
144154
const p = collector.streamCollectorCommandToFile(cmd, [], "/cwd", OUTPUT_FILE)
155+
await waitForCloseCb()
145156
closeCb!(0)
146157
await p
147158
expect(vi.mocked(childProcess.spawn).mock.calls[0][0]).toBe(cmd)
@@ -151,6 +162,7 @@ describe.sequential("streamCollectorCommandToFile", () => {
151162
test("extensionless at path without spaces: spawn receives original command", async ({ expect }) => {
152163
setPlatform("win32")
153164
const p = collector.streamCollectorCommandToFile("pnpm", [], "/cwd", OUTPUT_FILE)
165+
await waitForCloseCb()
154166
closeCb!(0)
155167
await p
156168
expect(vi.mocked(childProcess.spawn).mock.calls[0][0]).toBe("pnpm")
@@ -161,10 +173,72 @@ describe.sequential("streamCollectorCommandToFile", () => {
161173
setPlatform("darwin")
162174
const cmd = "/Applications/Volta/pnpm.exe"
163175
const p = collector.streamCollectorCommandToFile(cmd, [], "/cwd", OUTPUT_FILE)
176+
await waitForCloseCb()
164177
closeCb!(0)
165178
await p
166179
expect(vi.mocked(childProcess.spawn).mock.calls[0][0]).toBe(cmd)
167180
expect(vi.mocked(fsExtra.writeFile)).not.toHaveBeenCalled()
168181
})
169182
})
183+
184+
describe("stderr and exit code behavior", () => {
185+
test("npm list exit code 1: stderr is NOT surfaced as a collector warning", async ({ expect }) => {
186+
const p = collector.streamCollectorCommandToFile("npm", ["list", "--json"], "/cwd", OUTPUT_FILE)
187+
await waitForCloseCb()
188+
stderrDataCb?.("npm error code ELSPROBLEMS\nnpm error invalid: canvas@npm:npm-empty-stub@1.0.1\n")
189+
closeCb!(1)
190+
await p
191+
expect(collector.logSummary[LogMessageByKey.PKG_COLLECTOR_OUTPUT]).toHaveLength(0)
192+
})
193+
194+
test("npm list exit code 0 with stderr: stderr IS surfaced as a collector warning", async ({ expect }) => {
195+
const p = collector.streamCollectorCommandToFile("npm", ["list", "--json"], "/cwd", OUTPUT_FILE)
196+
await waitForCloseCb()
197+
stderrDataCb?.("npm warn some unexpected warning\n")
198+
closeCb!(0)
199+
await p
200+
const output = collector.logSummary[LogMessageByKey.PKG_COLLECTOR_OUTPUT]
201+
expect(output).toHaveLength(1)
202+
expect(output[0]).toContain("npm warn some unexpected warning")
203+
})
204+
205+
test("non-npm command exit code 1: rejects with stderr in error message", async ({ expect }) => {
206+
const p = collector.streamCollectorCommandToFile("pnpm", ["list", "--json"], "/cwd", OUTPUT_FILE)
207+
await waitForCloseCb()
208+
stderrDataCb?.("pnpm error: something went wrong\n")
209+
closeCb!(1)
210+
await expect(p).rejects.toThrow("pnpm error: something went wrong")
211+
})
212+
213+
test("npm command (not list) exit code 1: rejects", async ({ expect }) => {
214+
const p = collector.streamCollectorCommandToFile("npm", ["install"], "/cwd", OUTPUT_FILE)
215+
await waitForCloseCb()
216+
stderrDataCb?.("npm error: install failed\n")
217+
closeCb!(1)
218+
await expect(p).rejects.toThrow()
219+
})
220+
221+
test("npm list with full path exit code 1: stderr suppressed (shouldIgnore applies to basename)", async ({ expect }) => {
222+
const p = collector.streamCollectorCommandToFile("/usr/local/bin/npm", ["list", "--json"], "/cwd", OUTPUT_FILE)
223+
await waitForCloseCb()
224+
stderrDataCb?.("npm error code ELSPROBLEMS\nnpm error invalid: some-pkg@npm:stub@1.0.0\n")
225+
closeCb!(1)
226+
await p
227+
expect(collector.logSummary[LogMessageByKey.PKG_COLLECTOR_OUTPUT]).toHaveLength(0)
228+
})
229+
230+
test("win32 npm.cmd wrapped via cmd.exe: exit code 1 still triggers shouldIgnore", async ({ expect }) => {
231+
// Regression: execName must be derived from the original command ("npm"), not the rewritten
232+
// cmd.exe invocation, otherwise shouldIgnore never fires and npm list exit code 1 rejects.
233+
// Use "npm.cmd" without a backslash-prefixed directory so path.basename works cross-platform
234+
// in the test environment (macOS path.basename ignores backslash separators).
235+
setPlatform("win32")
236+
const p = collector.streamCollectorCommandToFile("npm.cmd", ["list", "--json"], "/cwd", OUTPUT_FILE)
237+
await waitForCloseCb()
238+
stderrDataCb?.("npm error code ELSPROBLEMS\nnpm error invalid: canvas@npm:npm-empty-stub@1.0.1\n")
239+
closeCb!(1)
240+
await p
241+
expect(collector.logSummary[LogMessageByKey.PKG_COLLECTOR_OUTPUT]).toHaveLength(0)
242+
})
243+
})
170244
})

0 commit comments

Comments
 (0)