Skip to content

Commit 3022f0f

Browse files
authored
fix: Windows build fails with spawn EINVAL after Node.js security fix (#9489)
1 parent 2c11709 commit 3022f0f

5 files changed

Lines changed: 159 additions & 17 deletions

File tree

.changeset/ninety-mugs-stop.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: Windows build fails with spawn EINVAL after Node.js security fix

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

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { log, retry, TmpDir } from "builder-util"
1+
import { exists, log, retry, TmpDir } from "builder-util"
22
import * as childProcess from "child_process"
33
import * as fs from "fs-extra"
44
import { createWriteStream } from "fs-extra"
@@ -36,6 +36,21 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
3636
private readonly tempDirManager: TmpDir
3737
) {}
3838

39+
/**
40+
* Retrieves and collects all Node.js modules for a given package.
41+
*
42+
* This method orchestrates the entire module collection process by:
43+
* 1. Fetching the dependency tree from the package manager
44+
* 2. Collecting all dependencies recursively
45+
* 3. Extracting workspace references if applicable
46+
* 4. Building a production dependency graph
47+
* 5. Hoisting the dependencies to their final locations
48+
* 6. Resolving and returning module information
49+
*
50+
* @param options - Configuration object
51+
* @param options.packageName - The name of the package to collect modules for
52+
* @returns Promise resolving to an array of NodeModuleInfo objects representing all collected modules
53+
*/
3954
public async getNodeModules({ packageName }: { packageName: string }): Promise<NodeModuleInfo[]> {
4055
const tree: ProdDepType = await this.getDependenciesTree(this.installOptions.manager)
4156

@@ -59,10 +74,20 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
5974
}
6075

6176
protected abstract getArgs(): string[]
62-
protected abstract parseDependenciesTree(jsonBlob: string): Promise<ProdDepType>
6377
protected abstract extractProductionDependencyGraph(tree: Dependency<ProdDepType, OptionalDepType>, dependencyId: string): Promise<void>
6478
protected abstract collectAllDependencies(tree: Dependency<ProdDepType, OptionalDepType>, appPackageName: string): Promise<void>
6579

