From 856a7c035738175d550acce130be062f8822ef3e Mon Sep 17 00:00:00 2001 From: ehmicky Date: Sun, 25 Aug 2024 22:01:29 +0100 Subject: [PATCH] Allow running local binaries --- index.js | 33 +++++++++++--- test.js | 127 ++++++++++++++++++++++++++++++++++++++--------------- windows.js | 6 +-- 3 files changed, 121 insertions(+), 45 deletions(-) diff --git a/index.js b/index.js index 6e4ac90..12c1293 100644 --- a/index.js +++ b/index.js @@ -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'; @@ -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. diff --git a/test.js b/test.js index 71c807e..8bc6701 100644 --- a/test.js +++ b/test.js @@ -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']); diff --git a/windows.js b/windows.js index 90b154d..f0b789e 100644 --- a/windows.js +++ b/windows.js @@ -1,6 +1,5 @@ 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. @@ -8,7 +7,7 @@ import process from 'node:process'; // 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); @@ -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) @@ -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