diff --git a/.changeset/self-update-syncs-package-manager-fields.md b/.changeset/self-update-syncs-package-manager-fields.md new file mode 100644 index 0000000000..354d53fd36 --- /dev/null +++ b/.changeset/self-update-syncs-package-manager-fields.md @@ -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@` (without an integrity hash), and `devEngines.packageManager.version` to the same exact `` (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). diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index b30e6bfbc9..1dc278a325 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -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 diff --git a/engine/pm/commands/src/self-updater/selfUpdate.ts b/engine/pm/commands/src/self-updater/selfUpdate.ts index 22b926be22..1da6fad246 100644 --- a/engine/pm/commands/src/self-updater/selfUpdate.ts +++ b/engine/pm/commands/src/self-updater/selfUpdate.ts @@ -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, diff --git a/engine/pm/commands/test/self-updater/selfUpdate.test.ts b/engine/pm/commands/test/self-updater/selfUpdate.test.ts index 74a4aa2667..3b23d19d28 100644 --- a/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -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: {