• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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