Files
Sebastian "Sebbie" SilbermannandGitHub 1353c78815 Always consult npm_config_user_agent first (#95879)
The `getPkgManager` helpers previously consulted the filesystem first
for files hinting at the used package manager. Only if none were found
did we check `process.env.npm_config_user_agent` and then shelled out to
testing which package manager is available.

This change makes all helpers check the `npm_config_user_agent`
environment variable first. The variable is set by the package manager
that invoked the process, so when present it is both the cheapest and
the most direct signal, avoiding filesystem access and subprocess spawns
entirely. `create-next-app` already used this user-agent-first approach
and is unchanged.

The check order is now consistent across all three implementations:
yarn, pnpm, bun, npm for the user agent (the `packages/next` variant has
no `bun` in its `PackageManager` type and falls through to lockfile
detection there), and the same order for the codemod's lockfile walk,
which previously prioritized `package-lock.json`.

Because the invoking package manager now takes precedence over lockfile
inference, running `npx next ...` or `npx @next/codemod ...` inside a
pnpm or yarn app is now detected as npm. Use `pnpx` (or `pnpm dlx`) and
`yarn dlx` instead so the detected package manager matches the project.
2026-07-22 11:52:14 +02:00

217 lines
5.3 KiB
TypeScript

import findUp from 'find-up'
import execa from 'execa'
import { execSync } from 'node:child_process'
import { basename } from 'node:path'
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
/**
* Get the full version string for the given package manager.
*
* First tries to parse from `npm_config_user_agent` (e.g., "pnpm/9.13.2 npm/? ..."),
* then falls back to spawning `<packageManager> --version`.
*
* Returns null if unable to determine the version.
*
* Mirrors `packages/create-next-app/helpers/get-pkg-manager.ts`.
*/
export function getPackageManagerVersion(
packageManager: PackageManager
): string | null {
const userAgent = process.env.npm_config_user_agent || ''
const userAgentMatch = userAgent.match(
new RegExp(`${packageManager}/([\\d.]+[\\w.-]*)`)
)
if (userAgentMatch) {
return userAgentMatch[1]
}
try {
const version = execSync(`${packageManager} --version`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim()
if (/^\d+\.\d+\.\d+/.test(version)) {
return version
}
} catch {
// package manager not available or failed to run
}
return null
}
/**
* Get the major version of pnpm being used.
* Returns null if unable to determine the version.
*/
export function getPnpmMajorVersion(): number | null {
const version = getPackageManagerVersion('pnpm')
if (!version) return null
const major = parseInt(version.split('.')[0], 10)
return Number.isNaN(major) ? null : major
}
export function getPkgManager(baseDir: string): PackageManager {
try {
const userAgent = process.env.npm_config_user_agent
if (userAgent) {
if (userAgent.startsWith('yarn')) {
return 'yarn'
} else if (userAgent.startsWith('pnpm')) {
return 'pnpm'
} else if (userAgent.startsWith('bun')) {
return 'bun'
} else if (userAgent.startsWith('npm')) {
return 'npm'
}
}
const lockFile = findUp.sync(
[
'yarn.lock',
'pnpm-lock.yaml',
'bun.lock',
'bun.lockb',
'package-lock.json',
],
{ cwd: baseDir }
)
if (lockFile) {
switch (basename(lockFile)) {
case 'yarn.lock':
return 'yarn'
case 'pnpm-lock.yaml':
return 'pnpm'
case 'bun.lock':
case 'bun.lockb':
return 'bun'
case 'package-lock.json':
return 'npm'
default:
return 'npm'
}
}
// No lock file found, default to npm
return 'npm'
} catch {
return 'npm'
}
}
export function uninstallPackage(
packageToUninstall: string,
pkgManager?: PackageManager
) {
pkgManager ??= getPkgManager(process.cwd())
if (!pkgManager) throw new Error('Failed to find package manager')
let command = 'uninstall'
if (pkgManager === 'yarn') {
command = 'remove'
}
try {
execa.sync(pkgManager, [command, packageToUninstall], {
stdio: 'inherit',
shell: true,
})
} catch (error) {
throw new Error(
`Failed to uninstall "${packageToUninstall}". Please uninstall it manually.`,
{ cause: error }
)
}
}
const ADD_CMD_FLAG = {
npm: 'install',
yarn: 'add',
pnpm: 'add',
bun: 'add',
}
const DEV_DEP_FLAG = {
npm: '--save-dev',
yarn: '--dev',
pnpm: '--save-dev',
bun: '--dev',
}
export function installPackages(
packageToInstall: string[],
options: {
packageManager?: PackageManager
silent?: boolean
dev?: boolean
} = {}
) {
if (packageToInstall.length === 0) return
const {
packageManager = getPkgManager(process.cwd()),
silent = false,
dev = false,
} = options
if (!packageManager) throw new Error('Failed to find package manager')
const addCmd = ADD_CMD_FLAG[packageManager]
const devDepFlag = dev ? DEV_DEP_FLAG[packageManager] : undefined
const installFlags = [addCmd]
if (devDepFlag) {
installFlags.push(devDepFlag)
}
try {
execa.sync(packageManager, [...installFlags, ...packageToInstall], {
// Keeping stderr since it'll likely be relevant later when it fails.
stdio: silent ? ['ignore', 'ignore', 'inherit'] : 'inherit',
shell: true,
})
} catch (error) {
throw new Error(
`Failed to install "${packageToInstall}". Please install it manually.`,
{ cause: error }
)
}
}
export function runInstallation(
packageManager: PackageManager,
options: { cwd: string }
) {
try {
execa.sync(packageManager, ['install'], {
cwd: options.cwd,
env: {
...process.env,
// In case NODE_ENV=production is set, we still want dev dependencies to
// be installed. Otherwise we won't be able to check for peer dependencies.
// --production=false is not implemented by every package manager.
NODE_ENV: 'development',
},
stdio: 'inherit',
shell: true,
})
} catch (error) {
throw new Error('Failed to install dependencies', { cause: error })
}
}
export function addPackageDependency(
packageJson: Record<string, any>,
name: string,
version: string,
dev: boolean
): void {
if (dev) {
packageJson.devDependencies = packageJson.devDependencies || {}
} else {
packageJson.dependencies = packageJson.dependencies || {}
}
const deps = dev ? packageJson.devDependencies : packageJson.dependencies
deps[name] = version
}