1- import { log , retry , TmpDir } from "builder-util"
1+ import { exists , log , retry , TmpDir } from "builder-util"
22import * as childProcess from "child_process"
33import * as fs from "fs-extra"
44import { 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 = ""
0 commit comments