diff --git a/README.md b/README.md index 330ff06..0605c9c 100644 --- a/README.md +++ b/README.md @@ -54,4 +54,9 @@ spawned process. - `cwd` String, default `process.cwd()`. Current working directory for running the script. Also the argument to `infer-owner` to determine 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. diff --git a/lib/escape.js b/lib/escape.js new file mode 100644 index 0000000..9aca8bd --- /dev/null +++ b/lib/escape.js @@ -0,0 +1,68 @@ +'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, +} diff --git a/lib/index.js b/lib/index.js index 8fa8151..8e64d39 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,32 +1,43 @@ +'use strict' + const { spawn } = require('child_process') +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, { cmd, args, ...stdioResult(stdout, stderr, opts), ...extra, })) + proc.on('error', reject) + if (proc.stdout) { proc.stdout.on('data', c => stdout.push(c)).on('error', reject) proc.stdout.on('error', er => reject(er)) } + if (proc.stderr) { proc.stderr.on('data', c => stderr.push(c)).on('error', reject) proc.stderr.on('error', er => reject(er)) } + proc.on('close', (code, signal) => { const result = { cmd, @@ -36,6 +47,7 @@ const promiseSpawn = (cmd, args, opts = {}, extra = {}) => { ...stdioResult(stdout, stderr, opts), ...extra, } + if (code || signal) { rej(Object.assign(new Error('command failed'), result)) } else { @@ -49,14 +61,101 @@ const promiseSpawn = (cmd, args, opts = {}, extra = {}) => { return p } -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) +} + +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, 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 diff --git a/package.json b/package.json index 679c498..514c812 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@npmcli/eslint-config": "^4.0.0", "@npmcli/template-oss": "4.8.0", "minipass": "^3.1.1", + "spawk": "^1.7.1", "tap": "^16.0.1" }, "engines": { @@ -42,5 +43,8 @@ "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", "version": "4.8.0" + }, + "dependencies": { + "which": "^2.0.2" } } diff --git a/test/escape.js b/test/escape.js new file mode 100644 index 0000000..8517115 --- /dev/null +++ b/test/escape.js @@ -0,0 +1,119 @@ +'use strict' + +const { writeFileSync: writeFile } = require('fs') +const { join } = require('path') +const t = require('tap') +const promiseSpawn = require('../lib/index.js') + +const escape = require('../lib/escape.js') +const isWindows = process.platform === 'win32' + +t.test('sh', (t) => { + const expectations = [ + ['', `''`], + ['test', 'test'], + ['test words', `'test words'`], + ['$1', `'$1'`], + ['"$1"', `'"$1"'`], + [`'$1'`, `\\''$1'\\'`], + ['\\$1', `'\\$1'`], + ['--arg="$1"', `'--arg="$1"'`], + ['--arg=npm exec -c "$1"', `'--arg=npm exec -c "$1"'`], + [`--arg=npm exec -c '$1'`, `'--arg=npm exec -c '\\''$1'\\'`], + [`'--arg=npm exec -c "$1"'`, `\\''--arg=npm exec -c "$1"'\\'`], + ] + + for (const [input, expectation] of expectations) { + t.equal(escape.sh(input), expectation, + `expected to escape \`${input}\` to \`${expectation}\``) + } + + t.test('integration', { skip: isWindows && 'posix only' }, async (t) => { + for (const [input] of expectations) { + const p = await promiseSpawn('node', ['-p', 'process.argv[1]', '--', input], + { shell: true, stdioString: true }) + const stdout = p.stdout.trim() + t.equal(stdout, input, `expected \`${stdout}\` to equal \`${input}\``) + } + + t.end() + }) + + t.end() +}) + +t.test('cmd', (t) => { + const expectations = [ + ['', '""'], + ['test', 'test'], + ['%PATH%', '^%PATH^%'], + ['%PATH%', '^^^%PATH^^^%', true], + ['"%PATH%"', '^"\\^"^%PATH^%\\^"^"'], + ['"%PATH%"', '^^^"\\^^^"^^^%PATH^^^%\\^^^"^^^"', true], + [`'%PATH%'`, `'^%PATH^%'`], + [`'%PATH%'`, `'^^^%PATH^^^%'`, true], + ['\\%PATH%', '\\^%PATH^%'], + ['\\%PATH%', '\\^^^%PATH^^^%', true], + ['--arg="%PATH%"', '^"--arg=\\^"^%PATH^%\\^"^"'], + ['--arg="%PATH%"', '^^^"--arg=\\^^^"^^^%PATH^^^%\\^^^"^^^"', true], + ['--arg=npm exec -c "%PATH%"', '^"--arg=npm^ exec^ -c^ \\^"^%PATH^%\\^"^"'], + ['--arg=npm exec -c "%PATH%"', + '^^^"--arg=npm^^^ exec^^^ -c^^^ \\^^^"^^^%PATH^^^%\\^^^"^^^"', true], + [`--arg=npm exec -c '%PATH%'`, `^"--arg=npm^ exec^ -c^ '^%PATH^%'^"`], + [`--arg=npm exec -c '%PATH%'`, `^^^"--arg=npm^^^ exec^^^ -c^^^ '^^^%PATH^^^%'^^^"`, true], + [`'--arg=npm exec -c "%PATH%"'`, `^"'--arg=npm^ exec^ -c^ \\^"^%PATH^%\\^"'^"`], + [`'--arg=npm exec -c "%PATH%"'`, + `^^^"'--arg=npm^^^ exec^^^ -c^^^ \\^^^"^^^%PATH^^^%\\^^^"'^^^"`, true], + ['"C:\\Program Files\\test.bat"', '^"\\^"C:\\Program^ Files\\test.bat\\^"^"'], + ['"C:\\Program Files\\test.bat"', '^^^"\\^^^"C:\\Program^^^ Files\\test.bat\\^^^"^^^"', true], + ['"C:\\Program Files\\test%.bat"', '^"\\^"C:\\Program^ Files\\test^%.bat\\^"^"'], + ['"C:\\Program Files\\test%.bat"', + '^^^"\\^^^"C:\\Program^^^ Files\\test^^^%.bat\\^^^"^^^"', true], + ['% % %', '^"^%^ ^%^ ^%^"'], + ['% % %', '^^^"^^^%^^^ ^^^%^^^ ^^^%^^^"', true], + ['hello^^^^^^', 'hello^^^^^^^^^^^^'], + ['hello^^^^^^', 'hello^^^^^^^^^^^^^^^^^^^^^^^^', true], + ['hello world', '^"hello^ world^"'], + ['hello world', '^^^"hello^^^ world^^^"', true], + ['hello"world', '^"hello\\^"world^"'], + ['hello"world', '^^^"hello\\^^^"world^^^"', true], + ['hello""world', '^"hello\\^"\\^"world^"'], + ['hello""world', '^^^"hello\\^^^"\\^^^"world^^^"', true], + ['hello\\world', 'hello\\world'], + ['hello\\world', 'hello\\world', true], + ['hello\\\\world', 'hello\\\\world'], + ['hello\\\\world', 'hello\\\\world', true], + ['hello\\"world', '^"hello\\\\\\^"world^"'], + ['hello\\"world', '^^^"hello\\\\\\^^^"world^^^"', true], + ['hello\\\\"world', '^"hello\\\\\\\\\\^"world^"'], + ['hello\\\\"world', '^^^"hello\\\\\\\\\\^^^"world^^^"', true], + ['hello world\\', '^"hello^ world\\\\^"'], + ['hello world\\', '^^^"hello^^^ world\\\\^^^"', true], + ['hello %PATH%', '^"hello^ ^%PATH^%^"'], + ['hello %PATH%', '^^^"hello^^^ ^^^%PATH^^^%^^^"', true], + ] + + for (const [input, expectation, double] of expectations) { + const msg = `expected to${double ? ' double' : ''} escape \`${input}\` to \`${expectation}\`` + t.equal(escape.cmd(input, double), expectation, msg) + } + + t.test('integration', { skip: !isWindows && 'Windows only' }, async (t) => { + const dir = t.testdir() + const shimFile = join(dir, 'shim.cmd') + const shim = `@echo off\nnode -p process.argv[1] -- %*` + writeFile(shimFile, shim) + + const spawnOpts = { shell: true, stdioString: true } + for (const [input,, double] of expectations) { + const p = double + ? await promiseSpawn(shimFile, [input], spawnOpts) + : await promiseSpawn('node', ['-p', 'process.argv[1]', '--', input], spawnOpts) + t.equal(p.stdout, input, `expected \`${p.stdout}\` to equal \`${input}\``) + } + + t.end() + }) + + t.end() +}) diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..a9bd8f4 --- /dev/null +++ b/test/index.js @@ -0,0 +1,413 @@ +'use strict' + +const spawk = require('spawk') +const t = require('tap') + +const promiseSpawn = require('../lib/index.js') + +spawk.preventUnmatched() +t.afterEach(() => { + spawk.clean() +}) + +t.test('defaults to returning buffers', async (t) => { + const proc = spawk.spawn('pass', [], {}) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', []) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('OK\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) +}) + +t.test('extra context is returned', async (t) => { + const proc = spawk.spawn('pass', [], {}) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', [], {}, { extra: 'property' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('OK\n'), + stderr: Buffer.from(''), + extra: 'property', + }) + + t.ok(proc.called) +}) + +t.test('stdioString returns trimmed strings', async (t) => { + const proc = spawk.spawn('pass', [], {}) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', [], { stdioString: true }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: 'OK', + stderr: '', + }) + + t.ok(proc.called) +}) + +t.test('stdout and stderr are null when stdio is inherit', async (t) => { + const proc = spawk.spawn('pass', [], { stdio: 'inherit' }) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', [], { stdio: 'inherit' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: null, + stderr: null, + }) + + t.ok(proc.called) +}) + +t.test('stdout and stderr are null when stdio is inherit and stdioString is set', async (t) => { + const proc = spawk.spawn('pass', [], { stdio: 'inherit' }) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', [], { stdio: 'inherit', stdioString: true }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: null, + stderr: null, + }) + + t.ok(proc.called) +}) + +t.test('stdout is null when stdio is [pipe, inherit, pipe]', async (t) => { + const proc = spawk.spawn('pass', [], { stdio: ['pipe', 'inherit', 'pipe'] }) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', [], { stdio: ['pipe', 'inherit', 'pipe'] }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: null, + stderr: Buffer.from(''), + }) + + t.ok(proc.called) +}) + +t.test('stderr is null when stdio is [pipe, pipe, inherit]', async (t) => { + const proc = spawk.spawn('pass', [], { stdio: ['pipe', 'pipe', 'inherit'] }) + .stdout(Buffer.from('OK\n')) + + const result = await promiseSpawn('pass', [], { stdio: ['pipe', 'pipe', 'inherit'] }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('OK\n'), + stderr: null, + }) + + t.ok(proc.called) +}) + +t.test('exposes stdin', async (t) => { + const proc = spawk.spawn('stdin', [], {}) + const p = promiseSpawn('stdin', []) + process.nextTick(() => { + p.process.stdin.pipe(p.process.stdout) + p.stdin.end('hello') + }) + + const result = await p + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) +}) + +t.test('exposes process', async (t) => { + const proc = spawk.spawn('proc', [], {}) + .exitOnSignal('SIGFAKE') + + const p = promiseSpawn('proc', []) + process.nextTick(() => p.process.kill('SIGFAKE')) + + // there are no signals in windows, so we expect a different result + if (process.platform === 'win32') { + await t.rejects(p, { + code: 1, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + } else { + await t.rejects(p, { + code: null, + signal: 'SIGFAKE', + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + } + + t.ok(proc.called) +}) + +t.test('rejects when spawn errors', async (t) => { + const proc = spawk.spawn('notfound', [], {}) + .spawnError(new Error('command not found')) + + await t.rejects(promiseSpawn('notfound', []), { + message: 'command not found', + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) +}) + +t.test('spawn error includes extra', async (t) => { + const proc = spawk.spawn('notfound', [], {}) + .spawnError(new Error('command not found')) + + await t.rejects(promiseSpawn('notfound', [], {}, { extra: 'property' }), { + message: 'command not found', + stdout: Buffer.from(''), + stderr: Buffer.from(''), + extra: 'property', + }) + + t.ok(proc.called) +}) + +t.test('spawn error respects stdioString', async (t) => { + const proc = spawk.spawn('notfound', [], {}) + .spawnError(new Error('command not found')) + + await t.rejects(promiseSpawn('notfound', [], { stdioString: true }), { + message: 'command not found', + stdout: '', + stderr: '', + }) + + t.ok(proc.called) +}) + +t.test('spawn error respects stdio as inherit', async (t) => { + const proc = spawk.spawn('notfound', [], { stdio: 'inherit' }) + .spawnError(new Error('command not found')) + + await t.rejects(promiseSpawn('notfound', [], { stdio: 'inherit' }), { + message: 'command not found', + stdout: null, + stderr: null, + }) + + t.ok(proc.called) +}) + +t.test('rejects when command fails', async (t) => { + const proc = spawk.spawn('fail', [], {}) + .stderr(Buffer.from('Error!\n')) + .exit(1) + + await t.rejects(promiseSpawn('fail', []), { + message: 'command failed', + code: 1, + stdout: Buffer.from(''), + stderr: Buffer.from('Error!\n'), + }) + + t.ok(proc.called) +}) + +t.test('failed command returns extra', async (t) => { + const proc = spawk.spawn('fail', [], {}) + .stderr(Buffer.from('Error!\n')) + .exit(1) + + await t.rejects(promiseSpawn('fail', [], {}, { extra: 'property' }), { + message: 'command failed', + code: 1, + stdout: Buffer.from(''), + stderr: Buffer.from('Error!\n'), + extra: 'property', + }) + + t.ok(proc.called) +}) + +t.test('failed command respects stdioString', async (t) => { + const proc = spawk.spawn('fail', [], {}) + .stderr(Buffer.from('Error!\n')) + .exit(1) + + await t.rejects(promiseSpawn('fail', [], { stdioString: true }), { + message: 'command failed', + code: 1, + stdout: '', + stderr: 'Error!', + }) + + t.ok(proc.called) +}) + +t.test('failed command respects stdio as inherit', async (t) => { + const proc = spawk.spawn('fail', [], { stdio: 'inherit' }) + .stderr(Buffer.from('Error!\n')) + .exit(1) + + await t.rejects(promiseSpawn('fail', [], { stdio: 'inherit' }), { + message: 'command failed', + code: 1, + stdout: null, + stderr: null, + }) + + t.ok(proc.called) +}) + +t.test('rejects when signal kills child', async (t) => { + const proc = spawk.spawn('signal', [], {}) + .signal('SIGFAKE') + + const p = promiseSpawn('signal', []) + // there are no signals in windows, so we expect a different result + if (process.platform === 'win32') { + await t.rejects(p, { + code: 1, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + } else { + await t.rejects(p, { + code: null, + signal: 'SIGFAKE', + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + } + + t.ok(proc.called) +}) + +t.test('signal death includes extra', async (t) => { + const proc = spawk.spawn('signal', [], {}) + .signal('SIGFAKE') + + const p = promiseSpawn('signal', [], {}, { extra: 'property' }) + // there are no signals in windows, so we expect a different result + if (process.platform === 'win32') { + await t.rejects(p, { + code: 1, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + extra: 'property', + }) + } else { + await t.rejects(p, { + code: null, + signal: 'SIGFAKE', + stdout: Buffer.from(''), + stderr: Buffer.from(''), + extra: 'property', + }) + } + + t.ok(proc.called) +}) + +t.test('signal death respects stdioString', async (t) => { + const proc = spawk.spawn('signal', [], {}) + .signal('SIGFAKE') + + const p = promiseSpawn('signal', [], { stdioString: true }) + // there are no signals in windows, so we expect a different result + if (process.platform === 'win32') { + await t.rejects(p, { + code: 1, + signal: null, + stdout: '', + stderr: '', + }) + } else { + await t.rejects(p, { + code: null, + signal: 'SIGFAKE', + stdout: '', + stderr: '', + }) + } + + t.ok(proc.called) +}) + +t.test('signal death respects stdio as inherit', async (t) => { + const proc = spawk.spawn('signal', [], { stdio: 'inherit' }) + .signal('SIGFAKE') + + const p = promiseSpawn('signal', [], { stdio: 'inherit' }) + // there are no signals in windows, so we expect a different result + if (process.platform === 'win32') { + await t.rejects(p, { + code: 1, + signal: null, + stdout: null, + stderr: null, + }) + } else { + await t.rejects(p, { + code: null, + signal: 'SIGFAKE', + stdout: null, + stderr: null, + }) + } + + t.ok(proc.called) +}) + +t.test('rejects when stdout errors', async (t) => { + const proc = spawk.spawn('stdout-err', [], {}) + + const p = promiseSpawn('stdout-err', []) + process.nextTick(() => p.process.stdout.emit('error', new Error('stdout err'))) + + await t.rejects(p, { + message: 'stdout err', + code: null, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) +}) + +t.test('rejects when stderr errors', async (t) => { + const proc = spawk.spawn('stderr-err', [], {}) + + const p = promiseSpawn('stderr-err', []) + process.nextTick(() => p.process.stderr.emit('error', new Error('stderr err'))) + + await t.rejects(p, { + message: 'stderr err', + code: null, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) +}) diff --git a/test/promise-spawn.js b/test/promise-spawn.js deleted file mode 100644 index eb28cbf..0000000 --- a/test/promise-spawn.js +++ /dev/null @@ -1,226 +0,0 @@ -const t = require('tap') -const Minipass = require('minipass') -const EE = require('events') - -const isPipe = (stdio = 'pipe', fd) => - stdio === 'pipe' || stdio === null ? true - : Array.isArray(stdio) ? isPipe(stdio[fd], fd) - : false - -class MockProc extends EE { - constructor (cmd, args, opts) { - super() - this.cmd = cmd - this.args = args - this.opts = opts - this.stdin = isPipe(opts.stdio, 0) ? new Minipass() : null - this.stdout = isPipe(opts.stdio, 1) ? new Minipass() : null - this.stderr = isPipe(opts.stdio, 2) ? new Minipass() : null - this.code = null - this.signal = null - process.nextTick(() => this.run()) - } - - exit (code) { - this.code = code - this.emit('exit', this.code, this.signal) - if (this.stdout && this.stderr) { - let stdoutEnded = false - let stderrEnded = false - this.stdout.on('end', () => { - stdoutEnded = true - if (stderrEnded) { - this.emit('close', this.code, this.signal) - } - }) - this.stderr.on('end', () => { - stderrEnded = true - if (stdoutEnded) { - this.emit('close', this.code, this.signal) - } - }) - this.stdout.end() - this.stderr.end() - } else { - this.emit('close', this.code, this.signal) - } - } - - kill (signal) { - this.signal = signal - this.exit(null) - } - - writeOut (m) { - this.stdout && this.stdout.write(m) - } - - writeErr (m) { - this.stderr && this.stderr.write(m) - } - - run () { - switch (this.cmd) { - case 'cat': - this.stdin.on('data', c => this.writeOut(c)) - this.stdin.on('end', () => this.exit(0)) - return - case 'not found': - return this.emit('error', new Error('command not found')) - case 'signal': - this.writeOut('stdout') - this.writeErr('stderr') - return this.kill('SIGFAKE') - case 'pass': - this.writeOut('OK :)') - return this.exit(0) - case 'pass-nl': - this.writeOut('OK :)\n') - return this.exit(0) - case 'fail': - this.writeOut('not ok :(') - this.writeErr('Some kind of helpful error') - return this.exit(1) - case 'whoami': - this.writeOut(`UID ${this.opts.uid}\n`) - this.writeOut(`GID ${this.opts.gid}\n`) - return this.exit(0) - case 'stdout-fail': - this.stdout.emit('error', new Error('stdout error')) - return this.exit(1) - case 'stderr-fail': - this.stderr.emit('error', new Error('stderr error')) - return this.exit(1) - } - } -} - -const promiseSpawn = t.mock('../', { - child_process: { - spawn: (cmd, args, opts) => new MockProc(cmd, args, opts), - }, -}) - -t.test('not found', t => t.rejects(promiseSpawn('not found', [], {}), { - message: 'command not found', -})) - -t.test('not found, with extra', - t => t.rejects(promiseSpawn('not found', [], { stdioString: true }, { a: 1 }), { - message: 'command not found', - stdout: '', - stderr: '', - a: 1, - })) - -t.test('pass', t => t.resolveMatch(promiseSpawn('pass', [], { stdioString: true }, { a: 1 }), { - code: 0, - signal: null, - stdout: 'OK :)', - stderr: '', - a: 1, -})) - -t.test('pass trim output', t => t.resolveMatch(promiseSpawn('pass-nl', [], { stdioString: true }), { - code: 0, - signal: null, - stdout: 'OK :)', - stderr: '', -})) - -t.test('pass, default opts', t => t.resolveMatch(promiseSpawn('pass', []), { - code: 0, - signal: null, -})) - -t.test('pass, share stdio', - t => t.resolveMatch(promiseSpawn('pass', [], { stdio: 'inherit' }, { a: 1 }), { - code: 0, - signal: null, - stdout: null, - stderr: null, - a: 1, - })) - -t.test('pass, share stdout', - t => t.resolveMatch( - promiseSpawn('pass', [], { stdioString: true, stdio: ['pipe', 'inherit', 'pipe'] }, { a: 1 }), { - code: 0, - signal: null, - stdout: null, - stderr: '', - a: 1, - })) - -t.test('pass, share stderr', - t => t.resolveMatch( - promiseSpawn('pass', [], { stdioString: true, stdio: ['pipe', 'pipe', 'inherit'] }, { a: 1 }), { - code: 0, - signal: null, - stdout: 'OK :)', - stderr: null, - a: 1, - })) - -t.test('fail', t => t.rejects(promiseSpawn('fail', [], {}, { a: 1 }), { - message: 'command failed', - code: 1, - signal: null, - stdout: Buffer.from('not ok :('), - stderr: Buffer.from('Some kind of helpful error'), - a: 1, -})) - -t.test('fail, shared stdio', - t => t.rejects(promiseSpawn('fail', [], { stdio: 'inherit' }, { a: 1 }), { - message: 'command failed', - code: 1, - signal: null, - stdout: null, - stderr: null, - a: 1, - })) - -t.test('signal', t => t.rejects(promiseSpawn('signal', [], {}, { a: 1 }), { - message: 'command failed', - code: null, - signal: 'SIGFAKE', - stdout: Buffer.from('stdout'), - stderr: Buffer.from('stderr'), - a: 1, -})) - -t.test('stdio errors', t => { - t.rejects(promiseSpawn('stdout-fail', [], {}), { - message: 'stdout error', - }) - t.rejects(promiseSpawn('stderr-fail', [], {}), { - message: 'stderr error', - }) - t.end() -}) - -t.test('expose process stdin', t => { - const p = promiseSpawn('cat', [], { stdio: 'pipe' }) - t.resolveMatch(p, { - code: 0, - signal: null, - stdout: Buffer.from('hello'), - stderr: Buffer.alloc(0), - }) - t.end() - p.stdin.write('hell') - setTimeout(() => p.stdin.end('o')) -}) - -t.test('expose process', t => { - const p = promiseSpawn('cat', [], { stdio: 'pipe' }) - t.resolveMatch(p, { - code: 0, - signal: null, - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - }) - t.end() - setTimeout(() => p.process.exit(0)) -}) diff --git a/test/shell.js b/test/shell.js new file mode 100644 index 0000000..d4dab39 --- /dev/null +++ b/test/shell.js @@ -0,0 +1,210 @@ +'use strict' + +const spawk = require('spawk') +const t = require('tap') + +const promiseSpawn = require('../lib/index.js') + +spawk.preventUnmatched() +t.afterEach(() => { + spawk.clean() +}) + +t.test('sh', (t) => { + t.test('runs in shell', async (t) => { + const proc = spawk.spawn('sh', ['-c', 'echo hello'], { shell: false }) + .stdout(Buffer.from('hello\n')) + + const result = await promiseSpawn('echo', ['hello'], { shell: 'sh' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('escapes arguments', async (t) => { + const proc = spawk.spawn('sh', ['-c', 'echo \'hello world\''], { shell: false }) + .stdout(Buffer.from('hello\n')) + + const result = await promiseSpawn('echo', ['hello world'], { shell: 'sh' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.end() +}) + +t.test('cmd', (t) => { + t.test('runs in shell', async (t) => { + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', 'echo hello'], { + shell: false, + windowsVerbatimArguments: true, + }) + .stdout(Buffer.from('hello\n')) + + const result = await promiseSpawn('echo', ['hello'], { shell: 'cmd.exe' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('works when initial cmd is wrapped in quotes', async (t) => { + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', '"echo" hello'], { + shell: false, + windowsVerbatimArguments: true, + }) + .stdout(Buffer.from('hello\n')) + + const result = await promiseSpawn('"echo"', ['hello'], { shell: 'cmd.exe' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('works when initial cmd has a space and is wrapped in quotes', async (t) => { + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', '"two words" hello'], { + shell: false, + windowsVerbatimArguments: true, + }) + .stdout(Buffer.from('hello\n')) + + const result = await promiseSpawn('"two words"', ['hello'], { shell: 'cmd.exe' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('works when initial cmd is more than one command', async (t) => { + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', 'one two three hello'], { + shell: false, + windowsVerbatimArguments: true, + }) + .stdout(Buffer.from('hello\n')) + + const result = await promiseSpawn('one two three', ['hello'], { shell: 'cmd.exe' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from('hello\n'), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('escapes when cmd is a .exe', async (t) => { + const promiseSpawnMock = t.mock('../lib/index.js', { + which: { + sync: (key, opts) => { + t.equal(key, 'dir') + return 'dir.exe' + }, + }, + }) + + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', 'dir ^"with^ spaces^"'], { + shell: false, + windowsVerbatimArguments: true, + }) + + const result = await promiseSpawnMock('dir', ['with spaces'], { shell: 'cmd.exe' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('double escapes when cmd is a .cmd', async (t) => { + const promiseSpawnMock = t.mock('../lib/index.js', { + which: { + sync: (key, opts) => { + t.equal(key, 'dir') + return 'dir.cmd' + }, + }, + }) + + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', 'dir ^^^"with^^^ spaces^^^"'], { + shell: false, + windowsVerbatimArguments: true, + }) + + const result = await promiseSpawnMock('dir', ['with spaces'], { shell: 'cmd.exe' }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.test('which respects provided env PATH/PATHEXT', async (t) => { + const PATH = 'C:\\Windows\\System32' + const PATHEXT = 'EXE' + + const promiseSpawnMock = t.mock('../lib/index.js', { + which: { + sync: (key, opts) => { + t.equal(key, 'dir') + t.equal(opts.path, PATH) + t.equal(opts.pathext, PATHEXT) + return 'dir.exe' + }, + }, + }) + + const proc = spawk.spawn('cmd.exe', ['/d', '/s', '/c', 'dir ^"with^ spaces^"'], { + shell: false, + windowsVerbatimArguments: true, + }) + + const result = await promiseSpawnMock('dir', ['with spaces'], { + env: { + PATH, + PATHEXT, + }, + shell: 'cmd.exe', + }) + t.hasStrict(result, { + code: 0, + signal: null, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + }) + + t.ok(proc.called) + }) + + t.end() +})