feat: support devEngines.packageManager for pnpm version management (#10932)

## Summary

- Support specifying the pnpm version via `devEngines.packageManager` in `package.json`, as an alternative to the `packageManager` field
- Unlike `packageManager`, `devEngines.packageManager` supports semver ranges — the resolved version is stored in `pnpm-lock.env.yaml` and reused if it still satisfies the range
- The `onFail` field determines behavior: `download` (auto-download), `error` (default), `warn`, or `ignore`
- `devEngines.packageManager` takes precedence over `packageManager` when both are present (with a warning)
- For array notation, default `onFail` is `ignore` for non-last elements and `error` for the last
- For the legacy `packageManager` field, `onFail` is derived from existing config settings (`managePackageManagerVersions`, `packageManagerStrict`, `packageManagerStrictVersion`), so `main.ts` uses `onFail` as the single source of truth
- Reuses `EngineDependency` type from `@pnpm/types` instead of a custom `WantedPackageManager` type

## Test plan

- [x] 10 tests in `switchingVersions.test.ts` — version switching with `packageManager` field, `devEngines.packageManager` with `onFail=download` (exact + range), env lockfile reuse, corrupt binary
- [x] 15 tests in `packageManagerCheck.test.ts` — version checks with `engines.pnpm`, `packageManager` field, `devEngines.packageManager` with all `onFail` values, array notation, range matching, precedence

close https://github.com/pnpm/pnpm/issues/8153
This commit is contained in:
Zoltan Kochan
2026-03-11 18:49:09 +01:00
committed by GitHub
parent 6d56db2aad
commit bb177242df
14 changed files with 646 additions and 57 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config": minor
"pnpm": minor
---
Support specifying the pnpm version via `devEngines.packageManager` in `package.json`. Unlike the `packageManager` field, this supports version ranges. The resolved version is stored in `pnpm-lock.env.yaml` and reused if it still satisfies the range.

View File

@@ -60,6 +60,7 @@
"ramda": "catalog:",
"read-ini-file": "catalog:",
"realpath-missing": "catalog:",
"semver": "catalog:",
"which": "catalog:"
},
"peerDependencies": {
@@ -73,6 +74,7 @@
"@types/is-windows": "catalog:",
"@types/lodash.kebabcase": "catalog:",
"@types/ramda": "catalog:",
"@types/semver": "catalog:",
"@types/which": "catalog:",
"symlink-dir": "catalog:",
"write-yaml-file": "catalog:"

View File

@@ -1,5 +1,6 @@
import type { Catalogs } from '@pnpm/catalogs.types'
import type {
EngineDependency,
Finder,
Project,
ProjectManifest,
@@ -14,10 +15,6 @@ import type { AuthInfo } from './parseAuthInfo.js'
export type UniversalOptions = Pick<Config, 'color' | 'dir' | 'rawConfig' | 'rawLocalConfig'>
export interface WantedPackageManager {
name: string
version?: string
}
export type VerifyDepsBeforeRun = 'install' | 'warn' | 'error' | 'prompt' | false
@@ -89,7 +86,7 @@ export interface Config extends AuthInfo, OptionsFromRootManifest {
name: string
version: string
}
wantedPackageManager?: WantedPackageManager
wantedPackageManager?: EngineDependency
preferOffline?: boolean
sideEffectsCache?: boolean // for backward compatibility
sideEffectsCacheReadonly?: boolean // for backward compatibility

View File

@@ -12,12 +12,13 @@ import type npmTypes from '@pnpm/npm-conf/lib/types.js'
import { safeReadProjectManifestOnly } from '@pnpm/read-project-manifest'
import { getCurrentBranch } from '@pnpm/git-utils'
import { createMatcher } from '@pnpm/matcher'
import type { ProjectManifest } from '@pnpm/types'
import type { DevEngines, EngineDependency, ProjectManifest } from '@pnpm/types'
import { betterPathResolve } from 'better-path-resolve'
import camelcase from 'camelcase'
import isWindows from 'is-windows'
import kebabCase from 'lodash.kebabcase'
import normalizeRegistryUrl from 'normalize-registry-url'
import semver from 'semver'
import { realpathMissing } from 'realpath-missing'
import { pathAbsolute } from 'path-absolute'
import which from 'which'
@@ -34,7 +35,6 @@ import type {
ProjectConfig,
UniversalOptions,
VerifyDepsBeforeRun,
WantedPackageManager,
} from './Config.js'
import { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concurrency.js'
import { parseEnvVars } from './env.js'
@@ -64,7 +64,7 @@ export {
createProjectConfigRecord,
} from './projectConfig.js'
export type { Config, ProjectConfig, UniversalOptions, WantedPackageManager, VerifyDepsBeforeRun }
export type { Config, ProjectConfig, UniversalOptions, VerifyDepsBeforeRun }
export { isIniConfigKey } from './auth.js'
export { type ConfigFileKey, isConfigFileKey } from './configFileKey.js'
@@ -394,9 +394,11 @@ export async function getConfig (opts: {
if (pnpmConfig.rootProjectManifest.workspaces?.length && !pnpmConfig.workspaceDir) {
warnings.push('The "workspaces" field in package.json is not supported by pnpm. Create a "pnpm-workspace.yaml" file instead.')
}
if (pnpmConfig.rootProjectManifest.packageManager) {
pnpmConfig.wantedPackageManager = parsePackageManager(pnpmConfig.rootProjectManifest.packageManager)
const wantedPmResult = getWantedPackageManager(pnpmConfig.rootProjectManifest)
if (wantedPmResult.pm) {
pnpmConfig.wantedPackageManager = wantedPmResult.pm
}
warnings.push(...wantedPmResult.warnings)
if (pnpmConfig.rootProjectManifest) {
Object.assign(pnpmConfig, getOptionsFromRootManifest(pnpmConfig.rootProjectManifestDir, pnpmConfig.rootProjectManifest))
}
@@ -623,6 +625,20 @@ export async function getConfig (opts: {
transformPathKeys(pnpmConfig, os.homedir())
// For the legacy packageManager field, derive onFail from config settings.
// devEngines.packageManager already has onFail set during parsing.
if (pnpmConfig.wantedPackageManager && pnpmConfig.wantedPackageManager.onFail == null) {
if (pnpmConfig.packageManagerStrict === false) {
pnpmConfig.wantedPackageManager.onFail = 'warn'
} else if (pnpmConfig.managePackageManagerVersions) {
pnpmConfig.wantedPackageManager.onFail = 'download'
} else if (pnpmConfig.packageManagerStrictVersion) {
pnpmConfig.wantedPackageManager.onFail = 'error'
} else {
pnpmConfig.wantedPackageManager.onFail = 'ignore'
}
}
return { config: pnpmConfig, warnings }
}
@@ -632,6 +648,36 @@ function getProcessEnv (env: string): string | undefined {
process.env[env.toLowerCase()]
}
function getWantedPackageManager (manifest: ProjectManifest): { pm?: EngineDependency, warnings: string[] } {
const warnings: string[] = []
const pmFromDevEngines = parseDevEnginesPackageManager(manifest.devEngines)
if (pmFromDevEngines) {
if (pmFromDevEngines.version != null && !semver.validRange(pmFromDevEngines.version)) {
warnings.push(`Cannot use devEngines.packageManager version "${pmFromDevEngines.version}": not a valid version or range`)
pmFromDevEngines.version = undefined
}
if (manifest.packageManager) {
warnings.push('Cannot use both "packageManager" and "devEngines.packageManager" in package.json. "packageManager" will be ignored')
}
return { pm: pmFromDevEngines, warnings }
}
if (manifest.packageManager) {
const pm = parsePackageManager(manifest.packageManager)
if (pm.version != null) {
const cleanVersion = semver.valid(pm.version)
if (!cleanVersion) {
warnings.push(`Cannot use packageManager "${manifest.packageManager}": "${pm.version}" is not a valid exact version`)
pm.version = undefined
} else if (cleanVersion !== pm.version) {
warnings.push(`Cannot use packageManager "${manifest.packageManager}": you need to specify the version as "${cleanVersion}"`)
pm.version = undefined
}
}
return { pm, warnings }
}
return { warnings }
}
function parsePackageManager (packageManager: string): { name: string, version: string | undefined } {
if (!packageManager.includes('@')) return { name: packageManager, version: undefined }
const [name, pmReference] = packageManager.split('@')
@@ -645,6 +691,36 @@ function parsePackageManager (packageManager: string): { name: string, version:
}
}
function parseDevEnginesPackageManager (devEngines?: DevEngines): EngineDependency | undefined {
if (!devEngines?.packageManager) return undefined
let pmEngine: EngineDependency | undefined
let onFail: 'ignore' | 'warn' | 'error' | 'download'
if (Array.isArray(devEngines.packageManager)) {
const engines = devEngines.packageManager
if (engines.length === 0) return undefined
const pnpmIndex = engines.findIndex((engine) => engine.name === 'pnpm')
if (pnpmIndex !== -1) {
pmEngine = engines[pnpmIndex]
// In array notation, default onFail is 'error' for the last element, 'ignore' for others.
onFail = pmEngine.onFail ?? (pnpmIndex === engines.length - 1 ? 'error' : 'ignore')
} else {
pmEngine = engines[0]
// No pnpm entry found — use the last element's onFail for the overall failure behavior.
const lastEngine = engines[engines.length - 1]
onFail = lastEngine.onFail ?? 'error'
}
} else {
pmEngine = devEngines.packageManager
onFail = pmEngine.onFail ?? 'error'
}
if (!pmEngine?.name) return undefined
return {
name: pmEngine.name,
version: pmEngine.version,
onFail,
}
}
function addSettingsFromWorkspaceManifestToConfig (pnpmConfig: Config, {
configFromCliOpts,
projectManifest,

15
pnpm-lock.yaml generated
View File

@@ -2023,6 +2023,9 @@ importers:
realpath-missing:
specifier: 'catalog:'
version: 2.0.0
semver:
specifier: 'catalog:'
version: 7.7.4
which:
specifier: 'catalog:'
version: '@pnpm/which@3.0.1'
@@ -2048,6 +2051,9 @@ importers:
'@types/ramda':
specifier: 'catalog:'
version: 0.29.12
'@types/semver':
specifier: 'catalog:'
version: 7.7.1
'@types/which':
specifier: 'catalog:'
version: 2.0.2
@@ -8980,6 +8986,9 @@ importers:
'@pnpm/logger':
specifier: 'catalog:'
version: 1001.0.1
'@pnpm/npm-resolver':
specifier: workspace:*
version: link:../../resolving/npm-resolver
'@pnpm/package-store':
specifier: workspace:*
version: link:../../store/package-store
@@ -8998,6 +9007,9 @@ importers:
render-help:
specifier: 'catalog:'
version: 2.0.0
semver:
specifier: 'catalog:'
version: 7.7.4
symlink-dir:
specifier: 'catalog:'
version: 7.1.0
@@ -9020,6 +9032,9 @@ importers:
'@types/ramda':
specifier: 'catalog:'
version: 0.29.12
'@types/semver':
specifier: 'catalog:'
version: 7.7.1
cross-spawn:
specifier: 'catalog:'
version: 7.0.6

View File

@@ -9,14 +9,16 @@ if (!global['pnpm__startedAt']) {
import loudRejection from 'loud-rejection'
import { packageManager, isExecutedByCorepack } from '@pnpm/cli-meta'
import { getConfig, installConfigDepsAndLoadHooks } from '@pnpm/cli-utils'
import type { Config, WantedPackageManager } from '@pnpm/config'
import type { Config } from '@pnpm/config'
import { executionTimeLogger, scopeLogger } from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
import { filterPackagesFromDir } from '@pnpm/filter-workspace-packages'
import { globalWarn, logger } from '@pnpm/logger'
import type { ParsedCliArgs } from '@pnpm/parse-cli-args'
import type { EngineDependency } from '@pnpm/types'
import { finishWorkers } from '@pnpm/worker'
import chalk from 'chalk'
import semver from 'semver'
import path from 'path'
import { isEmpty } from 'ramda'
import { stripVTControlCharacters as stripAnsi } from 'util'
@@ -112,13 +114,14 @@ export async function main (inputArgv: string[]): Promise<void> {
ignoreNonAuthSettingsFromLocal: isDlxOrCreateCommand,
}) as typeof config
if (!isExecutedByCorepack() && cmd !== 'setup' && config.wantedPackageManager != null) {
if (config.managePackageManagerVersions && config.wantedPackageManager?.name === 'pnpm' && cmd !== 'self-update') {
const pm = config.wantedPackageManager
if (pm.onFail === 'download' && pm.name === 'pnpm' && cmd !== 'self-update') {
await switchCliVersion(config)
} else if (!cmd || !skipPackageManagerCheckForCommand.has(cmd)) {
} else if (pm.onFail !== 'ignore' && (!cmd || !skipPackageManagerCheckForCommand.has(cmd))) {
if (cliOptions.global) {
globalWarn('Using --global skips the package manager check for this project')
} else {
checkPackageManager(config.wantedPackageManager, config)
checkPackageManager(pm)
}
}
}
@@ -334,23 +337,24 @@ function printError (message: string, hint?: string): void {
}
}
function checkPackageManager (pm: WantedPackageManager, config: Config): void {
function checkPackageManager (pm: EngineDependency): void {
if (!pm.name) return
const shouldError = pm.onFail === 'error' || pm.onFail === 'download'
if (pm.name !== 'pnpm') {
const msg = `This project is configured to use ${pm.name}`
if (config.packageManagerStrict) {
if (shouldError) {
throw new PnpmError('OTHER_PM_EXPECTED', msg)
}
globalWarn(msg)
} else {
} else if (pm.version) {
const currentPnpmVersion = packageManager.name === 'pnpm'
? packageManager.version
: undefined
if (currentPnpmVersion && config.packageManagerStrictVersion && pm.version && pm.version !== currentPnpmVersion) {
const msg = `This project is configured to use v${pm.version} of pnpm. Your current pnpm is v${currentPnpmVersion}`
if (config.packageManagerStrict) {
if (currentPnpmVersion && !semver.satisfies(currentPnpmVersion, pm.version, { includePrerelease: true })) {
const msg = `This project is configured to use ${pm.version} of pnpm. Your current pnpm is v${currentPnpmVersion}`
if (shouldError) {
throw new PnpmError('BAD_PM_VERSION', msg, {
hint: 'If you want to bypass this version check, you can set the "package-manager-strict" configuration to "false" or set the "COREPACK_ENABLE_STRICT" environment variable to "0"',
hint: 'If you want to bypass this version check, you can set the "package-manager-strict" configuration to "false" or set the "COREPACK_ENABLE_STRICT" environment variable to "0". If using "devEngines.packageManager", you can set its "onFail" to "warn" or "ignore"',
})
} else {
globalWarn(msg)

View File

@@ -14,20 +14,29 @@ import semver from 'semver'
export async function switchCliVersion (config: Config): Promise<void> {
const pm = config.wantedPackageManager
if (pm == null || pm.name !== 'pnpm' || pm.version == null) return
const pmVersion = semver.valid(pm.version)
if (!pmVersion) {
globalWarn(`Cannot switch to pnpm@${pm.version}: "${pm.version}" is not a valid version`)
return
}
if (pmVersion !== pm.version.trim()) {
globalWarn(`Cannot switch to pnpm@${pm.version}: you need to specify the version as "${pmVersion}"`)
return
}
let envLockfile = await readEnvLockfile(config.rootProjectManifestDir) ?? undefined
let storeToUse: Awaited<ReturnType<typeof createStoreController>> | undefined
if (!isPackageManagerResolved(envLockfile, pmVersion)) {
// Check if the env lockfile already has a resolved version that satisfies the wanted version/range.
let pmVersion = envLockfile?.importers['.'].packageManagerDependencies?.['pnpm']?.version
if (!pmVersion || !semver.satisfies(pmVersion, pm.version, { includePrerelease: true })) {
// Resolve to an exact version from the registry.
storeToUse = await createStoreController(config)
envLockfile = await resolvePackageManagerIntegrities(pm.version, {
envLockfile,
registries: config.registries,
rootDir: config.rootProjectManifestDir,
storeController: storeToUse.ctrl,
storeDir: storeToUse.dir,
})
pmVersion = envLockfile.importers['.'].packageManagerDependencies?.['pnpm']?.version
if (!pmVersion) {
globalWarn(`Cannot resolve pnpm version for "${pm.version}"`)
await storeToUse?.ctrl.close()
return
}
} else if (!isPackageManagerResolved(envLockfile, pmVersion)) {
storeToUse = await createStoreController(config)
envLockfile = await resolvePackageManagerIntegrities(pmVersion, {
envLockfile,

View File

@@ -30,7 +30,7 @@ test('install should not fail if the used pnpm version does not satisfy the pnpm
const { status, stderr } = execPnpmSync(['install', '--config.manage-package-manager-versions=false', '--config.package-manager-strict-version=true'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use v0.0.0 of pnpm. Your current pnpm is')
expect(stderr.toString()).toContain('This project is configured to use 0.0.0 of pnpm. Your current pnpm is')
})
test('install should fail if the project requires a different package manager', async () => {
@@ -87,3 +87,160 @@ test('some commands should not fail if the required package manager is not pnpm'
const { status } = execPnpmSync(['store', 'path'])
expect(status).toBe(0)
})
test('devEngines.packageManager with onFail=error should fail on version mismatch', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '0.0.1',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['install'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
})
test('devEngines.packageManager with onFail=warn should warn on version mismatch', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '0.0.1',
onFail: 'warn',
},
},
})
const { status, stdout } = execPnpmSync(['install'])
expect(status).toBe(0)
expect(stdout.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
})
test('devEngines.packageManager with onFail=ignore should not check version', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '0.0.1',
onFail: 'ignore',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['install'])
expect(status).toBe(0)
expect(stdout.toString()).not.toContain('0.0.1')
expect(stderr.toString()).not.toContain('0.0.1')
})
test('devEngines.packageManager defaults to onFail=error', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '0.0.1',
},
},
})
const { status, stderr } = execPnpmSync(['install'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
})
test('devEngines.packageManager with a different PM name should fail with onFail=error', async () => {
prepare({
devEngines: {
packageManager: {
name: 'yarn',
version: '>=4.0.0',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['install'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use yarn')
})
test('devEngines.packageManager array selects the pnpm entry', async () => {
prepare({
devEngines: {
packageManager: [
{ name: 'yarn', version: '>=4.0.0', onFail: 'ignore' },
{ name: 'pnpm', version: '0.0.1', onFail: 'error' },
],
},
})
const { status, stderr } = execPnpmSync(['install'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
})
test('devEngines.packageManager array defaults onFail to ignore for non-last elements', async () => {
const versionProcess = execPnpmSync(['--version'])
const pnpmVersion = versionProcess.stdout.toString().trim()
prepare({
devEngines: {
packageManager: [
{ name: 'pnpm', version: pnpmVersion },
{ name: 'yarn', version: '>=4.0.0' },
],
},
})
// pnpm is the first (non-last) element, so onFail defaults to 'ignore'
const { status } = execPnpmSync(['install'])
expect(status).toBe(0)
})
test('devEngines.packageManager with version range should match current version', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '>=1.0.0',
onFail: 'error',
},
},
})
const { status } = execPnpmSync(['install'])
expect(status).toBe(0)
})
test('devEngines.packageManager takes precedence over packageManager field', async () => {
const versionProcess = execPnpmSync(['--version'])
const pnpmVersion = versionProcess.stdout.toString().trim()
prepare({
packageManager: `pnpm@${pnpmVersion}`,
devEngines: {
packageManager: {
name: 'pnpm',
version: '0.0.1',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['install'])
// devEngines.packageManager takes effect, so version mismatch error is thrown
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
expect(stderr.toString()).toContain('"packageManager" will be ignored')
})

View File

@@ -43,9 +43,9 @@ test('do not switch to pnpm version that is specified not with a semver version'
packageManager: 'pnpm@kevva/is-positive',
})
const { stdout } = execPnpmSync(['help'], { env })
const { stderr } = execPnpmSync(['help'], { env })
expect(stdout.toString()).toContain('Cannot switch to pnpm@kevva/is-positive')
expect(stderr.toString()).toContain('"kevva/is-positive" is not a valid exact version')
})
test('do not switch to pnpm version that is specified starting with v', async () => {
@@ -56,12 +56,12 @@ test('do not switch to pnpm version that is specified starting with v', async ()
packageManager: 'pnpm@v9.15.5',
})
const { stdout } = execPnpmSync(['help'], { env })
const { stderr } = execPnpmSync(['help'], { env })
expect(stdout.toString()).toContain('Cannot switch to pnpm@v9.15.5: you need to specify the version as "9.15.5"')
expect(stderr.toString()).toContain('you need to specify the version as "9.15.5"')
})
test('do not switch to pnpm version when a range is specified', async () => {
test('do not switch to pnpm version when a range is specified in packageManager field', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
@@ -69,9 +69,132 @@ test('do not switch to pnpm version when a range is specified', async () => {
packageManager: 'pnpm@^9.3.0',
})
const { stderr } = execPnpmSync(['help'], { env })
expect(stderr.toString()).toContain('not a valid exact version')
})
test('switch to the pnpm version resolved from devEngines.packageManager with onFail=download', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
writeJsonFileSync('package.json', {
devEngines: {
packageManager: {
name: 'pnpm',
version: '9.3.0',
onFail: 'download',
},
},
})
const { stdout } = execPnpmSync(['help'], { env })
expect(stdout.toString()).toContain('Cannot switch to pnpm@^9.3.0')
expect(stdout.toString()).toContain('Version 9.3.0')
})
test('switch to the pnpm version resolved from devEngines.packageManager with a range', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
writeJsonFileSync('package.json', {
devEngines: {
packageManager: {
name: 'pnpm',
version: '>=9.1.0 <9.1.4',
onFail: 'download',
},
},
})
const { stdout } = execPnpmSync(['help'], { env })
// Should resolve to the highest version in the range (9.1.3, not 9.1.0)
expect(stdout.toString()).toContain('Version 9.1.3')
})
test('devEngines.packageManager with onFail=download reuses resolved version from env lockfile', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
writeJsonFileSync('package.json', {
devEngines: {
packageManager: {
name: 'pnpm',
version: '>=9.1.0 <9.1.4',
onFail: 'download',
},
},
})
// First run: resolves and writes env lockfile
const firstRun = execPnpmSync(['help'], { env })
expect(firstRun.stdout.toString()).toContain('Version 9.1.3')
// Second run: should reuse the resolved version from env lockfile
const secondRun = execPnpmSync(['help'], { env })
expect(secondRun.stdout.toString()).toContain('Version 9.1.3')
// Verify env lockfile was written
expect(fs.existsSync('pnpm-lock.env.yaml')).toBe(true)
})
test('devEngines.packageManager re-resolves when locked version no longer satisfies updated range', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
// First run: seed the lockfile with 9.1.1
writeJsonFileSync('package.json', {
devEngines: {
packageManager: {
name: 'pnpm',
version: '>=9.1.0 <9.1.2',
onFail: 'download',
},
},
})
const firstRun = execPnpmSync(['help'], { env })
expect(firstRun.stdout.toString()).toContain('Version 9.1.1')
expect(fs.existsSync('pnpm-lock.env.yaml')).toBe(true)
// Update range so the previously locked 9.1.1 no longer satisfies it
writeJsonFileSync('package.json', {
devEngines: {
packageManager: {
name: 'pnpm',
version: '>=9.1.2 <9.1.4',
onFail: 'download',
},
},
})
// Should re-resolve and switch to 9.1.3
const secondRun = execPnpmSync(['help'], { env })
expect(secondRun.stdout.toString()).toContain('Version 9.1.3')
})
test('devEngines.packageManager without onFail=download does not switch version', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
writeYamlFileSync('pnpm-workspace.yaml', {
managePackageManagerVersions: false,
})
writeJsonFileSync('package.json', {
devEngines: {
packageManager: {
name: 'pnpm',
version: '9.3.0',
onFail: 'error',
},
},
})
const { status, stdout } = execPnpmSync(['help'], { env })
expect(status).not.toBe(0)
expect(stdout.toString()).not.toContain('Version 9.3.0')
})
test('throws error if pnpm binary in store is corrupt', () => {

View File

@@ -115,6 +115,7 @@ export {
type RegistryPackageSpec,
RegistryResponseError,
}
export { whichVersionIsPinned } from './whichVersionIsPinned.js'
export interface ResolverFactoryOptions {
cacheDir: string

View File

@@ -43,12 +43,14 @@
"@pnpm/headless": "workspace:*",
"@pnpm/link-bins": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/npm-resolver": "workspace:*",
"@pnpm/package-store": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*",
"@pnpm/store-connection-manager": "workspace:*",
"@pnpm/types": "workspace:*",
"ramda": "catalog:",
"render-help": "catalog:",
"semver": "catalog:",
"symlink-dir": "catalog:"
},
"peerDependencies": {
@@ -61,6 +63,7 @@
"@pnpm/tools.plugin-commands-self-updater": "workspace:*",
"@types/cross-spawn": "catalog:",
"@types/ramda": "catalog:",
"@types/semver": "catalog:",
"cross-spawn": "catalog:",
"nock": "catalog:"
},

View File

@@ -9,7 +9,10 @@ import { linkBins } from '@pnpm/link-bins'
import { globalWarn } from '@pnpm/logger'
import { readProjectManifest } from '@pnpm/read-project-manifest'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import { whichVersionIsPinned } from '@pnpm/npm-resolver'
import type { PinnedVersion } from '@pnpm/types'
import { pick } from 'ramda'
import semver from 'semver'
import { renderHelp } from 'render-help'
import { installPnpm } from './installPnpm.js'
@@ -25,6 +28,8 @@ export function cliOptionsTypes (): Record<string, unknown> {
export const commandNames = ['self-update']
export const skipPackageManagerCheck = true
export function help (): string {
return renderHelp({
description: 'Updates pnpm to the latest version (or the one specified)',
@@ -68,18 +73,37 @@ export async function handler (
throw new PnpmError('CANNOT_RESOLVE_PNPM', `Cannot find "${bareSpecifier}" version of pnpm`)
}
if (opts.wantedPackageManager?.name === packageManager.name && opts.managePackageManagerVersions) {
if (opts.wantedPackageManager?.name === packageManager.name) {
if (opts.wantedPackageManager?.version !== resolution.manifest.version) {
const { manifest, writeProjectManifest } = await readProjectManifest(opts.rootProjectManifestDir)
manifest.packageManager = `pnpm@${resolution.manifest.version}`
await writeProjectManifest(manifest)
const store = await createStoreController(opts)
await resolvePackageManagerIntegrities(resolution.manifest.version, {
registries: opts.registries,
rootDir: opts.rootProjectManifestDir,
storeController: store.ctrl,
storeDir: store.dir,
})
if (manifest.devEngines?.packageManager) {
if (Array.isArray(manifest.devEngines.packageManager)) {
const pnpmEntry = manifest.devEngines.packageManager.find((e) => e.name === 'pnpm')
if (pnpmEntry) {
const updated = updateVersionConstraint(pnpmEntry.version, resolution.manifest.version)
if (updated !== pnpmEntry.version) {
pnpmEntry.version = updated
await writeProjectManifest(manifest)
}
}
} else if (manifest.devEngines.packageManager.name === 'pnpm') {
const updated = updateVersionConstraint(manifest.devEngines.packageManager.version, resolution.manifest.version)
if (updated !== manifest.devEngines.packageManager.version) {
manifest.devEngines.packageManager.version = updated
await writeProjectManifest(manifest)
}
}
const store = await createStoreController(opts)
await resolvePackageManagerIntegrities(resolution.manifest.version, {
registries: opts.registries,
rootDir: opts.rootProjectManifestDir,
storeController: store.ctrl,
storeDir: store.dir,
})
} else {
manifest.packageManager = `pnpm@${resolution.manifest.version}`
await writeProjectManifest(manifest)
}
return `The current project has been updated to use pnpm v${resolution.manifest.version}`
} else {
return `The current project is already set to use pnpm v${resolution.manifest.version}`
@@ -114,3 +138,34 @@ export async function handler (
}
return undefined
}
/**
* Returns the updated version constraint for devEngines.packageManager.
* - Exact versions and simple ranges (^, ~) are updated to the new version,
* preserving the range operator.
* - Ranges that still satisfy the new version are returned unchanged
* (the exact version will be pinned in the lockfile instead).
* - Complex ranges (>=x <y, etc.) that no longer satisfy the new version
* fall back to a caret range with the new version (`^${newVersion}`).
*/
function updateVersionConstraint (current: string | undefined, newVersion: string): string | undefined {
if (current == null) return newVersion
// Range that still satisfies the new version — leave it as-is (lockfile handles pinning)
if (semver.satisfies(newVersion, current, { includePrerelease: true })) return current
// Determine the pinning style of the current specifier
const pinnedVersion = whichVersionIsPinned(current)
if (pinnedVersion == null) {
// Complex range that can't be updated while preserving its structure — fall back to ^version
return `^${newVersion}`
}
return versionSpecFromPinned(newVersion, pinnedVersion)
}
function versionSpecFromPinned (version: string, pinnedVersion: PinnedVersion): string {
switch (pinnedVersion) {
case 'none':
case 'major': return `^${version}`
case 'minor': return `~${version}`
case 'patch': return version
}
}

View File

@@ -31,9 +31,9 @@ beforeEach(() => {
nock.enableNetConnect()
})
function prepare () {
function prepare (manifest: object = {}) {
const dir = tempDir(false)
fs.writeFileSync(path.join(dir, 'package.json'), '{}', 'utf8')
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(manifest), 'utf8')
return prepareOptions(dir)
}
@@ -208,10 +208,8 @@ test('should update packageManager field when a newer pnpm version is available'
packageManager: 'pnpm@8.0.0',
}), 'utf8')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
@@ -249,6 +247,151 @@ test('should not update packageManager field when current version matches latest
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.0.0')
})
test('should update devEngines.packageManager version when a newer pnpm version is available', async () => {
const opts = prepare({
devEngines: {
packageManager: { name: 'pnpm', version: '8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
managePackageManagerVersions: true,
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
expect(pkgJson.packageManager).toBeUndefined()
})
test('should update pnpm entry in devEngines.packageManager array', async () => {
const opts = prepare({
devEngines: {
packageManager: [
{ name: 'npm', version: '10.0.0' },
{ name: 'pnpm', version: '8.0.0' },
],
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
managePackageManagerVersions: true,
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.devEngines.packageManager[1].version).toBe('9.0.0')
expect(pkgJson.devEngines.packageManager[0].version).toBe('10.0.0')
expect(pkgJson.packageManager).toBeUndefined()
})
test('should not modify devEngines.packageManager range when resolved version still satisfies it', async () => {
const opts = prepare({
devEngines: {
packageManager: { name: 'pnpm', version: '>=8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
managePackageManagerVersions: true,
wantedPackageManager: {
name: 'pnpm',
version: '>=8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
// The range should remain unchanged — the exact version is pinned in the lockfile
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.devEngines.packageManager.version).toBe('>=8.0.0')
// The lockfile should be written with the resolved exact version
const lockfile = fs.readFileSync(path.join(opts.dir, 'pnpm-lock.env.yaml'), 'utf8')
expect(lockfile).toContain('9.0.0')
})
test('should fall back to ^version when complex range cannot accommodate the new version', async () => {
const opts = prepare({
devEngines: {
packageManager: { name: 'pnpm', version: '>=8.0.0 <9.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
await selfUpdate.handler({
...opts,
managePackageManagerVersions: true,
wantedPackageManager: {
name: 'pnpm',
version: '>=8.0.0 <9.0.0',
},
}, [])
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.devEngines.packageManager.version).toBe('^9.0.0')
})
test('should update devEngines.packageManager range when resolved version no longer satisfies it', async () => {
const opts = prepare({
devEngines: {
packageManager: { name: 'pnpm', version: '^8' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
managePackageManagerVersions: true,
wantedPackageManager: {
name: 'pnpm',
version: '^8',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
// Range operator preserved, version updated
expect(pkgJson.devEngines.packageManager.version).toBe('^9.0.0')
})
test('self-update finds pnpm that is already in the global dir', async () => {
const opts = prepare()
const globalDir = opts.globalPkgDir
@@ -337,13 +480,8 @@ test('self-update updates the packageManager field in package.json', async () =>
},
}
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.1.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.1.0')
nock(opts.registries.default)
.get('/pnpm/-/pnpm-9.1.0.tgz')
.replyWithFile(200, pnpmTarballPath)
const output = await selfUpdate.handler(opts, [])

View File

@@ -57,6 +57,9 @@
{
"path": "../../pkg-manifest/read-project-manifest"
},
{
"path": "../../resolving/npm-resolver"
},
{
"path": "../../store/package-store"
},