diff --git a/.changeset/fix-named-catalog-upgrade.md b/.changeset/fix-named-catalog-upgrade.md new file mode 100644 index 0000000000..3e1b0a9efa --- /dev/null +++ b/.changeset/fix-named-catalog-upgrade.md @@ -0,0 +1,6 @@ +--- +"@pnpm/installing.deps-installer": patch +"pnpm": patch +--- + +Fix `pnpm upgrade --interactive --latest -r` not respecting named catalog groups. Previously, upgrading a dependency using a named catalog (e.g. `"catalog:foo"`) would incorrectly rewrite `package.json` to `"catalog:"` and place the updated version in the default catalog instead of the named one [#10115](https://github.com/pnpm/pnpm/issues/10115). diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index d1b94b23af..899865c1b9 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -711,8 +711,9 @@ export async function mutateModules ( }) if (opts.catalogMode !== 'manual') { - const catalogBareSpecifier = `catalog:${opts.saveCatalogName == null || opts.saveCatalogName === 'default' ? '' : opts.saveCatalogName}` for (const wantedDep of wantedDeps) { + const perDepCatalogName = getPerDepCatalogName(wantedDep, opts.saveCatalogName) + const catalogBareSpecifier = `catalog:${perDepCatalogName === 'default' ? '' : perDepCatalogName}` const catalog = resolveFromCatalog(opts.catalogs, { ...wantedDep, bareSpecifier: catalogBareSpecifier }) const catalogDepSpecifier = matchCatalogResolveResult(catalog, pickCatalogSpecifier) @@ -723,7 +724,7 @@ export async function mutateModules ( semver.validRange(catalogDepSpecifier) && semver.eq(wantedDep.bareSpecifier, catalogDepSpecifier) ) { - wantedDep.saveCatalogName = opts.saveCatalogName ?? 'default' + wantedDep.saveCatalogName = perDepCatalogName continue } @@ -1097,6 +1098,27 @@ function isWantedDepBareSpecifierSame ( return prevCatalogEntrySpec === nextCatalogEntrySpec } +/** + * Determines the catalog name for a dependency during installSome. + * + * If the dependency's previous specifier already uses a named catalog + * (e.g. "catalog:foo"), that catalog name takes priority over the global + * saveCatalogName option. This ensures that interactive updates and + * `--latest` upgrades preserve the per-dependency catalog group. + */ +function getPerDepCatalogName ( + wantedDep: { prevSpecifier?: string }, + globalSaveCatalogName: string | undefined +): string { + if (wantedDep.prevSpecifier) { + const catalogFromPrev = parseCatalogProtocol(wantedDep.prevSpecifier) + if (catalogFromPrev != null) { + return catalogFromPrev + } + } + return globalSaveCatalogName ?? 'default' +} + export async function addDependenciesToPackage ( manifest: ProjectManifest, dependencySelectors: string[], diff --git a/installing/deps-installer/test/catalogs.ts b/installing/deps-installer/test/catalogs.ts index 732c6a6a24..f6feea0b6d 100644 --- a/installing/deps-installer/test/catalogs.ts +++ b/installing/deps-installer/test/catalogs.ts @@ -1755,6 +1755,119 @@ describe('update', () => { expect(Object.keys(readLockfile().snapshots)).toEqual(['@pnpm.e2e/foo@100.1.0']) }) + test('update --latest works on named catalog dependency', async () => { + await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' }) + + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ + name: 'project1', + dependencies: { + '@pnpm.e2e/foo': 'catalog:foo', + }, + }]) + + const catalogs = { + foo: { '@pnpm.e2e/foo': '1.0.0' }, + } + + const mutateOpts = { + ...options, + lockfileOnly: true, + catalogs, + } + + await mutateModules(installProjects(projects), mutateOpts) + + expect(readLockfile().catalogs.foo).toEqual({ + '@pnpm.e2e/foo': { specifier: '1.0.0', version: '1.0.0' }, + }) + + const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage( + projects['project1' as ProjectId], + ['@pnpm.e2e/foo'], + { + ...mutateOpts, + dir: path.join(process.cwd(), 'project1'), + allowNew: false, + update: true, + updateToLatest: true, + }) + + expect(updatedManifest).toEqual({ + name: 'project1', + dependencies: { + '@pnpm.e2e/foo': 'catalog:foo', + }, + }) + expect(updatedCatalogs).toEqual({ + foo: { + '@pnpm.e2e/foo': '100.1.0', + }, + }) + + const lockfile = readLockfile() + expect(lockfile.catalogs).toEqual({ + foo: { '@pnpm.e2e/foo': { specifier: '100.1.0', version: '100.1.0' } }, + }) + expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@100.1.0']) + }) + + test('update --latest works on named catalog dependency with catalogMode=prefer', async () => { + await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' }) + + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ + name: 'project1', + dependencies: { + '@pnpm.e2e/foo': 'catalog:foo', + }, + }]) + + const catalogs = { + foo: { '@pnpm.e2e/foo': '1.0.0' }, + } + + const mutateOpts = { + ...options, + lockfileOnly: true, + catalogs, + } + + await mutateModules(installProjects(projects), mutateOpts) + + expect(readLockfile().catalogs.foo).toEqual({ + '@pnpm.e2e/foo': { specifier: '1.0.0', version: '1.0.0' }, + }) + + const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage( + projects['project1' as ProjectId], + ['@pnpm.e2e/foo'], + { + ...mutateOpts, + catalogMode: 'prefer', + dir: path.join(process.cwd(), 'project1'), + allowNew: false, + update: true, + updateToLatest: true, + }) + + expect(updatedManifest).toEqual({ + name: 'project1', + dependencies: { + '@pnpm.e2e/foo': 'catalog:foo', + }, + }) + expect(updatedCatalogs).toEqual({ + foo: { + '@pnpm.e2e/foo': '100.1.0', + }, + }) + + const lockfile = readLockfile() + expect(lockfile.catalogs).toEqual({ + foo: { '@pnpm.e2e/foo': { specifier: '100.1.0', version: '100.1.0' } }, + }) + expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@100.1.0']) + }) + // This test will update @pnpm.e2e/bar, but make sure @pnpm.e2e/foo is // untouched. On the registry-mock, the versions for @pnpm.e2e/bar are: //