1'use strict' 2 3const { spawn } = require('child_process') 4const os = require('os') 5const which = require('which') 6 7const escape = require('./escape.js') 8 9// 'extra' object is for decorating the error a bit more 10const promiseSpawn = (cmd, args, opts = {}, extra = {}) => { 11 if (opts.shell) { 12 return spawnWithShell(cmd, args, opts, extra) 13 } 14 15 let proc 16 17 const p = new Promise((res, rej) => { 18 proc = spawn(cmd, args, opts) 19 20 const stdout = [] 21 const stderr = [] 22 23 const reject = er => rej(Object.assign(er, { 24 cmd, 25 args, 26 ...stdioResult(stdout, stderr, opts), 27 ...extra, 28 })) 29 30 proc.on('error', reject) 31 32 if (proc.stdout) { 33 proc.stdout.on('data', c => stdout.push(c)).on('error', reject) 34 proc.stdout.on('error', er => reject(er)) 35 } 36 37 if (proc.stderr) { 38 proc.stderr.on('data', c => stderr.push(c)).on('error', reject) 39 proc.stderr.on('error', er => reject(er)) 40 } 41 42 proc.on('close', (code, signal) => { 43 const result = { 44 cmd, 45 args, 46 code, 47 signal, 48 ...stdioResult(stdout, stderr, opts), 49 ...extra, 50 } 51 52 if (code || signal) { 53 rej(Object.assign(new Error('command failed'), result)) 54 } else { 55 res(result) 56 } 57 }) 58 }) 59 60 p.stdin = proc.stdin 61 p.process = proc 62 return p 63} 64 65const spawnWithShell = (cmd, args, opts, extra) => { 66 let command = opts.shell 67 // if shell is set to true, we use a platform default. we can't let the core 68 // spawn method decide this for us because we need to know what shell is in use 69 // ahead of time so that we can escape arguments properly. we don't need coverage here. 70 if (command === true) { 71 // istanbul ignore next 72 command = process.platform === 'win32' ? process.env.ComSpec : 'sh' 73 } 74 75 const options = { ...opts, shell: false } 76 const realArgs = [] 77 let script = cmd 78 79 // first, determine if we're in windows because if we are we need to know if we're 80 // running an .exe or a .cmd/.bat since the latter requires extra escaping 81 const isCmd = /(?:^|\\)cmd(?:\.exe)?$/i.test(command) 82 if (isCmd) { 83 let doubleEscape = false 84 85 // find the actual command we're running 86 let initialCmd = '' 87 let insideQuotes = false 88 for (let i = 0; i < cmd.length; ++i) { 89 const char = cmd.charAt(i) 90 if (char === ' ' && !insideQuotes) { 91 break 92 } 93 94 initialCmd += char 95 if (char === '"' || char === "'") { 96 insideQuotes = !insideQuotes 97 } 98 } 99 100 let pathToInitial 101 try { 102 pathToInitial = which.sync(initialCmd, { 103 path: (options.env && findInObject(options.env, 'PATH')) || process.env.PATH, 104 pathext: (options.env && findInObject(options.env, 'PATHEXT')) || process.env.PATHEXT, 105 }).toLowerCase() 106 } catch (err) { 107 pathToInitial = initialCmd.toLowerCase() 108 } 109 110 doubleEscape = pathToInitial.endsWith('.cmd') || pathToInitial.endsWith('.bat') 111 for (const arg of args) { 112 script += ` ${escape.cmd(arg, doubleEscape)}` 113 } 114 realArgs.push('/d', '/s', '/c', script) 115 options.windowsVerbatimArguments = true 116 } else { 117 for (const arg of args) { 118 script += ` ${escape.sh(arg)}` 119 } 120 realArgs.push('-c', script) 121 } 122 123 return promiseSpawn(command, realArgs, options, extra) 124} 125 126// open a file with the default application as defined by the user's OS 127const open = (_args, opts = {}, extra = {}) => { 128 const options = { ...opts, shell: true } 129 const args = [].concat(_args) 130 131 let platform = process.platform 132 // process.platform === 'linux' may actually indicate WSL, if that's the case 133 // we want to treat things as win32 anyway so the host can open the argument 134 if (platform === 'linux' && os.release().toLowerCase().includes('microsoft')) { 135 platform = 'win32' 136 } 137 138 let command = options.command 139 if (!command) { 140 if (platform === 'win32') { 141 // spawnWithShell does not do the additional os.release() check, so we 142 // have to force the shell here to make sure we treat WSL as windows. 143 options.shell = process.env.ComSpec 144 // also, the start command accepts a title so to make sure that we don't 145 // accidentally interpret the first arg as the title, we stick an empty 146 // string immediately after the start command 147 command = 'start ""' 148 } else if (platform === 'darwin') { 149 command = 'open' 150 } else { 151 command = 'xdg-open' 152 } 153 } 154 155 return spawnWithShell(command, args, options, extra) 156} 157promiseSpawn.open = open 158 159const isPipe = (stdio = 'pipe', fd) => { 160 if (stdio === 'pipe' || stdio === null) { 161 return true 162 } 163 164 if (Array.isArray(stdio)) { 165 return isPipe(stdio[fd], fd) 166 } 167 168 return false 169} 170 171const stdioResult = (stdout, stderr, { stdioString = true, stdio }) => { 172 const result = { 173 stdout: null, 174 stderr: null, 175 } 176 177 // stdio is [stdin, stdout, stderr] 178 if (isPipe(stdio, 1)) { 179 result.stdout = Buffer.concat(stdout) 180 if (stdioString) { 181 result.stdout = result.stdout.toString().trim() 182 } 183 } 184 185 if (isPipe(stdio, 2)) { 186 result.stderr = Buffer.concat(stderr) 187 if (stdioString) { 188 result.stderr = result.stderr.toString().trim() 189 } 190 } 191 192 return result 193} 194 195// case insensitive lookup in an object 196const findInObject = (obj, key) => { 197 key = key.toLowerCase() 198 for (const objKey of Object.keys(obj).sort()) { 199 if (objKey.toLowerCase() === key) { 200 return obj[objKey] 201 } 202 } 203} 204 205module.exports = promiseSpawn 206