fix: sync packageManager and devEngines.packageManager on self-update (#11395)

* fix: sync packageManager and devEngines.packageManager on self-update

When `package.json` declares both `packageManager` and
`devEngines.packageManager`, `pnpm self-update` previously bumped only
the latter — leaving Corepack (which reads `packageManager`) pinned to
the old version until a manual edit.

Now, when `packageManager` pins pnpm, both fields are rewritten to the
new exact version on update: `packageManager` to `pnpm@<version>`
(without an integrity hash) and `devEngines.packageManager.version` to
the same exact `<version>` (dropping any range operator). When only
`devEngines.packageManager` is declared, the existing range-preserving
behavior is unchanged.

Closes #11388

* refactor: export and reuse parsePackageManager from @pnpm/config.reader

Drop the inline duplicate in self-updater and use the existing
parser from config.reader. Same parsing rules (strips integrity
hash, rejects URL-style refs).

* refactor: collapse devEngines.packageManager array/object branches

Resolve to the underlying pnpm entry first (whether the field is an
array or an object) and run the version-update logic once, instead of
duplicating it across both branches.
This commit is contained in:
Zoltan Kochan
2026-04-29 22:56:33 +02:00
parent 7b83ecfc98
commit 0fbcf74aac
4 changed files with 170 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/config.reader": patch
"@pnpm/engine.pm.commands": patch
"pnpm": patch
---
`pnpm self-update` now keeps `package.json`'s `packageManager` and `devEngines.packageManager` in sync. When the legacy `packageManager` field pins pnpm, both fields are rewritten to the new exact pnpm version on update — `packageManager` to `pnpm@<version>` (without an integrity hash), and `devEngines.packageManager.version` to the same exact `<version>` (dropping any range operator). When only `devEngines.packageManager` is declared, the existing range-preserving behavior is unchanged [#11388](https://github.com/pnpm/pnpm/issues/11388).

View File

@@ -693,7 +693,7 @@ function getWantedPackageManager (manifest: ProjectManifest): { pm?: WantedPacka
return { warnings }
}
function parsePackageManager (packageManager: string): { name: string, version: string | undefined } {
export function parsePackageManager (packageManager: string): { name: string, version: string | undefined } {
if (!packageManager.includes('@')) return { name: packageManager, version: undefined }
const [name, pmReference] = packageManager.split('@')
// pmReference is semantic versioning, not URL

View File

@@ -3,7 +3,7 @@ import path from 'node:path'
import { linkBins } from '@pnpm/bins.linker'
import { isExecutedByCorepack, packageManager } from '@pnpm/cli.meta'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { type Config, type ConfigContext, parsePackageManager, types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import { createResolver } from '@pnpm/installing.client'
import { resolvePackageManagerIntegrities } from '@pnpm/installing.env-installer'
@@ -113,22 +113,38 @@ export async function handler (
if (opts.wantedPackageManager?.version !== resolution.manifest.version) {
const { manifest, writeProjectManifest } = await readProjectManifest(opts.rootProjectManifestDir)
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)
let manifestChanged = false
// If "packageManager" pins pnpm, treat both fields as the user's
// single source of truth for the active pnpm version: rewrite both
// to the new exact version (dropping any range operator in
// devEngines and any integrity hash on the legacy field). When only
// devEngines is set, preserve the user's range style and let the
// lockfile pin the exact version.
const legacyPm = manifest.packageManager != null
? parsePackageManager(manifest.packageManager)
: undefined
const legacyPinsPnpm = legacyPm?.name === 'pnpm' && legacyPm.version != null
const devEnginesPm = manifest.devEngines.packageManager
const pnpmEntry = Array.isArray(devEnginesPm)
? devEnginesPm.find((e) => e.name === 'pnpm')
: devEnginesPm.name === 'pnpm' ? devEnginesPm : undefined
if (pnpmEntry) {
const updated = legacyPinsPnpm
? resolution.manifest.version
: updateVersionConstraint(pnpmEntry.version, resolution.manifest.version)
if (updated !== pnpmEntry.version) {
pnpmEntry.version = updated
manifestChanged = true
}
}
if (legacyPinsPnpm) {
const newLegacy = `pnpm@${resolution.manifest.version}`
if (manifest.packageManager !== newLegacy) {
manifest.packageManager = newLegacy
manifestChanged = true
}
}
if (manifestChanged) await writeProjectManifest(manifest)
const store = await createStoreController(opts)
await resolvePackageManagerIntegrities(resolution.manifest.version, {
registries: opts.registries,

View File

@@ -367,6 +367,137 @@ test('should fall back to ^version when complex range cannot accommodate the new
expect(pkgJson.devEngines.packageManager.version).toBe('^9.0.0')
})
test('should update both packageManager and devEngines.packageManager when both pin the same exact version', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
devEngines: {
packageManager: { name: 'pnpm', version: '8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
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.packageManager).toBe('pnpm@9.0.0')
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
})
test('should update both packageManager (with integrity hash) and devEngines.packageManager when versions agree', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0+sha512.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
devEngines: {
packageManager: { name: 'pnpm', version: '8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
mockExeMetadata(opts.registries.default, '9.0.0')
await selfUpdate.handler({
...opts,
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
})
test('should sync both fields to the new exact version when their current versions disagree', async () => {
const opts = prepare({
packageManager: 'pnpm@7.0.0',
devEngines: {
packageManager: { name: 'pnpm', version: '8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
mockExeMetadata(opts.registries.default, '9.0.0')
await selfUpdate.handler({
...opts,
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
})
test('should pin devEngines.packageManager to an exact version when packageManager also pins pnpm', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
devEngines: {
packageManager: { name: 'pnpm', version: '^8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
mockExeMetadata(opts.registries.default, '9.0.0')
await selfUpdate.handler({
...opts,
wantedPackageManager: {
name: 'pnpm',
version: '^8.0.0',
},
}, [])
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.packageManager).toBe('pnpm@9.0.0')
expect(pkgJson.devEngines.packageManager.version).toBe('9.0.0')
})
test('should leave packageManager alone when it pins a different package manager', async () => {
const opts = prepare({
packageManager: 'yarn@4.0.0',
devEngines: {
packageManager: { name: 'pnpm', version: '^8.0.0' },
},
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.0.0', opts.registries.default)).persist()
mockExeMetadata(opts.registries.default, '9.0.0')
await selfUpdate.handler({
...opts,
wantedPackageManager: {
name: 'pnpm',
version: '^8.0.0',
},
}, [])
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
expect(pkgJson.packageManager).toBe('yarn@4.0.0')
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: {