@npmcli/promise-spawn
Advanced tools
| 'use strict' | ||
| // eslint-disable-next-line max-len | ||
| // this code adapted from: https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ | ||
| const cmd = (input, doubleEscape) => { | ||
| if (!input.length) { | ||
| return '""' | ||
| } | ||
| let result | ||
| if (!/[ \t\n\v"]/.test(input)) { | ||
| result = input | ||
| } else { | ||
| result = '"' | ||
| for (let i = 0; i <= input.length; ++i) { | ||
| let slashCount = 0 | ||
| while (input[i] === '\\') { | ||
| ++i | ||
| ++slashCount | ||
| } | ||
| if (i === input.length) { | ||
| result += '\\'.repeat(slashCount * 2) | ||
| break | ||
| } | ||
| if (input[i] === '"') { | ||
| result += '\\'.repeat(slashCount * 2 + 1) | ||
| result += input[i] | ||
| } else { | ||
| result += '\\'.repeat(slashCount) | ||
| result += input[i] | ||
| } | ||
| } | ||
| result += '"' | ||
| } | ||
| // and finally, prefix shell meta chars with a ^ | ||
| result = result.replace(/[ !%^&()<>|"]/g, '^$&') | ||
| if (doubleEscape) { | ||
| result = result.replace(/[ !%^&()<>|"]/g, '^$&') | ||
| } | ||
| return result | ||
| } | ||
| const sh = (input) => { | ||
| if (!input.length) { | ||
| return `''` | ||
| } | ||
| if (!/[\t\n\r "#$&'()*;<>?\\`|~]/.test(input)) { | ||
| return input | ||
| } | ||
| // replace single quotes with '\'' and wrap the whole result in a fresh set of quotes | ||
| const result = `'${input.replace(/'/g, `'\\''`)}'` | ||
| // if the input string already had single quotes around it, clean those up | ||
| .replace(/^(?:'')+(?!$)/, '') | ||
| .replace(/\\'''/g, `\\'`) | ||
| return result | ||
| } | ||
| module.exports = { | ||
| cmd, | ||
| sh, | ||
| } |
+144
-11
@@ -0,15 +1,23 @@ | ||
| 'use strict' | ||
| const { spawn } = require('child_process') | ||
| const os = require('os') | ||
| const which = require('which') | ||
| const isPipe = (stdio = 'pipe', fd) => | ||
| stdio === 'pipe' || stdio === null ? true | ||
| : Array.isArray(stdio) ? isPipe(stdio[fd], fd) | ||
| : false | ||
| const escape = require('./escape.js') | ||
| // 'extra' object is for decorating the error a bit more | ||
| const promiseSpawn = (cmd, args, opts = {}, extra = {}) => { | ||
| if (opts.shell) { | ||
| return spawnWithShell(cmd, args, opts, extra) | ||
| } | ||
| let proc | ||
| const p = new Promise((res, rej) => { | ||
| proc = spawn(cmd, args, opts) | ||
| const stdout = [] | ||
| const stderr = [] | ||
| const reject = er => rej(Object.assign(er, { | ||
@@ -21,3 +29,5 @@ cmd, | ||
| })) | ||
| proc.on('error', reject) | ||
| if (proc.stdout) { | ||
@@ -27,2 +37,3 @@ proc.stdout.on('data', c => stdout.push(c)).on('error', reject) | ||
| } | ||
| if (proc.stderr) { | ||
@@ -32,2 +43,3 @@ proc.stderr.on('data', c => stderr.push(c)).on('error', reject) | ||
| } | ||
| proc.on('close', (code, signal) => { | ||
@@ -42,2 +54,3 @@ const result = { | ||
| } | ||
| if (code || signal) { | ||
@@ -56,12 +69,132 @@ rej(Object.assign(new Error('command failed'), result)) | ||
| const stdioResult = (stdout, stderr, { stdioString, stdio }) => | ||
| stdioString ? { | ||
| stdout: isPipe(stdio, 1) ? Buffer.concat(stdout).toString().trim() : null, | ||
| stderr: isPipe(stdio, 2) ? Buffer.concat(stderr).toString().trim() : null, | ||
| const spawnWithShell = (cmd, args, opts, extra) => { | ||
| let command = opts.shell | ||
| // if shell is set to true, we use a platform default. we can't let the core | ||
| // spawn method decide this for us because we need to know what shell is in use | ||
| // ahead of time so that we can escape arguments properly. we don't need coverage here. | ||
| if (command === true) { | ||
| // istanbul ignore next | ||
| command = process.platform === 'win32' ? process.env.ComSpec : 'sh' | ||
| } | ||
| : { | ||
| stdout: isPipe(stdio, 1) ? Buffer.concat(stdout) : null, | ||
| stderr: isPipe(stdio, 2) ? Buffer.concat(stderr) : null, | ||
| const options = { ...opts, shell: false } | ||
| const realArgs = [] | ||
| let script = cmd | ||
| // first, determine if we're in windows because if we are we need to know if we're | ||
| // running an .exe or a .cmd/.bat since the latter requires extra escaping | ||
| const isCmd = /(?:^|\\)cmd(?:\.exe)?$/i.test(command) | ||
| if (isCmd) { | ||
| let doubleEscape = false | ||
| // find the actual command we're running | ||
| let initialCmd = '' | ||
| let insideQuotes = false | ||
| for (let i = 0; i < cmd.length; ++i) { | ||
| const char = cmd.charAt(i) | ||
| if (char === ' ' && !insideQuotes) { | ||
| break | ||
| } | ||
| initialCmd += char | ||
| if (char === '"' || char === "'") { | ||
| insideQuotes = !insideQuotes | ||
| } | ||
| } | ||
| let pathToInitial | ||
| try { | ||
| pathToInitial = which.sync(initialCmd, { | ||
| path: (options.env && options.env.PATH) || process.env.PATH, | ||
| pathext: (options.env && options.env.PATHEXT) || process.env.PATHEXT, | ||
| }).toLowerCase() | ||
| } catch (err) { | ||
| pathToInitial = initialCmd.toLowerCase() | ||
| } | ||
| doubleEscape = pathToInitial.endsWith('.cmd') || pathToInitial.endsWith('.bat') | ||
| for (const arg of args) { | ||
| script += ` ${escape.cmd(arg, doubleEscape)}` | ||
| } | ||
| realArgs.push('/d', '/s', '/c', script) | ||
| options.windowsVerbatimArguments = true | ||
| } else { | ||
| for (const arg of args) { | ||
| script += ` ${escape.sh(arg)}` | ||
| } | ||
| realArgs.push('-c', script) | ||
| } | ||
| return promiseSpawn(command, realArgs, options, extra) | ||
| } | ||
| // open a file with the default application as defined by the user's OS | ||
| const open = (_args, opts = {}, extra = {}) => { | ||
| const options = { ...opts, shell: true } | ||
| const args = [].concat(_args) | ||
| let platform = process.platform | ||
| // process.platform === 'linux' may actually indicate WSL, if that's the case | ||
| // we want to treat things as win32 anyway so the host can open the argument | ||
| if (platform === 'linux' && os.release().includes('Microsoft')) { | ||
| platform = 'win32' | ||
| } | ||
| let command = options.command | ||
| if (!command) { | ||
| if (platform === 'win32') { | ||
| // spawnWithShell does not do the additional os.release() check, so we | ||
| // have to force the shell here to make sure we treat WSL as windows. | ||
| options.shell = process.env.ComSpec | ||
| // also, the start command accepts a title so to make sure that we don't | ||
| // accidentally interpret the first arg as the title, we stick an empty | ||
| // string immediately after the start command | ||
| command = 'start ""' | ||
| } else if (platform === 'darwin') { | ||
| command = 'open' | ||
| } else { | ||
| command = 'xdg-open' | ||
| } | ||
| } | ||
| return spawnWithShell(command, args, options, extra) | ||
| } | ||
| promiseSpawn.open = open | ||
| const isPipe = (stdio = 'pipe', fd) => { | ||
| if (stdio === 'pipe' || stdio === null) { | ||
| return true | ||
| } | ||
| if (Array.isArray(stdio)) { | ||
| return isPipe(stdio[fd], fd) | ||
| } | ||
| return false | ||
| } | ||
| const stdioResult = (stdout, stderr, { stdioString = true, stdio }) => { | ||
| const result = { | ||
| stdout: null, | ||
| stderr: null, | ||
| } | ||
| // stdio is [stdin, stdout, stderr] | ||
| if (isPipe(stdio, 1)) { | ||
| result.stdout = Buffer.concat(stdout) | ||
| if (stdioString) { | ||
| result.stdout = result.stdout.toString().trim() | ||
| } | ||
| } | ||
| if (isPipe(stdio, 2)) { | ||
| result.stderr = Buffer.concat(stderr) | ||
| if (stdioString) { | ||
| result.stderr = result.stderr.toString().trim() | ||
| } | ||
| } | ||
| return result | ||
| } | ||
| module.exports = promiseSpawn |
+7
-3
| { | ||
| "name": "@npmcli/promise-spawn", | ||
| "version": "5.0.0", | ||
| "version": "6.0.0", | ||
| "files": [ | ||
@@ -35,4 +35,5 @@ "bin/", | ||
| "@npmcli/eslint-config": "^4.0.0", | ||
| "@npmcli/template-oss": "4.7.1", | ||
| "@npmcli/template-oss": "4.8.0", | ||
| "minipass": "^3.1.1", | ||
| "spawk": "^1.7.1", | ||
| "tap": "^16.0.1" | ||
@@ -45,4 +46,7 @@ }, | ||
| "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", | ||
| "version": "4.7.1" | ||
| "version": "4.8.0" | ||
| }, | ||
| "dependencies": { | ||
| "which": "^2.0.2" | ||
| } | ||
| } |
+24
-2
@@ -14,3 +14,3 @@ # @npmcli/promise-spawn | ||
| cwd: '/tmp/some/path', // defaults to process.cwd() | ||
| stdioString: false, // stdout/stderr as strings rather than buffers | ||
| stdioString: true, // stdout/stderr as strings rather than buffers | ||
| stdio: 'pipe', // any node spawn stdio arg is valid here | ||
@@ -53,3 +53,3 @@ // any other arguments to node child_process.spawn can go here as well, | ||
| - `stdioString` Boolean, default `false`. Return stdout/stderr output as | ||
| - `stdioString` Boolean, default `true`. Return stdout/stderr output as | ||
| strings rather than buffers. | ||
@@ -59,2 +59,24 @@ - `cwd` String, default `process.cwd()`. Current working directory for | ||
| effective uid/gid when run as root on Unix systems. | ||
| - `shell` Boolean or String. If false, no shell is used during spawn. If true, | ||
| the system default shell is used. If a String, that specific shell is used. | ||
| When a shell is used, the given command runs from within that shell by | ||
| concatenating the command and its escaped arguments and running the result. | ||
| This option is _not_ passed through to `child_process.spawn`. | ||
| - Any other options for `child_process.spawn` can be passed as well. | ||
| ### `promiseSpawn.open(arg, opts, extra)` -> `Promise` | ||
| Use the operating system to open `arg` with a default program. This is useful | ||
| for things like opening the user's default browser to a specific URL. | ||
| Depending on the platform in use this will use `start` (win32), `open` (darwin) | ||
| or `xdg-open` (everything else). In the case of Windows Subsystem for Linux we | ||
| use the default win32 behavior as it is much more predictable to open the arg | ||
| using the host operating system. | ||
| #### Options | ||
| Options are identical to `promiseSpawn` except for the following: | ||
| - `command` String, the command to use to open the file in question. Default is | ||
| one of `start`, `open` or `xdg-open` depending on platform in use. |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
11744
111.15%5
25%219
284.21%80
37.93%1
Infinity%5
25%5
400%+ Added
+ Added
+ Added