Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add execa.command() #261

Merged
merged 23 commits into from May 24, 2019
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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. Otherwise 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 synchronously.
ehmicky marked this conversation as resolved.
Show resolved Hide resolved

@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 @@ -55,7 +55,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 @@ -94,7 +94,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 @@ -121,16 +121,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 @@ -147,12 +142,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. Otherwise no escaping/quoting is needed.
ehmicky marked this conversation as resolved.
Show resolved Hide resolved

### execa.commandSync(command, [options])

Same as [`execa.command()`](#execacommand-command-options) but synchronously.
ehmicky marked this conversation as resolved.
Show resolved Hide resolved

Returns or throws a [`childProcessResult`](#childProcessResult).

### childProcessResult

Type: `object`
Expand Down Expand Up @@ -331,7 +337,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 @@ -363,7 +369,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');
});