Skip to content

Commit

Permalink
Allow running local binaries (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored Aug 27, 2024
1 parent 6567f40 commit 381a015
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 45 deletions.
33 changes: 27 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {stripVTControlCharacters} from 'node:util';
import path from 'node:path';
import process from 'node:process';
import {finished} from 'node:stream/promises';
import {fileURLToPath} from 'node:url';
import {lineIterator, combineAsyncIterators} from './iterable.js';
import {getForcedShell, escapeArguments} from './windows.js';

Expand Down Expand Up @@ -49,13 +51,32 @@ const getOptions = ({
stdout,
stderr,
stdio = [stdin, stdout, stderr],
env,
env: envOption,
preferLocal,
cwd: cwdOption = '.',
...options
}) => ({
...options,
stdio,
env: env === undefined ? env : {...process.env, ...env},
});
}) => {
const cwd = cwdOption instanceof URL ? fileURLToPath(cwdOption) : path.resolve(cwdOption);
const env = envOption === undefined ? undefined : {...process.env, ...envOption};
return {
...options,
stdio,
env: preferLocal ? addLocalPath(env ?? process.env, cwd) : env,
cwd,
};
};

const addLocalPath = ({Path = '', PATH = Path, ...env}, cwd) => {
const pathParts = PATH.split(path.delimiter);
const localPaths = getLocalPaths([], path.resolve(cwd))
.map(localPath => path.join(localPath, 'node_modules/.bin'))
.filter(localPath => !pathParts.includes(localPath));
return {...env, PATH: [...localPaths, PATH].filter(Boolean).join(path.delimiter)};
};

const getLocalPaths = (localPaths, localPath) => localPaths.at(-1) === localPath
? localPaths
: getLocalPaths([...localPaths, localPath], path.resolve(localPath, '..'));

// When running `node`, keep the current Node version and CLI flags.
// Not applied with file paths to `.../node` since those indicate a clear intent to use a specific Node version.
Expand Down
127 changes: 92 additions & 35 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -737,52 +737,109 @@ test('Handles non-existing command, shell', async t => {
}
});

const VERSION_REGEXP = /^\d+\.\d+\.\d+$/;

test('Can run global npm binaries', async t => {
const {stdout} = await nanoSpawn('npm', ['--version']);
t.regex(stdout, /^\d+\.\d+\.\d+$/);
t.regex(stdout, VERSION_REGEXP);
});

