Repository: sindresorhus/execa
Pull request #261 (2019-05-21 12:28:16)

Add `execa.command()`
This PR is a follow-up on #182 which allowed specifying the command and its arguments as a single string. 

It improves the common case where the program contains a space in its path. For example on Windows `C:\Program Files\node\node.exe`. 

With `execa(command, [array], [options])`, when:
  1) `command` contains a space
  2) `array` is defined
  3) `options.shell` is not `true`

Then:
  - former behavior: an exception is thrown.
  - new behavior: `command` is handled as a command with a space in its path.
 
```js
// Success
execa('echo hello')
// Fail
execa('echo hello', ['world'])
// Success
execa('echo hello', {shell: true})

// Fail
execa('C:\\Program Files\\node\\node.exe')
// Success
execa('C:\\Program Files\\node\\node.exe', [])
// Success
execa('C:\\Program\\ Files\\node\\node.exe')
```

I've fixed the tests. No changes need to be done to the `README` since it already implies that no spaces escaping is needed for programs containing a space in their path.

GMartigny: I think this should be documented somewhere that `[]` is required as second argument when your command has spaces in it.

Weird behavior I see while testing:
If I want to escape myself the command `execa("fixtures/command\\ with\\ space")` it fail anyway. Maybe it's not an expected behavior, just saying.
ehmicky: Good point! Fixed.
GMartigny: I confirm that there's a bug with path with more than one spaces:

```js
execa("path/with/one\\ space"); // Success
execa("path/with/more \\than\\ one\\ space"); // Fail
```

