Skip to content

Commit

Permalink
Add execa.command() (#261)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored and sindresorhus committed May 24, 2019
1 parent cf0e164 commit 6853316
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 106 deletions.
31 changes: 31 additions & 0 deletions index.d.ts
Expand Up @@ -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;
100 changes: 48 additions & 52 deletions index.js
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
};
13 changes: 13 additions & 0 deletions index.test-d.ts
Expand Up @@ -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}));
32 changes: 19 additions & 13 deletions readme.md
Expand Up @@ -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.


Expand Down Expand Up @@ -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);
/*
Expand Down Expand Up @@ -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);
/*
Expand All @@ -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.

Expand All @@ -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`
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
86 changes: 45 additions & 41 deletions test.js
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});

0 comments on commit 6853316

Please sign in to comment.