test('Can run local npm binaries', async t => {
const testLocalBinaryExec = async (t, cwd) => {
const {stdout} = await nanoSpawn('ava', ['--version'], {preferLocal: true, cwd});
t.regex(stdout, VERSION_REGEXP);
};

test('options.preferLocal true runs local npm binaries', testLocalBinaryExec, undefined);
test('options.preferLocal true runs local npm binaries with options.cwd string', testLocalBinaryExec, './fixtures');
test('options.preferLocal true runs local npm binaries with options.cwd URL', testLocalBinaryExec, FIXTURES_URL);

if (!isWindows) {
const testPathVariable = async (t, pathName) => {
const {stdout} = await nanoSpawn('ava', ['--version'], {preferLocal: true, env: {Path: undefined, [pathName]: path.dirname(process.execPath)}});
t.regex(stdout, VERSION_REGEXP);
};

test('options.preferLocal true uses options.env.PATH when set', testPathVariable, 'PATH');
test('options.preferLocal true uses options.env.Path when set', testPathVariable, 'Path');
}

const testNoLocal = async (t, preferLocal) => {
const PATH = process.env[pathKey()]
.split(path.delimiter)
.filter(pathPart => !pathPart.includes(path.join('node_modules', '.bin')))
.join(path.delimiter);
const {stderr, cause} = await t.throwsAsync(nanoSpawn('ava', ['--version'], {preferLocal, env: {Path: undefined, PATH}}));
if (isWindows) {
t.true(stderr.includes('\'ava\' is not recognized as an internal or external command'));
} else {
t.is(cause.code, 'ENOENT');
t.is(cause.path, 'ava');
}
};

test('options.preferLocal undefined does not run local npm binaries', testNoLocal, undefined);
test('options.preferLocal false does not run local npm binaries', testNoLocal, false);

test('options.preferLocal true uses options.env when empty', async t => {
const {exitCode, stderr, cause} = await t.throwsAsync(nanoSpawn('ava', ['--version'], {preferLocal: true, env: {PATH: undefined, Path: undefined}}));
if (isWindows) {
t.is(cause.code, 'ENOENT');
} else {
t.is(exitCode, 127);
t.true(stderr.includes('No such file'));
}
});

if (isWindows) {
test('options.preferLocal true runs local npm binaries with process.env.Path', async t => {
const {stdout} = await nanoSpawn('ava', ['--version'], {preferLocal: true, env: {PATH: undefined, Path: process.env[pathKey()]}});
t.regex(stdout, VERSION_REGEXP);
});
}

test('options.preferLocal true does not add node_modules/.bin if already present', async t => {
const localDirectory = fileURLToPath(new URL('node_modules/.bin', import.meta.url));
const pathValue = `${process.env[pathKey()]}${path.delimiter}${localDirectory}`;
const {stdout} = await nanoSpawn('ava', ['--version'], {[pathKey()]: pathValue});
t.regex(stdout, /^\d+\.\d+\.\d+$/);
const currentPath = process.env[pathKey()];
const pathValue = `${localDirectory}${path.delimiter}${currentPath}`;
const {stdout} = await nanoSpawn('node', ['-p', `process.env.${pathKey()}`], {preferLocal: true, env: {[pathKey()]: pathValue}});
t.is(
stdout.split(path.delimiter).filter(pathPart => pathPart === localDirectory).length
- currentPath.split(path.delimiter).filter(pathPart => pathPart === localDirectory).length,
1,
);
});

const testLocalBinary = async (t, input) => {
const localDirectory = fileURLToPath(new URL('node_modules/.bin', import.meta.url));
const pathValue = `${process.env[pathKey()]}${path.delimiter}${localDirectory}`;
const testFile = fileURLToPath(new URL('fixtures/test.js', import.meta.url));
const {stderr} = await nanoSpawn('ava', [testFile, '--', input], {[pathKey()]: pathValue});
const {stderr} = await nanoSpawn('ava', ['test.js', '--', input], {preferLocal: true, cwd: FIXTURES_URL});
t.is(stderr, input);
};

test('Can pass arguments to local npm binaries, "', testLocalBinary, '"');
test('Can pass arguments to local npm binaries, \\', testLocalBinary, '\\');
test('Can pass arguments to local npm binaries, \\.', testLocalBinary, '\\.');
test('Can pass arguments to local npm binaries, \\"', testLocalBinary, '\\"');
test('Can pass arguments to local npm binaries, \\\\"', testLocalBinary, '\\\\"');
test('Can pass arguments to local npm binaries, a b', testLocalBinary, 'a b');
test('Can pass arguments to local npm binaries, \'.\'', testLocalBinary, '\'.\'');
test('Can pass arguments to local npm binaries, "."', testLocalBinary, '"."');
test('Can pass arguments to local npm binaries, (', testLocalBinary, '(');
test('Can pass arguments to local npm binaries, )', testLocalBinary, ')');
test('Can pass arguments to local npm binaries, ]', testLocalBinary, ']');
test('Can pass arguments to local npm binaries, [', testLocalBinary, '[');
test('Can pass arguments to local npm binaries, %', testLocalBinary, '%');
test('Can pass arguments to local npm binaries, %1', testLocalBinary, '%1');
test('Can pass arguments to local npm binaries, !', testLocalBinary, '!');
test('Can pass arguments to local npm binaries, ^', testLocalBinary, '^');
test('Can pass arguments to local npm binaries, `', testLocalBinary, '`');
test('Can pass arguments to local npm binaries, <', testLocalBinary, '<');
test('Can pass arguments to local npm binaries, >', testLocalBinary, '>');
test('Can pass arguments to local npm binaries, &', testLocalBinary, '&');
test('Can pass arguments to local npm binaries, |', testLocalBinary, '|');
test('Can pass arguments to local npm binaries, ;', testLocalBinary, ';');
test('Can pass arguments to local npm binaries, ,', testLocalBinary, ',');
test('Can pass arguments to local npm binaries, space', testLocalBinary, ' ');
test('Can pass arguments to local npm binaries, *', testLocalBinary, '*');
test('Can pass arguments to local npm binaries, ?', testLocalBinary, '?');
test('options.preferLocal true can pass arguments to local npm binaries, "', testLocalBinary, '"');
test('options.preferLocal true can pass arguments to local npm binaries, \\', testLocalBinary, '\\');
test('options.preferLocal true can pass arguments to local npm binaries, \\.', testLocalBinary, '\\.');
test('options.preferLocal true can pass arguments to local npm binaries, \\"', testLocalBinary, '\\"');
test('options.preferLocal true can pass arguments to local npm binaries, \\\\"', testLocalBinary, '\\\\"');
test('options.preferLocal true can pass arguments to local npm binaries, a b', testLocalBinary, 'a b');
test('options.preferLocal true can pass arguments to local npm binaries, \'.\'', testLocalBinary, '\'.\'');
test('options.preferLocal true can pass arguments to local npm binaries, "."', testLocalBinary, '"."');
test('options.preferLocal true can pass arguments to local npm binaries, (', testLocalBinary, '(');
test('options.preferLocal true can pass arguments to local npm binaries, )', testLocalBinary, ')');
test('options.preferLocal true can pass arguments to local npm binaries, ]', testLocalBinary, ']');
test('options.preferLocal true can pass arguments to local npm binaries, [', testLocalBinary, '[');
test('options.preferLocal true can pass arguments to local npm binaries, %', testLocalBinary, '%');
test('options.preferLocal true can pass arguments to local npm binaries, %1', testLocalBinary, '%1');
test('options.preferLocal true can pass arguments to local npm binaries, !', testLocalBinary, '!');
test('options.preferLocal true can pass arguments to local npm binaries, ^', testLocalBinary, '^');
test('options.preferLocal true can pass arguments to local npm binaries, `', testLocalBinary, '`');
test('options.preferLocal true can pass arguments to local npm binaries, <', testLocalBinary, '<');
test('options.preferLocal true can pass arguments to local npm binaries, >', testLocalBinary, '>');
test('options.preferLocal true can pass arguments to local npm binaries, &', testLocalBinary, '&');
test('options.preferLocal true can pass arguments to local npm binaries, |', testLocalBinary, '|');
test('options.preferLocal true can pass arguments to local npm binaries, ;', testLocalBinary, ';');
test('options.preferLocal true can pass arguments to local npm binaries, ,', testLocalBinary, ',');
test('options.preferLocal true can pass arguments to local npm binaries, space', testLocalBinary, ' ');
test('options.preferLocal true can pass arguments to local npm binaries, *', testLocalBinary, '*');
test('options.preferLocal true can pass arguments to local npm binaries, ?', testLocalBinary, '?');

test('Can run OS binaries', async t => {
const {stdout} = await nanoSpawn('git', ['--version']);
Expand Down
6 changes: 2 additions & 4 deletions windows.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {statSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import process from 'node:process';

// On Windows, running most executable files (except *.exe and *.com) requires using a shell.
// This includes *.cmd and *.bat, which itself includes Node modules binaries.
// We detect this situation and automatically:
// - Set the `shell: true` option
// - Escape shell-specific characters
export const getForcedShell = (file, {shell, cwd = '.', env = process.env}) => process.platform === 'win32'
export const getForcedShell = (file, {shell, cwd, env = process.env}) => process.platform === 'win32'
&& !shell
&& !isExe(file, cwd, env);

Expand All @@ -23,7 +22,6 @@ const isExe = (file, cwd, {Path = '', PATH = Path}) => {
return true;
}

const cwdPath = cwd instanceof URL ? fileURLToPath(cwd) : cwd;
const parts = PATH
// `PATH` is ;-separated on Windows
.split(path.delimiter)
Expand All @@ -32,7 +30,7 @@ const isExe = (file, cwd, {Path = '', PATH = Path}) => {
// `PATH` parts can be double quoted on Windows
.map(part => part.replace(/^"(.*)"$/, '$1'));
const possibleFiles = exeExtensions.flatMap(extension =>
[cwdPath, ...parts].map(part => `${path.resolve(part, file)}${extension}`));
[cwd, ...parts].map(part => `${path.resolve(part, file)}${extension}`));
return possibleFiles.some(possibleFile => {
try {
// This must unfortunately be synchronous because we return the spawned `subprocess` synchronously
Expand Down

0 comments on commit 381a015

Please sign in to comment.