11import  {  hoist ,  type  HoisterTree ,  type  HoisterResult  }  from  "./hoist" 
22import  *  as  path  from  "path" 
3- import  *  as  fs  from  "fs" 
3+ import  *  as  fs  from  "fs-extra " 
44import  type  {  NodeModuleInfo ,  DependencyGraph ,  Dependency  }  from  "./types" 
5- import  {  exec ,  log  }  from  "builder-util" 
5+ import  {  exists ,  log ,   retry ,   TmpDir  }  from  "builder-util" 
66import  {  getPackageManagerCommand ,  PM  }  from  "./packageManager" 
7+ import  {  exec ,  spawn  }  from  "child_process" 
8+ import  {  promisify  }  from  "util" 
9+ import  {  createWriteStream  }  from  "fs" 
10+ 
11+ const  execAsync  =  promisify ( exec ) 
712
813export  abstract  class  NodeModulesCollector < T  extends  Dependency < T ,  OptionalsType > ,  OptionalsType >  { 
914  private  nodeModules : NodeModuleInfo [ ]  =  [ ] 
1015  protected  allDependencies : Map < string ,  T >  =  new  Map ( ) 
1116  protected  productionGraph : DependencyGraph  =  { } 
1217
13-   constructor ( private  readonly  rootDir : string )  { } 
18+   constructor ( 
19+     private  readonly  rootDir : string , 
20+     private  readonly  tempDirManager : TmpDir 
21+   )  { } 
1422
1523  public  async  getNodeModules ( ) : Promise < NodeModuleInfo [ ] >  { 
1624    const  tree : T  =  await  this . getDependenciesTree ( ) 
@@ -37,11 +45,43 @@ export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType
3745  protected  async  getDependenciesTree ( ) : Promise < T >  { 
3846    const  command  =  getPackageManagerCommand ( this . installOptions . manager ) 
3947    const  args  =  this . getArgs ( ) 
40-     const  dependencies  =  await  exec ( command ,  args ,  { 
41-       cwd : this . rootDir , 
42-       shell : true , 
48+ 
49+     const  tempOutputFile  =  await  this . tempDirManager . getTempFile ( { 
50+       prefix : path . basename ( command ,  path . extname ( command ) ) , 
51+       suffix : "output.json" , 
4352    } ) 
44-     return  this . parseDependenciesTree ( dependencies ) 
53+ 
54+     return  retry ( 
55+       async  ( )  =>  { 
56+         await  this . streamCollectorCommandToJsonFile ( command ,  args ,  this . rootDir ,  tempOutputFile ) 
57+         const  dependencies  =  await  fs . readFile ( tempOutputFile ,  {  encoding : "utf8"  } ) 
58+         try  { 
59+           return  this . parseDependenciesTree ( dependencies ) 
60+         }  catch  ( error : any )  { 
61+           log . debug ( {  message : error . message  ||  error . stack ,  shellOutput : dependencies  } ,  "error parsing dependencies tree" ) 
62+           throw  new  Error ( `Failed to parse dependencies tree: ${ error . message  ||  error . stack }  . Use DEBUG=electron-builder env var to see the dependency query output.` ) 
63+         } 
64+       } , 
65+       { 
66+         retries : 2 , 
67+         interval : 2000 , 
68+         backoff : 2000 , 
69+         shouldRetry : async  ( error : any )  =>  { 
70+           if  ( ! ( await  exists ( tempOutputFile ) ) )  { 
71+             log . error ( {  error : error . message  ||  error . stack ,  tempOutputFile } ,  "error getting dependencies tree, unable to find output; retrying" ) 
72+             return  true 
73+           } 
74+           const  dependencies  =  await  fs . readFile ( tempOutputFile ,  {  encoding : "utf8"  } ) 
75+           if  ( dependencies . trim ( ) . length  ===  0  ||  error . message ?. includes ( "Unexpected end of JSON input" ) )  { 
76+             // If the output file is empty or contains invalid JSON, we retry 
77+             // This can happen if the command fails or if the output is not as expected 
78+             log . error ( {  error : error . message  ||  error . stack ,  tempOutputFile } ,  "dependency tree output file is empty, retrying" ) 
79+             return  true 
80+           } 
81+           return  false 
82+         } , 
83+       } 
84+     ) 
4585  } 
4686
4787  protected  resolvePath ( filePath : string ) : string  { 
@@ -117,4 +157,60 @@ export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType
117157    } 
118158    result . sort ( ( a ,  b )  =>  a . name . localeCompare ( b . name ) ) 
119159  } 
160+ 
161+   static  async  safeExec ( command : string ,  args : string [ ] ,  cwd : string ) : Promise < string >  { 
162+     const  payload  =  await  execAsync ( [ `"${ command }  "` ,  ...args ] . join ( " " ) ,  {  cwd,  maxBuffer : 100  *  1024  *  1024  } )  // 100MB buffer LOL, some projects can have extremely large dependency trees 
163+     return  payload . stdout . trim ( ) 
164+   } 
165+ 
166+   async  streamCollectorCommandToJsonFile ( command : string ,  args : string [ ] ,  cwd : string ,  tempOutputFile : string )  { 
167+     const  execName  =  path . basename ( command ,  path . extname ( command ) ) 
168+     const  isWindowsScriptFile  =  process . platform  ===  "win32"  &&  path . extname ( command ) . toLowerCase ( )  ===  ".cmd" 
169+     if  ( isWindowsScriptFile )  { 
170+       // If the command is a Windows script file (.cmd), we need to wrap it in a .bat file to ensure it runs correctly with cmd.exe 
171+       // This is necessary because .cmd files are not directly executable in the same way as .bat files. 
172+       // We create a temporary .bat file that calls the .cmd file with the provided arguments. The .bat file will be executed by cmd.exe. 
173+       const  tempBatFile  =  await  this . tempDirManager . getTempFile ( { 
174+         prefix : execName , 
175+         suffix : ".bat" , 
176+       } ) 
177+       const  batScript  =  `@echo off\r\n"${ command }  " %*\r\n`  // <-- CRLF required for .bat 
178+       await  fs . writeFile ( tempBatFile ,  batScript ,  {  encoding : "utf8"  } ) 
179+       command  =  "cmd.exe" 
180+       args  =  [ "/c" ,  tempBatFile ,  ...args ] 
181+     } 
182+ 
183+     await  new  Promise < void > ( ( resolve ,  reject )  =>  { 
184+       const  outStream  =  createWriteStream ( tempOutputFile ) 
185+ 
186+       const  child  =  spawn ( command ,  args ,  { 
187+         cwd, 
188+         shell : false ,  // required to prevent console logs polution from shell profile loading when `true` 
189+       } ) 
190+ 
191+       let  stderr  =  "" 
192+       child . stdout . pipe ( outStream ) 
193+       child . stderr . on ( "data" ,  chunk  =>  { 
194+         stderr  +=  chunk . toString ( ) 
195+       } ) 
196+       child . on ( "error" ,  err  =>  { 
197+         reject ( new  Error ( `Spawn failed: ${ err . message }  ` ) ) 
198+       } ) 
199+ 
200+       child . on ( "close" ,  code  =>  { 
201+         outStream . close ( ) 
202+         // https://github.com/npm/npm/issues/17624 
203+         if  ( code  ===  1  &&  execName . toLowerCase ( )  ===  "npm"  &&  args . includes ( "list" ) )  { 
204+           log . debug ( {  code,  stderr } ,  "`npm list` returned non-zero exit code, but it MIGHT be expected (https://github.com/npm/npm/issues/17624). Check stderr for details." ) 
205+           // This is a known issue with npm list command, it can return code 1 even when the command is "technically" successful 
206+           resolve ( ) 
207+           return 
208+         } 
209+         if  ( code  !==  0 )  { 
210+           return  reject ( new  Error ( `Process exited with code ${ code }  :\n${ stderr }  ` ) ) 
211+         } 
212+         resolve ( ) 
213+       } ) 
214+     } ) 
215+   } 
120216} 
0 commit comments