Skip to content

Commit

Permalink
Add node option
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Feb 8, 2024
1 parent 72836f3 commit 5532b2e
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 133 deletions.
62 changes: 33 additions & 29 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,41 @@ type CommonOptions<IsSync extends boolean = boolean> = {
*/
readonly localDir?: string | URL;

/**
If `true`, runs with Node.js. The first argument must be a Node.js file.
@default `true` with `execaNode()`, `false` otherwise
*/
readonly node?: boolean;

/**
Node.js executable used to create the child process.
Requires the `node` option to be `true`.
@default [`process.execPath`](https://nodejs.org/api/process.html#process_process_execpath) (current Node.js executable)
*/
readonly nodePath?: string | URL;

/**
List of [CLI options](https://nodejs.org/api/cli.html#cli_options) passed to the Node.js executable.
Requires the `node` option to be `true`.
@default [`process.execArgv`](https://nodejs.org/api/process.html#process_process_execargv) (current Node.js CLI options)
*/
readonly nodeOptions?: string[];

/**
Path to the Node.js executable to use in child processes.
This can be either an absolute path or a path relative to the `cwd` option.
Requires `preferLocal` to be `true`.
Requires the `preferLocal` option to be `true`.
For example, this can be used together with [`get-node`](https://github.com/ehmicky/get-node) to run a specific Node.js version in a child process.
@default process.execPath
@default [`process.execPath`](https://nodejs.org/api/process.html#process_process_execpath) (current Node.js executable)
*/
readonly execPath?: string | URL;

Expand Down Expand Up @@ -556,6 +581,8 @@ type CommonOptions<IsSync extends boolean = boolean> = {

/**
Enables exchanging messages with the child process using [`childProcess.send(value)`](https://nodejs.org/api/child_process.html#subprocesssendmessage-sendhandle-options-callback) and [`childProcess.on('message', (value) => {})`](https://nodejs.org/api/child_process.html#event-message).
@default `true` if the `node` option is enabled, `false` otherwise
*/
readonly ipc?: IfAsync<IsSync, boolean>;

Expand Down Expand Up @@ -607,22 +634,6 @@ type CommonOptions<IsSync extends boolean = boolean> = {
export type Options = CommonOptions<false>;
export type SyncOptions = CommonOptions<true>;

export type NodeOptions<OptionsType extends Options = Options> = {
/**
The Node.js executable to use.
@default process.execPath
*/
readonly nodePath?: string | URL;

/**
List of [CLI options](https://nodejs.org/api/cli.html#cli_options) passed to the Node.js executable.
@default process.execArgv
*/
readonly nodeOptions?: string[];
} & OptionsType;

/**
Result of a child process execution. On success this is a plain object. On failure this is also an `Error` instance.
Expand Down Expand Up @@ -1262,16 +1273,9 @@ await $$`echo rainbows`;
export const $: Execa$;

/**
Executes a Node.js file using `node scriptPath ...arguments`. `file` is a string or a file URL. `arguments` are an array of strings. Returns a `childProcess`.
Arguments are automatically escaped. They can contain any character, including spaces.
This is the preferred method when executing Node.js files.
Same as `execa()` but using the `node` option.
Like [`child_process#fork()`](https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options):
- the current Node version and options are used. This can be overridden using the `nodePath` and `nodeOptions` options.
- the `shell` option cannot be used
- the `ipc` option defaults to `true`
Executes a Node.js file using `node scriptPath ...arguments`.
@param scriptPath - Node.js script to execute, as a string or file URL
@param arguments - Arguments to pass to `scriptPath` on execution.
Expand All @@ -1287,12 +1291,12 @@ import {execa} from 'execa';
await execaNode('scriptPath', ['argument']);
```
*/
export function execaNode<OptionsType extends NodeOptions = {}>(
export function execaNode<OptionsType extends Options = {}>(
scriptPath: string | URL,
arguments?: readonly string[],
options?: OptionsType
): ExecaChildProcess<OptionsType>;
export function execaNode<OptionsType extends NodeOptions = {}>(
export function execaNode<OptionsType extends Options = {}>(
scriptPath: string | URL,
options?: OptionsType
): ExecaChildProcess<OptionsType>;
42 changes: 16 additions & 26 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {setMaxListeners} from 'node:events';
import path from 'node:path';
import childProcess from 'node:child_process';
import process from 'node:process';
import {fileURLToPath} from 'node:url';
import crossSpawn from 'cross-spawn';
import stripFinalNewline from 'strip-final-newline';
import {npmRunPathEnv} from 'npm-run-path';
import {makeError} from './lib/error.js';
import {handleNodeOption} from './lib/node.js';
import {handleInputAsync, pipeOutputAsync, cleanupStdioStreams} from './lib/stdio/async.js';
import {handleInputSync, pipeOutputSync} from './lib/stdio/sync.js';
import {spawnedKill, validateTimeout, normalizeForceKillAfterDelay, cleanupOnExit} from './lib/kill.js';
Expand All @@ -16,6 +16,7 @@ import {getSpawnedResult, makeAllStream} from './lib/stream.js';
import {mergePromise} from './lib/promise.js';
import {joinCommand, getEscapedCommand} from './lib/escape.js';
import {parseCommand} from './lib/command.js';
import {safeNormalizeFileUrl, normalizeFileUrl} from './lib/cwd.js';
import {parseTemplates} from './lib/script.js';
import {logCommand, verboseDefault} from './lib/verbose.js';
import {bufferToUint8Array} from './lib/stdio/utils.js';
Expand All @@ -32,24 +33,16 @@ const getEnv = ({env: envOption, extendEnv, preferLocal, localDir, execPath}) =>
return env;
};

const normalizeFileUrl = file => file instanceof URL ? fileURLToPath(file) : file;

const getFilePath = rawFile => {
const fileString = normalizeFileUrl(rawFile);

if (typeof fileString !== 'string') {
throw new TypeError('First argument must be a string or a file URL.');
}

return fileString;
};
const getFilePath = rawFile => safeNormalizeFileUrl(rawFile, 'First argument');

const handleArguments = (rawFile, rawArgs, rawOptions = {}) => {
const filePath = getFilePath(rawFile);
const command = joinCommand(filePath, rawArgs);
const escapedCommand = getEscapedCommand(filePath, rawArgs);

const {command: file, args, options: initialOptions} = crossSpawn._parse(filePath, rawArgs, rawOptions);
const [processedFile, processedArgs, processedOptions] = handleNodeOption(filePath, rawArgs, rawOptions);

const {command: file, args, options: initialOptions} = crossSpawn._parse(processedFile, processedArgs, processedOptions);

const options = addDefaultOptions(initialOptions);
validateTimeout(options);
Expand Down Expand Up @@ -223,7 +216,8 @@ const handlePromise = async ({spawned, options, stdioStreamsGroups, originalStre
};

export function execaSync(rawFile, rawArgs, rawOptions) {
const {file, args, command, escapedCommand, options} = handleArguments(rawFile, rawArgs, rawOptions);
const syncOptions = normalizeSyncOptions(rawOptions);
const {file, args, command, escapedCommand, options} = handleArguments(rawFile, rawArgs, syncOptions);
validateSyncOptions(options);

const stdioStreamsGroups = handleInputSync(options);
Expand Down Expand Up @@ -288,6 +282,8 @@ export function execaSync(rawFile, rawArgs, rawOptions) {
};
}

const normalizeSyncOptions = (options = {}) => options.node && !options.ipc ? {...options, ipc: false} : options;

const validateSyncOptions = ({ipc}) => {
if (ipc) {
throw new TypeError('The "ipc: true" option cannot be used with synchronous methods.');
Expand Down Expand Up @@ -340,21 +336,15 @@ export function execaCommandSync(command, options) {
return execaSync(file, args, options);
}

export function execaNode(scriptPath, args = [], options = {}) {
export function execaNode(file, args = [], options = {}) {
if (!Array.isArray(args)) {
options = args;
args = [];
}

const defaultExecArgv = process.execArgv.filter(arg => !arg.startsWith('--inspect'));
const {
nodePath = process.execPath,
nodeOptions = defaultExecArgv,
} = options;

return execa(
nodePath,
[...nodeOptions, getFilePath(scriptPath), ...args],
{ipc: true, ...options, shell: false},
);
if (options.node === false) {
throw new TypeError('The "node" option cannot be false with `execaNode()`.');
}

return execa(file, args, {...options, node: true});
}
8 changes: 8 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,14 @@ execa('unicorns', {shell: '/bin/sh'});
execaSync('unicorns', {shell: '/bin/sh'});
execa('unicorns', {shell: fileUrl});
execaSync('unicorns', {shell: fileUrl});
execa('unicorns', {node: true});
execaSync('unicorns', {node: true});
execa('unicorns', {nodePath: './node'});
execaSync('unicorns', {nodePath: './node'});
execa('unicorns', {nodePath: fileUrl});
execaSync('unicorns', {nodePath: fileUrl});
execa('unicorns', {nodeOptions: ['--async-stack-traces']});
execaSync('unicorns', {nodeOptions: ['--async-stack-traces']});
execa('unicorns', {timeout: 1000});
execaSync('unicorns', {timeout: 1000});
execa('unicorns', {maxBuffer: 1000});
Expand Down
13 changes: 13 additions & 0 deletions lib/cwd.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {fileURLToPath} from 'node:url';

export const safeNormalizeFileUrl = (file, name) => {
const fileString = normalizeFileUrl(file);

if (typeof fileString !== 'string') {
throw new TypeError(`${name} must be a string or a file URL: ${fileString}.`);
}

return fileString;
};

export const normalizeFileUrl = file => file instanceof URL ? fileURLToPath(file) : file;
24 changes: 24 additions & 0 deletions lib/node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {execPath, execArgv} from 'node:process';
import {basename} from 'node:path';
import {safeNormalizeFileUrl} from './cwd.js';

export const handleNodeOption = (file, args, {
node: shouldHandleNode = false,
nodePath = execPath,
nodeOptions = execArgv.filter(arg => !arg.startsWith('--inspect')),
...options
}) => {
if (!shouldHandleNode) {
return [file, args, options];
}
if (basename(file, '.exe') === 'node') {
throw new TypeError('When the "node" option is true, the first argument does not need to be "node".');
}
return [
safeNormalizeFileUrl(nodePath, 'The "nodePath" option'),
[...nodeOptions, file, ...args],
{ipc: true, ...options, shell: false},
];
};
58 changes: 31 additions & 27 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,19 +233,6 @@ Arguments are [automatically escaped](#shell-syntax). They can contain any chara

This is the preferred method when executing single commands.

#### execaNode(scriptPath, arguments?, options?)

Executes a Node.js file using `node scriptPath ...arguments`. `file` is a string or a file URL. `arguments` are an array of strings. Returns a [`childProcess`](#childprocess).

Arguments are [automatically escaped](#shell-syntax). They can contain any character, including spaces.

This is the preferred method when executing Node.js files.

Like [`child_process#fork()`](https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options):
- the current Node version and options are used. This can be overridden using the [`nodePath`](#nodepath-for-node-only) and [`nodeOptions`](#nodeoptions-for-node-only) options.
- the [`shell`](#shell) option cannot be used
- the [`ipc`](#ipc) option defaults to `true`

#### $\`command\`

Executes a command. The `command` string includes both the `file` and its `arguments`. Returns a [`childProcess`](#childprocess).
Expand Down Expand Up @@ -274,6 +261,12 @@ Arguments are [automatically escaped](#shell-syntax). They can contain any chara

This is the preferred method when executing a user-supplied `command` string, such as in a REPL.

#### execaNode(scriptPath, arguments?, options?)

Same as [`execa()`](#execacommandcommand-options) but using the [`node`](#node) option.

Executes a Node.js file using `node scriptPath ...arguments`.

#### execaSync(file, arguments?, options?)

Same as [`execa()`](#execacommandcommand-options) but synchronous.
Expand Down Expand Up @@ -542,32 +535,43 @@ Default: `process.cwd()`

Preferred path to find locally installed binaries in (use with `preferLocal`).

#### execPath
#### node

Type: `string | URL`\
Default: `process.execPath` (Current Node.js executable)
Type: `boolean`\
Default: `true` with [`execaNode()`](#execanodescriptpath-arguments-options), `false` otherwise

Path to the Node.js executable to use in child processes.
If `true`, runs with Node.js. The first argument must be a Node.js file.

This can be either an absolute path or a path relative to the [`cwd` option](#cwd).
#### nodeOptions

Type: `string[]`\
Default: [`process.execArgv`](https://nodejs.org/api/process.html#process_process_execargv) (current Node.js CLI options)

Requires [`preferLocal`](#preferlocal) to be `true`.
List of [CLI options](https://nodejs.org/api/cli.html#cli_options) passed to the [Node.js executable](#nodepath).

For example, this can be used together with [`get-node`](https://github.com/ehmicky/get-node) to run a specific Node.js version in a child process.
Requires the [`node`](#node) option to be `true`.

#### nodePath *(For `.node()` only)*
#### nodePath

Type: `string | URL`\
Default: [`process.execPath`](https://nodejs.org/api/process.html#process_process_execpath)
Default: [`process.execPath`](https://nodejs.org/api/process.html#process_process_execpath) (current Node.js executable)

Node.js executable used to create the child process.

#### nodeOptions *(For `.node()` only)*
Requires the [`node`](#node) option to be `true`.

Type: `string[]`\
Default: [`process.execArgv`](https://nodejs.org/api/process.html#process_process_execargv)
#### execPath

List of [CLI options](https://nodejs.org/api/cli.html#cli_options) passed to the Node.js executable.
Type: `string | URL`\
Default: [`process.execPath`](https://nodejs.org/api/process.html#process_process_execpath) (current Node.js executable)

Path to the Node.js executable to use in child processes.

This can be either an absolute path or a path relative to the [`cwd` option](#cwd).

Requires the [`preferLocal`](#preferlocal) option to be `true`.

For example, this can be used together with [`get-node`](https://github.com/ehmicky/get-node) to run a specific Node.js version in a child process.

#### verbose

Expand Down Expand Up @@ -719,7 +723,7 @@ Largest amount of data in bytes allowed on [`stdout`](#stdout), [`stderr`](#stde
#### ipc

Type: `boolean`\
Default: `true` with [`execaNode()`](#execanodescriptpath-arguments-options), `false` otherwise
Default: `true` if the [`node`](#node) option is enabled, `false` otherwise

Enables exchanging messages with the child process using [`childProcess.send(value)`](https://nodejs.org/api/child_process.html#subprocesssendmessage-sendhandle-options-callback) and [`childProcess.on('message', (value) => {})`](https://nodejs.org/api/child_process.html#event-message).

Expand Down
9 changes: 6 additions & 3 deletions test/fixtures/nested-node.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
#!/usr/bin/env node
import process from 'node:process';
import {writeSync} from 'node:fs';
import {execaNode} from '../../index.js';
import {execa, execaNode} from '../../index.js';

const [fakeExecArgv, nodeOptions, file, ...args] = process.argv.slice(2);
const [fakeExecArgv, execaMethod, nodeOptions, file, ...args] = process.argv.slice(2);

if (fakeExecArgv !== '') {
process.execArgv = [fakeExecArgv];
}

const {stdout, stderr} = await execaNode(file, args, {nodeOptions: [nodeOptions].filter(Boolean)});
const filteredNodeOptions = [nodeOptions].filter(Boolean);
const {stdout, stderr} = await (execaMethod === 'execaNode'
? execaNode(file, args, {nodeOptions: filteredNodeOptions})
: execa(file, args, {nodeOptions: filteredNodeOptions, node: true}));
console.log(stdout);
writeSync(3, stderr);

0 comments on commit 5532b2e

Please sign in to comment.