This is due to [`previousToken` not being accurate](https://github.com/sindresorhus/execa/pull/261/files#diff-168726dbe96b3ce427e7fedce31bb0bcR26) when ignoring some item of the array.
ehmicky: This is a different bug. I've opened a PR at #262 to fix it.
ehmicky: Done. I've separated the `command` feature into two new methods `execa.command()` and `execa.commandSync()`.
cliffordfajardo: @ehmicky - would this new `command` method allow users to use pipe symbol inside their command like so: `execa.command('ls | head -n 5')`?

Side question:
If not, how would one go about doing something similar to that in the current API? 

Thanks!
ehmicky: @cliffordfajardo this is not the point of this PR. `execa.command()` will just be a convenience method over `execa()`. 

In you case, you need to use the [`shell` option](https://github.com/sindresorhus/execa#shell).

Or even better, you don't use a shell at all. For example, you could use [`readdirp`](https://github.com/paulmillr/readdirp) and pipe the result in Node.js. 

If you still have questions, please open an issue though, as this is not related to this PR.
ehmicky: Fixed!

Patch:
diff --git a/index.d.ts b/index.d.ts
index 746056e..beee76b 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -368,6 +368,37 @@ declare const execa: {
 		file: string,
 		options?: execa.SyncOptions<null>
 	): execa.ExecaSyncReturnValue<Buffer>;
+
+	/**
+	Same as `execa()` except both file and arguments are specified in a single `command` string. For example, `execa('echo', ['unicorns'])` is the same as `execa.command('echo unicorns')`.
+
+	If the file or an argument contains spaces, they must be escaped with backslashes. This matters especially if `command` is not a constant but a variable, for example with `__dirname` or `process.cwd()`. Except for spaces, no escaping/quoting is needed.
+
+	@param command - The program/script to execute and its arguments.
+	@returns A [`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess), which is enhanced to also be a `Promise` for a result `Object` with `stdout` and `stderr` properties.
+
+	@example
+	```
+	import execa from 'execa';
+
+	(async () => {
+		const {stdout} = await execa.command('echo unicorns');
+		console.log(stdout);
+		//=> 'unicorns'
+	})();
+	```
+	*/
+	command(command: string, options?: execa.Options): execa.ExecaChildProcess;
+	command(command: string, options?: execa.Options<null>): execa.ExecaChildProcess<Buffer>;
+
+	/**
+	Same as `execa.command()` but synchronous.
+
+	@param command - The program/script to execute and its arguments.
+	@returns A result `Object` with `stdout` and `stderr` properties.
+	*/
+	commandSync(command: string, options?: execa.SyncOptions): execa.ExecaSyncReturnValue;
+	commandSync(command: string, options?: execa.SyncOptions<null>): execa.ExecaSyncReturnValue<Buffer>;
 };
 
 export = execa;
diff --git a/index.js b/index.js
index 52ffd4d..bb3d217 100644
--- a/index.js
+++ b/index.js
@@ -17,45 +17,9 @@ const TEN_MEGABYTES = 1000 * 1000 * 10;
 
 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[tokens.length - 1];
-
-	if (previousToken.endsWith('\\')) {
-		return [...tokens.slice(0, -1), `${previousToken.slice(0, -1)} ${token}`];
-	}
-
-	return [...tokens, 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;
+function handleArgs(file, args, options = {}) {
+	const parsed = crossSpawn._parse(file, args, options);
+	file = parsed.command;
 	args = parsed.args;
 	options = parsed.options;
 
@@ -88,12 +52,12 @@ function handleArgs(command, args, options = {}) {
 
 	options.stdio = stdio(options);
 
-	if (process.platform === 'win32' && path.basename(command, '.exe') === 'cmd') {
+	if (process.platform === 'win32' && path.basename(file, '.exe') === 'cmd') {
 		// #116
 		args.unshift('/q');
 	}
 
-	return {command, args, options, parsed};
+	return {file, args, options, parsed};
 }
 
 function handleInput(spawned, input) {
@@ -237,22 +201,22 @@ function getErrorPrefix({timedOut, timeout, signal, exitCodeName, exitCode, isCa
 	return `failed with exit code ${exitCode} (${exitCodeName})`;
 }
 
-function joinCommand(command, args = []) {
+function joinCommand(file, args = []) {
 	if (!Array.isArray(args)) {
-		return command;
+		return file;
 	}
 
-	return [command, ...args].join(' ');
+	return [file, ...args].join(' ');
 }
 
-const execa = (command, args, options) => {
-	const parsed = handleArgs(command, args, options);
+const execa = (file, args, options) => {
+	const parsed = handleArgs(file, args, options);
 	const {encoding, buffer, maxBuffer} = parsed.options;
-	const joinedCommand = joinCommand(command, args);
+	const joinedCommand = joinCommand(file, args);
 
 	let spawned;
 	try {
-		spawned = childProcess.spawn(parsed.command, parsed.args, parsed.options);
+		spawned = childProcess.spawn(parsed.file, parsed.args, parsed.options);
 	} catch (error) {
 		return Promise.reject(error);
 	}
@@ -420,15 +384,15 @@ const execa = (command, args, options) => {
 
 module.exports = execa;
 
-module.exports.sync = (command, args, options) => {
-	const parsed = handleArgs(command, args, options);
-	const joinedCommand = joinCommand(command, args);
+module.exports.sync = (file, args, options) => {
+	const parsed = handleArgs(file, args, options);
+	const joinedCommand = joinCommand(file, args);
 
 	if (isStream(parsed.options.input)) {
 		throw new TypeError('The `input` option cannot be a stream in sync mode');
 	}
 
-	const result = childProcess.spawnSync(parsed.command, parsed.args, parsed.options);
+	const result = childProcess.spawnSync(parsed.file, parsed.args, parsed.options);
 	result.stdout = handleOutput(parsed.options, result.stdout, result.error);
 	result.stderr = handleOutput(parsed.options, result.stderr, result.error);
 
@@ -461,3 +425,35 @@ module.exports.sync = (command, args, options) => {
 		killed: false
 	};
 };
+
+// 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[tokens.length - 1];
+
+	if (previousToken.endsWith('\\')) {
+		return [...tokens.slice(0, -1), `${previousToken.slice(0, -1)} ${token}`];
+	}
+
+	return [...tokens, token];
+}
+
+function parseCommand(command) {
+	return command
+		.trim()
+		.split(SPACES_REGEXP)
+		.reduce(handleEscaping, []);
+}
+
+module.exports.command = (command, options) => {
+	const [file, ...args] = parseCommand(command);
+	return execa(file, args, options);
+};
+
+module.exports.commandSync = (command, options) => {
+	const [file, ...args] = parseCommand(command);
+	return execa.sync(file, args, options);
+};
diff --git a/package.json b/package.json
index 1fcbe00..1fa5020 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
 		"cross-spawn": "^6.0.5",
 		"get-stream": "^5.0.0",
 		"is-stream": "^2.0.0",
-		"merge-stream": "^1.0.1",
+		"merge-stream": "^2.0.0",
 		"npm-run-path": "^3.0.0",
 		"p-finally": "^1.0.0",
 		"signal-exit": "^3.0.2",
diff --git a/readme.md b/readme.md
index 5957329..9edfdaf 100644
--- a/readme.md
+++ b/readme.md
@@ -16,7 +16,7 @@
 - [Executes locally installed binaries by name.](#preferlocal)
 - [Cleans up spawned processes when the parent process dies.](#cleanup)
 - [Get interleaved output](#all) from `stdout` and `stderr` similar to what is printed on the terminal. [*(Async only)*](#execasyncfile-arguments-options)
-- [Can specify command and arguments as a single string without a shell](#execafile-arguments-options)
+- [Can specify command and arguments as a single string without a shell](#execacommandcommand-options)
 - More descriptive errors.
 
 
@@ -51,7 +51,7 @@ const execa = require('execa');
 
 	// Catching an error
 	try {
-		await execa('wrong command');
+		await execa('wrong', ['command']);
 	} catch (error) {
 		console.log(error);
 		/*
@@ -90,7 +90,7 @@ const execa = require('execa');
 
 // Catching an error with a sync method
 try {
-	execa.sync('wrong command');
+	execa.sync('wrong', ['command']);
 } catch (error) {
 	console.log(error);
 	/*
@@ -117,16 +117,11 @@ try {
 
 ## API
 
-### execa(file, [arguments], [options])
-### execa(command, [options])
+### execa(file, arguments, [options])
 
 Execute a file. Think of this as a mix of [`child_process.execFile()`](https://nodejs.org/api/child_process.html#child_process_child_process_execfile_file_args_options_callback) and [`child_process.spawn()`](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options).
 
-Arguments can be specified in either:
-  - `arguments`: `execa('echo', ['unicorns'])`.
-  - `command`: `execa('echo unicorns')`.
-
-Arguments should not be escaped nor quoted, except inside `command` where spaces can be escaped with a backslash.
+No escaping/quoting is needed.
 
 Unless the [`shell`](#shell) option is used, no shell interpreter (Bash, `cmd.exe`, etc.) is used, so shell features such as variables substitution (`echo $PATH`) are not allowed.
 
@@ -143,12 +138,23 @@ Similar to [`childProcess.kill()`](https://nodejs.org/api/child_process.html#chi
 Stream combining/interleaving [`stdout`](https://nodejs.org/api/child_process.html#child_process_subprocess_stdout) and [`stderr`](https://nodejs.org/api/child_process.html#child_process_subprocess_stderr).
 
 ### execa.sync(file, [arguments], [options])
-### execa.sync(command, [options])
 
 Execute a file synchronously.
 
 Returns or throws a [`childProcessResult`](#childProcessResult).
 
+### execa.command(command, [options])
+
+Same as [`execa()`](#execafile-arguments-options) except both file and arguments are specified in a single `command` string. For example, `execa('echo', ['unicorns'])` is the same as `execa.command('echo unicorns')`.
+
+If the file or an argument contains spaces, they must be escaped with backslashes. This matters especially if `command` is not a constant but a variable, for example with `__dirname` or `process.cwd()`. Except for spaces, no escaping/quoting is needed.
+
+### execa.commandSync(command, [options])
+
+Same as [`execa.command()`](#execacommand-command-options) but synchronous.
+
+Returns or throws a [`childProcessResult`](#childProcessResult).
+
 ### childProcessResult
 
 Type: `object`
@@ -327,7 +333,7 @@ Environment key-value pairs. Extends automatically from `process.env`. Set [`ext
 
 Type: `string`
 
-Explicitly set the value of `argv[0]` sent to the child process. This will be set to `command` or `file` if not specified.
+Explicitly set the value of `argv[0]` sent to the child process. This will be set to `file` if not specified.
 
 #### stdio
 
@@ -359,7 +365,7 @@ Sets the group identity of the process.
 Type: `boolean | string`<br>
 Default: `false`
 
-If `true`, runs `command` inside of a shell. Uses `/bin/sh` on UNIX and `cmd.exe` on Windows. A different shell can be specified as a string. The shell should understand the `-c` switch on UNIX or `/d /s /c` on Windows.
+If `true`, runs `file` inside of a shell. Uses `/bin/sh` on UNIX and `cmd.exe` on Windows. A different shell can be specified as a string. The shell should understand the `-c` switch on UNIX or `/d /s /c` on Windows.
 
 We recommend against using this option since it is:
 - not cross-platform, encouraging shell-specific syntax.


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 048f46a..ef8d904 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -146,3 +146,16 @@ expectType<ExecaSyncReturnValue<string>>(
 expectType<ExecaSyncReturnValue<Buffer>>(
 	execa.sync('unicorns', ['foo'], {encoding: null})
 );
+
+expectType<ExecaChildProcess<string>>(execa.command('unicorns'));
+expectType<ExecaReturnValue<string>>(await execa.command('unicorns'));
+expectType<ExecaReturnValue<string>>(await execa.command('unicorns', {encoding: 'utf8'}));
+expectType<ExecaReturnValue<Buffer>>(await execa.command('unicorns', {encoding: null}));
+expectType<ExecaReturnValue<string>>(await execa.command('unicorns foo', {encoding: 'utf8'}));
+expectType<ExecaReturnValue<Buffer>>(await execa.command('unicorns foo', {encoding: null}));
+
+expectType<ExecaSyncReturnValue<string>>(execa.commandSync('unicorns'));
+expectType<ExecaSyncReturnValue<string>>(execa.commandSync('unicorns', {encoding: 'utf8'}));
+expectType<ExecaSyncReturnValue<Buffer>>(execa.commandSync('unicorns', {encoding: null}));
+expectType<ExecaSyncReturnValue<string>>(execa.commandSync('unicorns foo', {encoding: 'utf8'}));
+expectType<ExecaSyncReturnValue<Buffer>>(execa.commandSync('unicorns foo', {encoding: null}));
diff --git a/test.js b/test.js
index aeefcc7..aa74acd 100644
--- a/test.js
+++ b/test.js
@@ -97,47 +97,6 @@ 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 commands', async t => {
-	const {stdout} = await execa('./fixtures/command\\ with\\ space foo bar');
-	t.is(stdout, 'foo\nbar');
-});
-
-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.sync()', t => {
 	const {stdout} = execa.sync('noop', ['foo']);
 	t.is(stdout, 'foo');
@@ -711,3 +670,48 @@ test('calling cancel method on a process which has been killed does not make err
 	const {isCanceled} = await t.throwsAsync(subprocess);
 	t.false(isCanceled);
 });
+
+test('allow commands with spaces and no array arguments', async t => {
+	const {stdout} = await execa('./fixtures/command with space');
+	t.is(stdout, '');
+});
+
+test('allow commands with spaces and array arguments', async t => {
+	const {stdout} = await execa('./fixtures/command with space', ['foo', 'bar']);
+	t.is(stdout, 'foo\nbar');
+});
+
+test('execa.command()', async t => {
+	const {stdout} = await execa.command('node fixtures/echo foo bar');
+	t.is(stdout, 'foo\nbar');
+});
+
+test('execa.command() ignores consecutive spaces', async t => {
+	const {stdout} = await execa.command('node fixtures/echo foo    bar');
+	t.is(stdout, 'foo\nbar');
+});
+
+test('execa.command() allows escaping spaces in commands', async t => {
+	const {stdout} = await execa.command('./fixtures/command\\ with\\ space foo bar');
+	t.is(stdout, 'foo\nbar');
+});
+
+test('execa.command() allows escaping spaces in arguments', async t => {
+	const {stdout} = await execa.command('node fixtures/echo foo\\ bar');
+	t.is(stdout, 'foo bar');
+});
+
+test('execa.command() escapes other whitespaces', async t => {
+	const {stdout} = await execa.command('node fixtures/echo foo\tbar');
+	t.is(stdout, 'foo\tbar');
+});
+
+test('execa.command() trims', async t => {
+	const {stdout} = await execa.command('  node fixtures/echo foo bar  ');
+	t.is(stdout, 'foo\nbar');
+});
+
+test('execa.command.sync()', t => {
+	const {stdout} = execa.commandSync('node fixtures/echo foo bar');
+	t.is(stdout, 'foo\nbar');
+});