Repository: sindresorhus/execa (js)

Issue:
[Feature proposal] Single string input without using the `shell`
I can submit a PR for this feature proposal if approved.

I think many users might use `execa.shell()` or the `shell: true` option as a way to pass arguments as a single string which feels more intuitive than using one string + one array:

```js
execa.shell('eslint *.js *.yml --ignore-path=.gitignore --fix --cache --format=codeframe --max-warnings=0')
execa('eslint', ['*.js', '*.yml', '--ignore-path=.gitignore', '--fix', '--cache', '--format=codeframe', '--max-warnings=0'])
```

However those two calls use very different mechanism to execute the command, as the first one relies on system calls and the second one uses a shell interpreter. Using a shell interpreter is:
  - less secure: it allows for [command injection](https://www.owasp.org/index.php/Command_Injection) by passing arguments like `$(rm -rf /)` or `&& rm -rf /`
  - less cross-platform: it encourages using [shell-specific features](https://twitter.com/ehmicky/status/1098980276787732480) such as globbing, single quote escaping or even semicolons which won't work on `cmd.exe`.
  - slower as it goes through an extra step (the shell interpreter)

Using the shell interpreter can almost always be avoided as almost all shell features can be emulated in Node.js:
  - globbing can be done with libraries like `minimatch`. Directory recursion can also be used.
  - piping, subcommands, chaining and streams redirection can be done with the `std*` and `input` options.
  - escaping/quoting becomes unnecessary.
  - variable expansion can be done with template strings. This includes `process.env`.
  - passing environment variables can be done with the `env` option.
  - built-in Bash commands logic can be performed in JavaScript instead.
  - background processes can be done with the `detached` option.

In order to encourage more secure, cross-platform and faster shell execution, I suggest the following: 
  - when the `shell` option is `false`, the first argument can be either `command` (current behavior) or `command ...args` (new behavior)
  - `command ...args` is a string delimited by spaces.
  - spaces can be escaped with backslashes: `command escaped\ space`. Anything else does not need escaping, just like the `args` array.

What do you think? Again if approved I can submit a PR.

Pull request description:
Support single string input without using the `shell` option
Fix #176 

This allows passing command+arguments as a single string with `execa()` without using the `shell` option:

```js
execa('eslint *.js *.yml --ignore-path=.gitignore --fix --cache --format=codeframe --max-warnings=0')
```

Instead of:

```js
execa('eslint', ['*.js', '*.yml', '--ignore-path=.gitignore', '--fix', '--cache', '--format=codeframe', '--max-warnings=0'])
```

This is not only for convenience, the main goal is actually to make `execa` more secure, cross-platform and faster. The reason is: many users might be tempted to use `execa.shell()` for the convenience of specifying command and arguments inside a single string, because it feels closer to typing in a terminal. However `execa.shell()` involves using a shell interpreter which is:

* less secure: it allows for [command injection](https://www.owasp.org/index.php/Command_Injection) by passing arguments like `$(rm -rf /)` or `&& rm -rf /`
* less cross-platform: it encourages using [shell-specific features](https://twitter.com/ehmicky/status/1098980276787732480) such as globbing, single quote escaping or even semicolons which won't work on `cmd.exe`.
* slower as it goes through an extra step (the shell interpreter)

Giving users the possibility to do this without `execa.shell()` prevents this issue.

Like `execa()` (and the underlying `child_process.spawn()`) arguments do not need any escaping/quoting. One exception: spaces can be escaped with a backslash if not meant as an arguments delimiter.

Patch:
diff --git a/fixtures/echo b/fixtures/echo
new file mode 100755
index 0000000..cdfa972
--- /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 9899d2c..bb635fa 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 3dccd85..971fa6d 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.
 


Test patch:
diff --git a/test.js b/test.js
index 07a3b01..2c7e017 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');