diff --git a/bin/npm b/bin/npm index 027dc9d128d22..d68e1b6d102d2 100755 --- a/bin/npm +++ b/bin/npm @@ -3,6 +3,25 @@ # This is used by the Node.js installer, which expects the cygwin/mingw # shell script to already be present in the npm dependency folder. +# Git Bash (msys) and Cygwin: start node once. npm-shim.js picks this npm or +# the one in the global prefix in the same node process, and msys converts the +# script path for node.exe, so this needs no subshells (Cygwin: one cygpath). +case "$OSTYPE" in + msys*|cygwin*) + set -o igncr 2>/dev/null # cygwin encoding fix + basedir=${0%/*} + [ "$basedir" = "$0" ] && basedir=. + case "$OSTYPE" in cygwin*) basedir=$(cygpath -w "$basedir");; esac + NODE_EXE="$basedir/node.exe" + [ -x "$NODE_EXE" ] || NODE_EXE="$basedir/node" + [ -x "$NODE_EXE" ] || NODE_EXE=node + "$NODE_EXE" "$basedir/node_modules/npm/bin/npm-shim.js" npm "$@" + exit $? + ;; +esac + +# Everything else, e.g. WSL running the Windows install: find the prefix first. + (set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix basedir=`dirname "$0"` diff --git a/bin/npm-prefix.js b/bin/npm-prefix.js index b0b0ace6a92ab..2c97e19debe61 100755 --- a/bin/npm-prefix.js +++ b/bin/npm-prefix.js @@ -2,25 +2,15 @@ // This is a single-use bin to help windows discover the proper prefix for npm // without having to load all of npm first // It does not accept argv params +// The Windows shims now make this decision in-process via npm-shim.js; this +// is still used by bin/npm and bin/npx outside Git Bash and Cygwin (WSL). -const path = require('node:path') -const Config = require('@npmcli/config') -const { definitions, flatten, shorthands } = require('@npmcli/config/lib/definitions') -const config = new Config({ - npmPath: path.dirname(__dirname), - // argv is explicitly not looked at since prefix is not something that can be changed via argv - argv: [], - definitions, - flatten, - shorthands, - excludeNpmCwd: false, -}) +const globalPrefix = require('../lib/cli/global-prefix.js') async function main () { try { - await config.load() // eslint-disable-next-line no-console - console.log(config.globalPrefix) + console.log(await globalPrefix()) } catch (err) { // eslint-disable-next-line no-console console.error(err) diff --git a/bin/npm-shim.js b/bin/npm-shim.js new file mode 100755 index 0000000000000..9d2310669f1d3 --- /dev/null +++ b/bin/npm-shim.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Entry point for the Windows shims (npm, npx, npm.cmd, npx.cmd, npm.ps1, +// npx.ps1): node npm-shim.js [args...] +// +// It runs the npm installed in the global prefix if there is one, else this +// one, like the shims did with npm-prefix.js, but decides in the node process +// that then runs npm instead of starting node a second time to print the +// prefix. + +const { join } = require('node:path') +const { existsSync } = require('node:fs') +const globalPrefix = require('../lib/cli/global-prefix.js') + +async function main () { + const [node, , bin, ...args] = process.argv + const cliName = bin === 'npx' ? 'npx-cli.js' : 'npm-cli.js' + let prefix + try { + prefix = await globalPrefix() + } catch (err) { + // eslint-disable-next-line no-console + console.error(err) + // eslint-disable-next-line no-console + console.error('Could not determine Node.js install directory') + process.exit(1) + return + } + const fromPrefix = join(prefix, 'node_modules', 'npm', 'bin', cliName) + const cli = existsSync(fromPrefix) ? fromPrefix : join(__dirname, cliName) + // the cli sees the same argv as when it is run directly + process.argv = [node, cli, ...args] + require(cli) +} +main() diff --git a/bin/npm.cmd b/bin/npm.cmd index 68af4b0fca09f..48cee72098a1a 100755 --- a/bin/npm.cmd +++ b/bin/npm.cmd @@ -8,13 +8,4 @@ IF NOT EXIST "%NODE_EXE%" ( SET "NODE_EXE=node" ) -SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js" -SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js" -FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO ( - SET "NPM_PREFIX_NPM_CLI_JS=%%F\node_modules\npm\bin\npm-cli.js" -) -IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" ( - SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%" -) - -"%NODE_EXE%" "%NPM_CLI_JS%" %* +"%NODE_EXE%" "%~dp0\node_modules\npm\bin\npm-shim.js" npm %* diff --git a/bin/npm.ps1 b/bin/npm.ps1 index efed03fe5655e..0142350a46462 100644 --- a/bin/npm.ps1 +++ b/bin/npm.ps1 @@ -10,24 +10,13 @@ if (-not (Test-Path $NODE_EXE)) { $NODE_EXE="node" } -$NPM_PREFIX_JS="$PSScriptRoot/node_modules/npm/bin/npm-prefix.js" -$NPM_CLI_JS="$PSScriptRoot/node_modules/npm/bin/npm-cli.js" -$NPM_PREFIX=(& $NODE_EXE $NPM_PREFIX_JS) - -if ($LASTEXITCODE -ne 0) { - Write-Host "Could not determine Node.js install directory" - exit 1 -} - -$NPM_PREFIX_NPM_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npm-cli.js" -if (Test-Path $NPM_PREFIX_NPM_CLI_JS) { - $NPM_CLI_JS=$NPM_PREFIX_NPM_CLI_JS -} +# npm-shim.js picks this npm or the one in the global prefix, in the same node process. +$NPM_SHIM_JS="$PSScriptRoot/node_modules/npm/bin/npm-shim.js" if ($MyInvocation.ExpectingInput) { # takes pipeline input - $input | & $NODE_EXE $NPM_CLI_JS $args + $input | & $NODE_EXE $NPM_SHIM_JS npm $args } elseif (-not $MyInvocation.Line) { # used "-File" argument - & $NODE_EXE $NPM_CLI_JS $args + & $NODE_EXE $NPM_SHIM_JS npm $args } else { # used "-Command" argument if (($MyInvocation | Get-Member -Name 'Statement') -and $MyInvocation.Statement) { $NPM_ORIGINAL_COMMAND = $MyInvocation.Statement @@ -38,13 +27,13 @@ if ($MyInvocation.ExpectingInput) { # takes pipeline input } $NODE_EXE = $NODE_EXE.Replace("``", "````") - $NPM_CLI_JS = $NPM_CLI_JS.Replace("``", "````") + $NPM_SHIM_JS = $NPM_SHIM_JS.Replace("``", "````") $NPM_COMMAND_ARRAY = [Management.Automation.Language.Parser]::ParseInput($NPM_ORIGINAL_COMMAND, [ref] $null, [ref] $null). EndBlock.Statements.PipelineElements.CommandElements.Extent.Text $NPM_ARGS = ($NPM_COMMAND_ARRAY | Select-Object -Skip 1) -join ' ' - Invoke-Expression "& `"$NODE_EXE`" `"$NPM_CLI_JS`" $NPM_ARGS" + Invoke-Expression "& `"$NODE_EXE`" `"$NPM_SHIM_JS`" npm $NPM_ARGS" } exit $LASTEXITCODE diff --git a/bin/npx b/bin/npx index b8619ee9c5e37..330749471b41c 100755 --- a/bin/npx +++ b/bin/npx @@ -3,6 +3,25 @@ # This is used by the Node.js installer, which expects the cygwin/mingw # shell script to already be present in the npm dependency folder. +# Git Bash (msys) and Cygwin: start node once. npm-shim.js picks this npm or +# the one in the global prefix in the same node process, and msys converts the +# script path for node.exe, so this needs no subshells (Cygwin: one cygpath). +case "$OSTYPE" in + msys*|cygwin*) + set -o igncr 2>/dev/null # cygwin encoding fix + basedir=${0%/*} + [ "$basedir" = "$0" ] && basedir=. + case "$OSTYPE" in cygwin*) basedir=$(cygpath -w "$basedir");; esac + NODE_EXE="$basedir/node.exe" + [ -x "$NODE_EXE" ] || NODE_EXE="$basedir/node" + [ -x "$NODE_EXE" ] || NODE_EXE=node + "$NODE_EXE" "$basedir/node_modules/npm/bin/npm-shim.js" npx "$@" + exit $? + ;; +esac + +# Everything else, e.g. WSL running the Windows install: find the prefix first. + (set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix basedir=`dirname "$0"` diff --git a/bin/npx.cmd b/bin/npx.cmd index ab991abfc2562..1be32b6398cab 100755 --- a/bin/npx.cmd +++ b/bin/npx.cmd @@ -8,13 +8,4 @@ IF NOT EXIST "%NODE_EXE%" ( SET "NODE_EXE=node" ) -SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js" -SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js" -FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO ( - SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js" -) -IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" ( - SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%" -) - -"%NODE_EXE%" "%NPX_CLI_JS%" %* +"%NODE_EXE%" "%~dp0\node_modules\npm\bin\npm-shim.js" npx %* diff --git a/bin/npx.ps1 b/bin/npx.ps1 index 3fe7b5435763a..6c517f8381a0f 100644 --- a/bin/npx.ps1 +++ b/bin/npx.ps1 @@ -10,24 +10,13 @@ if (-not (Test-Path $NODE_EXE)) { $NODE_EXE="node" } -$NPM_PREFIX_JS="$PSScriptRoot/node_modules/npm/bin/npm-prefix.js" -$NPX_CLI_JS="$PSScriptRoot/node_modules/npm/bin/npx-cli.js" -$NPM_PREFIX=(& $NODE_EXE $NPM_PREFIX_JS) - -if ($LASTEXITCODE -ne 0) { - Write-Host "Could not determine Node.js install directory" - exit 1 -} - -$NPM_PREFIX_NPX_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npx-cli.js" -if (Test-Path $NPM_PREFIX_NPX_CLI_JS) { - $NPX_CLI_JS=$NPM_PREFIX_NPX_CLI_JS -} +# npm-shim.js picks this npx or the one in the global prefix, in the same node process. +$NPX_SHIM_JS="$PSScriptRoot/node_modules/npm/bin/npm-shim.js" if ($MyInvocation.ExpectingInput) { # takes pipeline input - $input | & $NODE_EXE $NPX_CLI_JS $args + $input | & $NODE_EXE $NPX_SHIM_JS npx $args } elseif (-not $MyInvocation.Line) { # used "-File" argument - & $NODE_EXE $NPX_CLI_JS $args + & $NODE_EXE $NPX_SHIM_JS npx $args } else { # used "-Command" argument if (($MyInvocation | Get-Member -Name 'Statement') -and $MyInvocation.Statement) { $NPX_ORIGINAL_COMMAND = $MyInvocation.Statement @@ -38,13 +27,13 @@ if ($MyInvocation.ExpectingInput) { # takes pipeline input } $NODE_EXE = $NODE_EXE.Replace("``", "````") - $NPX_CLI_JS = $NPX_CLI_JS.Replace("``", "````") + $NPX_SHIM_JS = $NPX_SHIM_JS.Replace("``", "````") $NPX_COMMAND_ARRAY = [Management.Automation.Language.Parser]::ParseInput($NPX_ORIGINAL_COMMAND, [ref] $null, [ref] $null). EndBlock.Statements.PipelineElements.CommandElements.Extent.Text $NPX_ARGS = ($NPX_COMMAND_ARRAY | Select-Object -Skip 1) -join ' ' - Invoke-Expression "& `"$NODE_EXE`" `"$NPX_CLI_JS`" $NPX_ARGS" + Invoke-Expression "& `"$NODE_EXE`" `"$NPX_SHIM_JS`" npx $NPX_ARGS" } exit $LASTEXITCODE diff --git a/lib/cli/global-prefix.js b/lib/cli/global-prefix.js new file mode 100644 index 0000000000000..5c6be0662ccee --- /dev/null +++ b/lib/cli/global-prefix.js @@ -0,0 +1,25 @@ +// Finds the global prefix the way npm does, without loading the rest of npm. +// The Windows shims use it (through bin/npm-shim.js and bin/npm-prefix.js) to +// run the npm installed in the global prefix, if there is one, instead of the +// npm that came with Node.js. + +const { resolve } = require('node:path') +const Config = require('@npmcli/config') +const { definitions, flatten, shorthands } = require('@npmcli/config/lib/definitions') + +module.exports = async () => { + const config = new Config({ + npmPath: resolve(__dirname, '../..'), + // argv is explicitly not looked at since prefix is not something that can be changed via argv + argv: [], + definitions, + flatten, + shorthands, + excludeNpmCwd: false, + // a copy: load() exports npm_config_* variables to env, and npm-shim.js + // then runs npm, maybe another version of it, in this same process + env: { ...process.env }, + }) + await config.load() + return config.globalPrefix +} diff --git a/test/bin/npm-prefix.js b/test/bin/npm-prefix.js new file mode 100644 index 0000000000000..9d8e9c1652409 --- /dev/null +++ b/test/bin/npm-prefix.js @@ -0,0 +1,34 @@ +const t = require('tap') +const mockGlobals = require('@npmcli/mock-globals') +const tmock = require('../fixtures/tmock') + +const mockPrefix = async (t, globalPrefix) => { + const logs = [] + const errors = [] + const exits = [] + mockGlobals(t, { + 'console.log': (...msg) => logs.push(msg), + 'console.error': (...msg) => errors.push(msg), + 'process.exit': (code) => exits.push(code), + }) + tmock(t, '{BIN}/npm-prefix.js', { '{LIB}/cli/global-prefix.js': globalPrefix }) + await new Promise(resolve => setImmediate(resolve)) + return { logs, errors, exits } +} + +t.test('prints the global prefix', async t => { + const { logs, errors, exits } = await mockPrefix(t, async () => '/global/prefix') + t.strictSame(logs, [['/global/prefix']]) + t.strictSame(errors, []) + t.strictSame(exits, []) +}) + +t.test('exits 1 if the global prefix cannot be determined', async t => { + const err = new Error('bad config') + const { logs, errors, exits } = await mockPrefix(t, async () => { + throw err + }) + t.strictSame(logs, []) + t.strictSame(errors, [[err]]) + t.strictSame(exits, [1]) +}) diff --git a/test/bin/npm-shim.js b/test/bin/npm-shim.js new file mode 100644 index 0000000000000..313a778163350 --- /dev/null +++ b/test/bin/npm-shim.js @@ -0,0 +1,106 @@ +const t = require('tap') +const { join } = require('node:path') +const mockGlobals = require('@npmcli/mock-globals') +const tmock = require('../fixtures/tmock') + +const BIN = join(__dirname, '../../bin') + +// the cli files record what they see when they are loaded +const cli = (name) => `global.npmShimSeen.push({ cli: ${JSON.stringify(name)}, argv: process.argv.slice() })` + +const mockShim = async (t, { argv, prefix, prefixError }) => { + const seen = [] + const errors = [] + const exits = [] + mockGlobals(t, { + npmShimSeen: seen, + 'process.argv': argv, + 'console.error': (...msg) => errors.push(msg), + 'process.exit': (code) => exits.push(code), + }) + // the npm that came with node, next to the shim; process.argv[1] tells + // which cli was loaded, since the shim sets it right before the require + tmock(t, '{BIN}/npm-shim.js', { + '{LIB}/cli/global-prefix.js': async () => { + if (prefixError) { + throw prefixError + } + return prefix + }, + '{BIN}/npm-cli.js': {}, + '{BIN}/npx-cli.js': {}, + }) + await new Promise(resolve => setImmediate(resolve)) + return { seen, errors, exits } +} + +t.test('runs the npm in the global prefix if there is one', async t => { + const prefix = t.testdir({ + node_modules: { npm: { bin: { 'npm-cli.js': cli('global npm'), 'npx-cli.js': cli('global npx') } } }, + }) + const npmCli = join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js') + const { seen, errors, exits } = await mockShim(t, { + argv: ['node', join(BIN, 'npm-shim.js'), 'npm', 'install', '--save-dev', 'a b', ''], + prefix, + }) + t.strictSame(seen, [{ cli: 'global npm', argv: ['node', npmCli, 'install', '--save-dev', 'a b', ''] }]) + t.strictSame(errors, []) + t.strictSame(exits, []) +}) + +t.test('runs the npx in the global prefix if there is one', async t => { + const prefix = t.testdir({ + node_modules: { npm: { bin: { 'npm-cli.js': cli('global npm'), 'npx-cli.js': cli('global npx') } } }, + }) + const npxCli = join(prefix, 'node_modules', 'npm', 'bin', 'npx-cli.js') + const { seen } = await mockShim(t, { + argv: ['node', join(BIN, 'npm-shim.js'), 'npx', '--no-install', 'tsc', '--version'], + prefix, + }) + t.strictSame(seen, [{ cli: 'global npx', argv: ['node', npxCli, '--no-install', 'tsc', '--version'] }]) +}) + +t.test('runs its own npm if the global prefix has none', async t => { + const prefix = t.testdir({}) + const { seen, errors, exits } = await mockShim(t, { + argv: ['node', join(BIN, 'npm-shim.js'), 'npm', '--version'], + prefix, + }) + t.strictSame(seen, [], 'the global prefix npm is not loaded') + t.strictSame(process.argv, ['node', join(BIN, 'npm-cli.js'), '--version'], 'argv as seen by npm-cli.js') + t.strictSame(errors, []) + t.strictSame(exits, []) +}) + +t.test('runs its own npx if the global prefix has none', async t => { + const prefix = t.testdir({}) + const { seen } = await mockShim(t, { + argv: ['node', join(BIN, 'npm-shim.js'), 'npx', 'cowsay', 'hi'], + prefix, + }) + t.strictSame(seen, [], 'the global prefix npx is not loaded') + t.strictSame(process.argv, ['node', join(BIN, 'npx-cli.js'), 'cowsay', 'hi'], 'argv as seen by npx-cli.js') +}) + +t.test('a global prefix with only npx does not count for npm', async t => { + const prefix = t.testdir({ + node_modules: { npm: { bin: { 'npx-cli.js': cli('global npx') } } }, + }) + const { seen } = await mockShim(t, { + argv: ['node', join(BIN, 'npm-shim.js'), 'npm', 'ls'], + prefix, + }) + t.strictSame(seen, []) + t.strictSame(process.argv, ['node', join(BIN, 'npm-cli.js'), 'ls']) +}) + +t.test('exits 1 if the global prefix cannot be determined', async t => { + const prefixError = new Error('bad config') + const { seen, errors, exits } = await mockShim(t, { + argv: ['node', join(BIN, 'npm-shim.js'), 'npm', '--version'], + prefixError, + }) + t.strictSame(seen, [], 'no cli is loaded') + t.strictSame(errors, [[prefixError], ['Could not determine Node.js install directory']]) + t.strictSame(exits, [1]) +}) diff --git a/test/bin/windows-shims.js b/test/bin/windows-shims.js index d785fbe7b1c55..5b75ca1ce1955 100644 --- a/test/bin/windows-shims.js +++ b/test/bin/windows-shims.js @@ -88,11 +88,13 @@ t.test('run shims', t => { npm: t.fixture('symlink', ROOT), }, }, - // put in a shim that ONLY prints the intended global prefix, - // and should not be used for anything else. + // the real entry point of the shims, and a fake of the helper it uses to + // find the global prefix: it ONLY returns the intended global prefix. + // npm-prefix.js is the same, for the WSL path of the bash shims. node_modules: { npm: { bin: { + 'npm-shim.js': readFileSync(join(BIN, 'npm-shim.js'), 'utf-8'), 'npm-prefix.js': ` const { resolve } = require('path') console.log(resolve(__dirname, '../../../global-prefix')) @@ -100,6 +102,14 @@ t.test('run shims', t => { 'npx-cli.js': `throw new Error('local npx should not be called')`, 'npm-cli.js': `throw new Error('local npm should not be called')`, }, + lib: { + cli: { + 'global-prefix.js': ` + const { resolve } = require('path') + module.exports = async () => resolve(__dirname, '../../../../global-prefix') + `, + }, + }, }, }, // test script returning all command line arguments diff --git a/test/lib/cli/global-prefix.js b/test/lib/cli/global-prefix.js new file mode 100644 index 0000000000000..7f7d91c2347cc --- /dev/null +++ b/test/lib/cli/global-prefix.js @@ -0,0 +1,56 @@ +const t = require('tap') +const { resolve } = require('node:path') +const mockGlobals = require('@npmcli/mock-globals') +const tmock = require('../../fixtures/tmock') + +const ROOT = resolve(__dirname, '../../..') + +t.test('loads config like npm, without argv, and returns the global prefix', async t => { + const created = [] + class Config { + constructor (opts) { + created.push(opts) + this.globalPrefix = '/global/prefix' + } + + async load () {} + } + const globalPrefix = tmock(t, '{LIB}/cli/global-prefix.js', { '@npmcli/config': Config }) + t.equal(await globalPrefix(), '/global/prefix') + t.equal(created.length, 1) + t.match(created[0], { + npmPath: ROOT, + argv: [], + definitions: Object, + flatten: Function, + shorthands: Object, + excludeNpmCwd: false, + env: { ...process.env }, + }) + t.not(created[0].env, process.env, 'config gets a copy of the environment') +}) + +t.test('rejects if config cannot be loaded', async t => { + class Config { + async load () { + throw new Error('bad config') + } + } + const globalPrefix = tmock(t, '{LIB}/cli/global-prefix.js', { '@npmcli/config': Config }) + await t.rejects(globalPrefix(), /bad config/) +}) + +t.test('honors the prefix config, as npm does', async t => { + const prefix = t.testdir({}) + mockGlobals(t, { 'process.env.npm_config_prefix': prefix }) + const globalPrefix = require('../../../lib/cli/global-prefix.js') + t.equal(await globalPrefix(), prefix) +}) + +t.test('leaves process.env alone for the npm that runs next', async t => { + mockGlobals(t, { 'process.env': { ...process.env } }) + const before = { ...process.env } + const globalPrefix = require('../../../lib/cli/global-prefix.js') + await globalPrefix() + t.strictSame({ ...process.env }, before) +})