diff --git a/fixtures/echo b/fixtures/echo new file mode 100755 index 000000000..cdfa972df --- /dev/null +++ b/fixtures/echo @@ -0,0 +1,3 @@ +#!/usr/bin/env node +'use strict'; +console.log(process.argv.slice(2).join('\n')) diff --git a/index.js b/index.js index 9899d2c48..bb635fadf 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,45 @@ const stdio = require('./lib/stdio'); const TEN_MEGABYTES = 1000 * 1000 * 10; -function handleArgs(command, args, options) { +const SPACES_REGEXP = / +/g; + +// Allow spaces to be escaped by a backslash if not meant as a delimiter +function handleEscaping(tokens, token, index) { + if (index === 0) { + return [token]; + } + + const previousToken = tokens[index - 1]; + + if (!previousToken.endsWith('\\')) { + return [...tokens, token]; + } + + return [...tokens.slice(0, index - 1), `${previousToken.slice(0, -1)} ${token}`]; +} + +function parseCommand(command, args = []) { + if (args.length !== 0) { + throw new Error('Arguments cannot be inside `command` when also specified as an array of strings'); + } + + const [file, ...extraArgs] = command + .trim() + .split(SPACES_REGEXP) + .reduce(handleEscaping, []); + return [file, extraArgs]; +} + +function handleArgs(command, args, options = {}) { + if (args && !Array.isArray(args)) { + options = args; + args = []; + } + + if (!options.shell && command.includes(' ')) { + [command, args] = parseCommand(command, args); + } + const parsed = crossSpawn._parse(command, args, options); command = parsed.command; args = parsed.args; diff --git a/readme.md b/readme.md index 3dccd8581..971fa6d9a 100644 --- a/readme.md +++ b/readme.md @@ -16,6 +16,7 @@ - [Executes locally installed binaries by name.](#preferlocal) - [Cleans up spawned processes when the parent process dies.](#cleanup) - [Adds an `.all` property](#execafile-arguments-options) with interleaved output from `stdout` and `stderr`, similar to what the terminal sees. [*(Async only)*](#execasyncfile-arguments-options) +- [Can specify command and arguments as a single string without a shell](#execafile-arguments-options) ## Install @@ -115,11 +116,20 @@ try { ## API ### execa(file, [arguments], [options]) +### execa(command, [options]) Execute a file. +Arguments can be specified in either: + - `arguments`: `execa('echo', ['unicorns'])`. + - `command`: `execa('echo unicorns')`. + +Arguments should not be escaped nor quoted. Exception: inside `command`, spaces can be escaped with a backslash. + Think of this as a mix of `child_process.execFile` and `child_process.spawn`. +As opposed to [`execa.shell()`](#execashellcommand-options), no shell interpreter (Bash, `cmd.exe`, etc.) is used, so shell features such as variables substitution (`echo $PATH`) are not allowed. + Returns a [`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess) which is enhanced to be a `Promise`. It exposes an additional `.all` stream, with `stdout` and `stderr` interleaved. @@ -129,22 +139,25 @@ The spawned process can be canceled with the `.cancel()` method on the promise, The promise result is an `Object` with `stdout`, `stderr` and `all` properties. ### execa.stdout(file, [arguments], [options]) +### execa.stdout(command, [options]) Same as `execa()`, but returns only `stdout`. ### execa.stderr(file, [arguments], [options]) +### execa.stderr(command, [options]) Same as `execa()`, but returns only `stderr`. ### execa.shell(command, [options]) -Execute a command through the system shell. Prefer `execa()` whenever possible, as it's both faster and safer. +Execute a command through the system shell. Prefer `execa()` whenever possible, as it's faster, safer and more cross-platform. Returns a [`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess). The `child_process` instance is enhanced to also be promise for a result object with `stdout` and `stderr` properties. ### execa.sync(file, [arguments], [options]) +### execa.sync(command, [options]) Execute a file synchronously. @@ -154,7 +167,7 @@ It does not have the `.all` property that `execa()` has because the [underlying This method throws an `Error` if the command fails. -### execa.shellSync(file, [options]) +### execa.shellSync(command, [options]) Execute a command synchronously through the system shell. diff --git a/test.js b/test.js index 07a3b0180..2c7e017dd 100644 --- a/test.js +++ b/test.js @@ -101,6 +101,40 @@ test('pass `stderr` to a file descriptor', async t => { t.is(fs.readFileSync(file, 'utf8'), 'foo bar\n'); }); +test('allow string arguments', async t => { + const {stdout} = await execa('node fixtures/echo foo bar'); + t.is(stdout, 'foo\nbar'); +}); + +test('allow string arguments in synchronous mode', t => { + const {stdout} = execa.sync('node fixtures/echo foo bar'); + t.is(stdout, 'foo\nbar'); +}); + +test('forbid string arguments together with array arguments', t => { + t.throws(() => execa('node fixtures/echo foo bar', ['foo', 'bar']), /Arguments cannot be inside/); +}); + +test('ignore consecutive spaces in string arguments', async t => { + const {stdout} = await execa('node fixtures/echo foo bar'); + t.is(stdout, 'foo\nbar'); +}); + +test('escape other whitespaces in string arguments', async t => { + const {stdout} = await execa('node fixtures/echo foo\tbar'); + t.is(stdout, 'foo\tbar'); +}); + +test('allow escaping spaces in string arguments', async t => { + const {stdout} = await execa('node fixtures/echo foo\\ bar'); + t.is(stdout, 'foo bar'); +}); + +test('trim string arguments', async t => { + const {stdout} = await execa(' node fixtures/echo foo bar '); + t.is(stdout, 'foo\nbar'); +}); + test('execa.shell()', async t => { const {stdout} = await execa.shell('node fixtures/noop foo'); t.is(stdout, 'foo');