Skip to content

Commit

Permalink
Allow running local binaries
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Aug 25, 2024
1 parent d4eef84 commit f71a6ad
Show file tree
Hide file tree
Showing 3 changed files with 59 additions and 20 deletions.
31 changes: 26 additions & 5 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,12 +51,31 @@ const getOptions = ({
stderr,
stdio = [stdin, stdout, stderr],
env,
cwd: cwdOption = '.',
...options
}) => ({
...options,
stdio,
env: env === undefined ? env : {...process.env, ...env},
});
}) => {
const cwd = cwdOption instanceof URL ? fileURLToPath(cwdOption) : path.resolve(cwdOption);
return {
...options,
stdio,
env: getEnv({...process.env, ...env}, cwd),
cwd,
};
};

const getEnv = ({Path, PATH = Path, ...env}, cwd) => ({...env, PATH: getPath(PATH, cwd)});

const getPath = (PATH, 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 [...localPaths, PATH].filter(Boolean).join(path.delimiter);
};

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

const getInput = ({stdio}) => {
if (stdio[0]?.string === undefined) {
Expand Down
38 changes: 29 additions & 9 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -552,23 +552,43 @@ 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'], {cwd});
t.regex(stdout, VERSION_REGEXP);
};

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

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

test('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()}`], {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], {cwd: FIXTURES_URL});
t.is(stderr, input);
};

Expand Down
10 changes: 4 additions & 6 deletions windows.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
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.platform === 'win32'
&& !shell
&& !isExe(file, cwd, env);
&& !isExe(file, cwd, env.PATH);

// Detect whether the executable file is a *.exe or *.com file.
// Windows allows omitting file extensions (present in the `PATHEXT` environment variable).
// Therefore we must use the `PATH` environment variable and make `stat` calls to check this.
// Environment variables are case-insensitive on Windows, so we check both `PATH` and `Path`.
const isExe = (file, cwd, {Path = '', PATH = Path}) => {
const isExe = (file, cwd, PATH = '') => {
// If the *.exe or *.com file extension was not omitted.
// Windows common file systems are case-insensitive.
if (exeExtensions.some(extension => file.toLowerCase().endsWith(extension))) {
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 f71a6ad

Please sign in to comment.