80+
/**
81+
* Retrieves the dependency tree from the package manager.
82+
*
83+
* Executes the appropriate package manager command to fetch the dependency tree and writes
84+
* the output to a temporary file. Includes retry logic to handle transient failures such as
85+
* incomplete JSON output or missing files. Will retry up to 1 time with exponential backoff.
86+
*
87+
* @param pm - The package manager to use (npm, yarn, pnpm, etc.)
88+
* @returns Promise resolving to the parsed dependency tree
89+
* @throws {Error} If the dependency tree cannot be retrieved after retries
90+
*/
6691
protected async getDependenciesTree(pm: PM): Promise<ProdDepType> {
6792
const command = getPackageManagerCommand(pm)
6893
const args = this.getArgs()
@@ -76,7 +101,8 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
76101
async () => {
77102
await this.streamCollectorCommandToFile(command, args, this.rootDir, tempOutputFile)
78103
const shellOutput = await fs.readFile(tempOutputFile, { encoding: "utf8" })
79-
return await this.parseDependenciesTree(shellOutput.trim())
104+
const result = Promise.resolve(this.parseDependenciesTree(shellOutput))
105+
return result
80106
},
81107
{
82108
retries: 1,
@@ -85,7 +111,7 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
85111
shouldRetry: async (error: any) => {
86112
const logFields = { error: error.message, tempOutputFile, cwd: this.rootDir }
87113

88-
if (!(await this.cache.exists[tempOutputFile])) {
114+
if (!(await exists(tempOutputFile))) {
89115
log.debug(logFields, "dependency tree output file missing, retrying")
90116
return true
91117
}
@@ -110,6 +136,84 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
110136
)
111137
}
112138

139+
/**
140+
* Parses the dependencies tree from shell command output.
141+
*
142+
**/
143+
protected parseDependenciesTree(shellOutput: string): ProdDepType | Promise<ProdDepType> {
144+
return this.extractJsonFromPollutedOutput<ProdDepType>(shellOutput)
145+
}
146+
/**
147+
*
148+
* This method attempts to extract and parse JSON data from shell output that may contain
149+
* additional non-JSON content (like warnings or informational messages). It first tries
150+
* to parse the entire output as JSON, and if that fails, it intelligently searches for
151+
* JSON content within the output by:
152+
* 1. Finding the first line that starts with `{` or `[`
153+
* 2. Tracking bracket depth to find the matching closing bracket
154+
* 3. Extracting only the valid JSON portion
155+
*
156+
* @param shellOutput - The raw output from a shell command, potentially containing JSON
157+
* @returns The parsed dependencies tree object
158+
* @throws {Error} If no JSON content is found in the output
159+
* @throws {Error} If no matching closing bracket is found in the output
160+
* @throws {SyntaxError} If the extracted content is not valid JSON
161+
*/
162+
protected extractJsonFromPollutedOutput<T>(shellOutput: string): T {
163+
const consoleOutput = shellOutput.trim()
164+
try {
165+
return JSON.parse(consoleOutput)
166+
} catch {
167+
// Continue
168+
}
169+
170+
const lines = consoleOutput.split("\n")
171+
172+
// Find the first line that starts with { or [
173+
const jsonStartIdx = lines.findIndex(line => {
174+
const trimmed = line.trim()
175+
return trimmed.startsWith("{") || trimmed.startsWith("[")
176+
})
177+
178+
if (jsonStartIdx === -1) {
179+
throw new Error("No JSON content found in output")
180+
}
181+
182+
// Find matching closing bracket using bracket counting
183+
let depth = 0
184+
let jsonEndIdx = -1
185+
186+
for (let i = jsonStartIdx; i < lines.length; i++) {
187+
const line = lines[i]
188+
for (const char of line) {
189+
if (char === "{" || char === "[") {
190+
depth++
191+
} else if (char === "}" || char === "]") {
192+
depth--
193+
if (depth === 0) {
194+
jsonEndIdx = i
195+
break
196+
}
197+
}
198+
}
199+
if (jsonEndIdx !== -1) {
200+
break
201+
}
202+
}
203+
204+
if (jsonEndIdx === -1) {
205+
throw new Error("No matching closing bracket found in output")
206+
}
207+
208+
// Parse the matched JSON section
209+
const candidate = lines
210+
.slice(jsonStartIdx, jsonEndIdx + 1)
211+
.join("\n")
212+
.trim()
213+
214+
return JSON.parse(candidate)
215+
}
216+
113217
protected cacheKey(pkg: Pick<ProdDepType, "name" | "version" | "path">): string {
114218
const rel = path.relative(this.rootDir, pkg.path)
115219
return `${pkg.name}::${pkg.version}::${rel ?? "."}`
@@ -119,6 +223,16 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
119223
return `${pkg.name}@${pkg.version}`
120224
}
121225

226+
/**
227+
* Determines if a given dependency is a production dependency of a package.
228+
*
229+
* Checks both the dependencies and optionalDependencies of a package to see if
230+
* the specified dependency name is listed.
231+
*
232+
* @param depName - The name of the dependency to check
233+
* @param pkg - The package to search for the dependency in
234+
* @returns True if the dependency is found in either dependencies or optionalDependencies, false otherwise
235+
*/
122236
protected isProdDependency(depName: string, pkg: ProdDepType): boolean {
123237
const prodDeps = { ...pkg.dependencies, ...pkg.optionalDependencies }
124238
return prodDeps[depName] != null
@@ -133,7 +247,11 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
133247
return result
134248
}
135249
/**
136-
* Parse a dependency identifier like "@scope/pkg@1.2.3" or "pkg@1.2.3"
250+
* Parses a dependency identifier string into name and version components.
251+
*
252+
* Handles both scoped packages (e.g., "@scope/pkg@1.2.3") and regular packages (e.g., "pkg@1.2.3").
253+
* If the identifier is malformed or cannot be parsed, defaults to treating the entire string as
254+
* the package name with an "unknown" version.
137255
*/
138256
protected parseNameVersion(identifier: string): { name: string; version: string } {
139257
const lastAt = identifier.lastIndexOf("@")
@@ -146,6 +264,17 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
146264
return { name, version }
147265
}
148266

267+
/**
268+
* Retrieves the dependency tree and handles workspace package self-references.
269+
*
270+
* If the project is a workspace project, this method removes the root package's self-reference
271+
* from the dependency tree to avoid circular dependencies. It promotes the root package's
272+
* direct dependencies to the top level of the tree.
273+
*
274+
* @param tree - The original dependency tree
275+
* @param packageName - The name of the package to check for and remove from the tree
276+
* @returns Promise resolving to the pruned dependency tree
277+
*/
149278
protected async getTreeFromWorkspaces(tree: ProdDepType, packageName: string): Promise<ProdDepType> {
150279
if (!(tree.workspaces && tree.dependencies)) {
151280
return tree
@@ -234,6 +363,22 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
234363
}
235364
}
236365

366+
/**
367+
* Executes a command and streams its output to a file.
368+
*
369+
* Spawns a child process to execute the specified command with arguments, capturing stdout
370+
* to a file. Handles Windows-specific quirks by wrapping .cmd files in a temporary .bat file
371+
* when necessary. Enables corepack strict mode by default but allows process.env overrides.
372+
*
373+
* Special handling for `npm list` exit code 1, which is expected in certain scenarios.
374+
*
375+
* @param command - The command to execute
376+
* @param args - Array of command-line arguments
377+
* @param cwd - The working directory to execute the command in
378+
* @param tempOutputFile - The path to the temporary file where stdout will be written
379+
* @returns Promise that resolves when the command completes successfully or rejects if it fails
380+
* @throws {Error} If the child process spawn fails or exits with a non-zero code
381+
*/
237382
async streamCollectorCommandToFile(command: string, args: string[], cwd: string, tempOutputFile: string) {
238383
const execName = path.basename(command, path.extname(command))
239384
const isWindowsScriptFile = process.platform === "win32" && path.extname(command).toLowerCase() === ".cmd"
@@ -258,7 +403,7 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
258403
const child = childProcess.spawn(command, args, {
259404
cwd,
260405
env: { COREPACK_ENABLE_STRICT: "0", ...process.env }, // allow `process.env` overrides
261-
shell: false, // required to prevent console logs polution from shell profile loading when `true`
406+
shell: true, // `true`` is now required: https://github.com/electron-userland/electron-builder/issues/9488
262407
})
263408

264409
let stderr = ""

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,4 @@ export class NpmNodeModulesCollector extends NodeModulesCollector<NpmDependency,
6767
protected isProdDependency(packageName: string, tree: NpmDependency) {
6868
return tree._dependencies?.[packageName] != null
6969
}
70-
71-
protected async parseDependenciesTree(jsonBlob: string): Promise<NpmDependency> {
72-
return Promise.resolve(JSON.parse(jsonBlob))
73-
}
7470
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ export class PnpmNodeModulesCollector extends NodeModulesCollector<PnpmDependenc
8484
return `${pkg.from}@${pkg.version}`
8585
}
8686

87-
protected async parseDependenciesTree(jsonBlob: string): Promise<PnpmDependency> {
88-
const dependencyTree: PnpmDependency[] = JSON.parse(jsonBlob)
87+
protected parseDependenciesTree(jsonBlob: string): PnpmDependency {
8988
// pnpm returns an array of dependency trees
90-
return Promise.resolve(dependencyTree[0])
89+
const dependencyTree: PnpmDependency[] = this.extractJsonFromPollutedOutput<PnpmDependency[]>(jsonBlob)
90+
return dependencyTree[0]
9191
}
9292
}

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ export class TraversalNodeModulesCollector extends NodeModulesCollector<Traverse
4646
this.productionGraph[dependencyId] = { dependencies: collectedDependencies }
4747
}
4848

49-
protected async parseDependenciesTree(jsonBlob: string): Promise<TraversedDependency> {
50-
return Promise.resolve(JSON.parse(jsonBlob))
51-
}
52-
5349
/**
5450
* Builds a dependency tree using only package.json dependencies and optionalDependencies.
5551
* This skips devDependencies and uses Node.js module resolution (require.resolve).

0 commit comments

Comments
 (0)