feat: support catalog: protocol on pnpm update (#9517)

This commit is contained in:
Brandon Cheng
2025-06-08 04:26:15 -04:00
committed by GitHub
parent b3898dbb1e
commit 5ab40c1dee
6 changed files with 221 additions and 85 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolve-dependencies": minor
pnpm: minor
---
The `pnpm update` command now supports updating `catalog:` protocol dependencies and writes new specifiers to `pnpm-workspace.yaml`.

View File

@@ -1325,11 +1325,6 @@ describe('add', () => {
})
})
// The 'pnpm update' command should eventually support updates of dependencies
// in the catalog. This is a more involved feature since pnpm-workspace.yaml
// needs to be edited. Until the catalog update feature is implemented, ensure
// pnpm update does not touch or rewrite dependencies using the catalog
// protocol.
describe('update', () => {
// Many of the update tests use @pnpm.e2e/foo, which has the following
// versions currently published to the https://github.com/pnpm/registry-mock
@@ -1346,38 +1341,7 @@ describe('update', () => {
// is-positive since public packages can release new versions and break the
// tests here.
test('update does not modify catalog: protocol', async () => {
const { options, projects } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
}])
const { updatedManifest } = await addDependenciesToPackage(
projects['project1' as ProjectId],
['@pnpm.e2e/foo'],
{
...options,
dir: path.join(options.lockfileDir, 'project1'),
lockfileOnly: true,
allowNew: false,
update: true,
catalogs: {
default: { '@pnpm.e2e/foo': '^1.0.0' },
},
})
// Expecting the manifest to remain unchanged.
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
})
})
test('update does not upgrade cataloged dependency', async () => {
test('update works on cataloged dependency', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
@@ -1385,28 +1349,30 @@ describe('update', () => {
},
}])
const catalogs = {
default: { '@pnpm.e2e/foo': '1.0.0' },
}
const mutateOpts = {
...options,
lockfileOnly: true,
catalogs,
// Start by using 1.0.0 as the specifier. We'll then change this to ^1.0.0
// and to test pnpm properly updates from 1.0.0 to 1.3.0.
catalogs: {
default: { '@pnpm.e2e/foo': '1.0.0' },
},
}
await mutateModules(installProjects(projects), mutateOpts)
// Updating the catalog from 1.0.0 to ^1.0.0. This should still lock to the
// existing 1.0.0 version despite version 1.3.0 existing.
catalogs.default['@pnpm.e2e/foo'] = '^1.0.0'
// Changing the catalog from 1.0.0 to ^1.0.0. This should still lock to the
// existing 1.0.0 version despite version 1.3.0 available on the registry.
mutateOpts.catalogs.default['@pnpm.e2e/foo'] = '^1.0.0'
await mutateModules(installProjects(projects), mutateOpts)
// Sanity check that the @pnpm.e2e/foo dependency is installed on the older
// requested version.
expect(readLockfile().catalogs.default).toEqual({
'@pnpm.e2e/foo': { specifier: '^1.0.0', version: '1.0.0' },
})
// Expecting the manifest to remain unchanged after running an update.
const { updatedManifest } = await addDependenciesToPackage(
const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage(
projects['project1' as ProjectId],
['@pnpm.e2e/foo'],
{
@@ -1415,21 +1381,99 @@ describe('update', () => {
update: true,
})
// Expecting the manifest to remain unchanged after running an update. The
// change should be reflected in the returned updatedCatalogs object
// instead.
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
})
// The lockfile should only contain 1.0.0 and not 1.3.0 (or a later version).
expect(readLockfile()).toMatchObject({
catalogs: { default: { '@pnpm.e2e/foo': { specifier: '^1.0.0', version: '1.0.0' } } },
packages: { '@pnpm.e2e/foo@1.0.0': expect.any(Object) },
expect(updatedCatalogs).toEqual({
default: {
'@pnpm.e2e/foo': '^1.3.0',
},
})
// The lockfile should also contain the updated ^1.3.0 reference.
const lockfile = readLockfile()
expect(lockfile.catalogs).toEqual({
default: { '@pnpm.e2e/foo': { specifier: '^1.3.0', version: '1.3.0' } },
})
// Ensure the old 1.0.0 version is no longer used.
expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@1.3.0'])
})
test('update latest does not modify catalog: protocol', async () => {
test('update works on named catalog', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo',
},
}])
// Start by using 1.0.0 as the specifier. We'll then change this to ^1.0.0
// and to test pnpm properly updates from 1.0.0 to 1.3.0.
const mutateOpts = {
...options,
lockfileOnly: true,
catalogs: {
foo: { '@pnpm.e2e/foo': '1.0.0' },
},
}
await mutateModules(installProjects(projects), mutateOpts)
// Changing the catalog from 1.0.0 to ^1.0.0. This should still lock to the
// existing 1.0.0 version despite version 1.3.0 available on the registry.
mutateOpts.catalogs.foo['@pnpm.e2e/foo'] = '^1.0.0'
await mutateModules(installProjects(projects), mutateOpts)
// Sanity check that the @pnpm.e2e/foo dependency is installed on the older
// requested version.
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(options.lockfileDir, 'project1'),
update: true,
})
// Expecting the manifest to remain unchanged after running an update. The
// change should be reflected in the returned updatedCatalogs object
// instead.
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo',
},
})
expect(updatedCatalogs).toEqual({
foo: {
'@pnpm.e2e/foo': '^1.3.0',
},
})
// The lockfile should also contain the updated ^1.3.0 reference.
const lockfile = readLockfile()
expect(lockfile.catalogs).toEqual({
foo: { '@pnpm.e2e/foo': { specifier: '^1.3.0', version: '1.3.0' } },
})
// Ensure the old 1.0.0 version is no longer used.
expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@1.3.0'])
})
test('update --latest works on cataloged dependency', async () => {
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
@@ -1455,7 +1499,7 @@ describe('update', () => {
'@pnpm.e2e/foo': { specifier: '1.0.0', version: '1.0.0' },
})
const { updatedManifest } = await addDependenciesToPackage(
const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage(
projects['project1' as ProjectId],
['@pnpm.e2e/foo'],
{
@@ -1466,15 +1510,111 @@ describe('update', () => {
updateToLatest: true,
})
// Expecting the manifest to remain unchanged.
// Expecting the manifest to remain unchanged after running an update. The
// change should be reflected in the returned updatedCatalogs object
// instead.
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
})
expect(updatedCatalogs).toEqual({
default: {
'@pnpm.e2e/foo': '100.1.0',
},
})
expect(Object.keys(readLockfile().snapshots)).toEqual(['@pnpm.e2e/foo@1.0.0'])
expect(Object.keys(readLockfile().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:
//
// - 100.0.0
// - 100.1.0
test('update only affects matching filter', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
'@pnpm.e2e/bar': 'catalog:',
},
}])
const mutateOpts = {
...options,
lockfileOnly: true,
catalogs: {
default: {
// Start by using exact versions for specifiers. We'll then change this to be a range below.
'@pnpm.e2e/foo': '1.0.0',
'@pnpm.e2e/bar': '100.0.0',
},
},
}
await mutateModules(installProjects(projects), mutateOpts)
// Adding ^ to the catalog config entries. This allows the update process to
// consider newer versions to update to for this test.
mutateOpts.catalogs.default['@pnpm.e2e/foo'] = '^1.0.0'
mutateOpts.catalogs.default['@pnpm.e2e/bar'] = '^100.0.0'
await mutateModules(installProjects(projects), mutateOpts)
// Sanity check dependencies are still installed on older requested version
// and not accidentally updated due to adding ^ above.
expect(readLockfile().catalogs.default).toEqual({
'@pnpm.e2e/foo': { specifier: '^1.0.0', version: '1.0.0' },
'@pnpm.e2e/bar': { specifier: '^100.0.0', version: '100.0.0' },
})
const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage(
projects['project1' as ProjectId],
['@pnpm.e2e/bar'],
{
...mutateOpts,
dir: path.join(options.lockfileDir, 'project1'),
update: true,
updateMatching: (pkgName) => pkgName === '@pnpm.e2e/bar',
})
// Expecting the manifest to remain unchanged after running an update. The
// change should be reflected in the returned updatedCatalogs object
// instead.
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
'@pnpm.e2e/bar': 'catalog:',
},
})
expect(updatedCatalogs).toEqual({
default: {
'@pnpm.e2e/bar': '^100.1.0',
},
})
// The lockfile should also contain the updated ^100.1.0 reference.
const lockfile = readLockfile()
expect(lockfile).toEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/foo': { specifier: '^1.0.0', version: '1.0.0' },
'@pnpm.e2e/bar': { specifier: '^100.1.0', version: '100.1.0' },
},
},
packages: {
'@pnpm.e2e/foo@1.0.0': expect.objectContaining({}),
'@pnpm.e2e/bar@100.1.0': expect.objectContaining({}),
},
}))
// Ensure the old 1.0.0 version is no longer used.
expect(Object.keys(lockfile.snapshots)).toEqual([
'@pnpm.e2e/bar@100.1.0',
'@pnpm.e2e/foo@1.0.0',
])
})
})

View File

@@ -1,14 +1,24 @@
import { type Catalogs } from '@pnpm/catalogs.types'
import { type CatalogSnapshots } from '@pnpm/lockfile.types'
import { type ResolvedDirectDependency } from './resolveDependencyTree'
export function getCatalogSnapshots (resolvedDirectDeps: readonly ResolvedDirectDependency[]): CatalogSnapshots {
export function getCatalogSnapshots (
resolvedDirectDeps: readonly ResolvedDirectDependency[],
updatedCatalogs?: Catalogs
): CatalogSnapshots {
const catalogSnapshots: CatalogSnapshots = {}
const catalogedDeps = resolvedDirectDeps.filter(isCatalogedDep)
for (const dep of catalogedDeps) {
const snapshotForSingleCatalog = (catalogSnapshots[dep.catalogLookup.catalogName] ??= {})
const updatedSpecifier = updatedCatalogs?.[dep.catalogLookup.catalogName]?.[dep.alias]
snapshotForSingleCatalog[dep.alias] = {
specifier: dep.catalogLookup.specifier,
// The "updated specifier" will be present when pnpm add/update is ran and
// bare specifiers need to be added in the pnpm-workspace.yaml file. When
// this happens, the updated specifier should be saved to lockfile instead
// of the original specifier before the update.
specifier: updatedSpecifier ?? dep.catalogLookup.specifier,
version: dep.version,
}
}

View File

@@ -322,7 +322,9 @@ export async function resolveDependencies (
}
}
newLockfile.catalogs = getCatalogSnapshots(Object.values(resolvedImporters).flatMap(({ directDependencies }) => directDependencies))
newLockfile.catalogs = getCatalogSnapshots(
Object.values(resolvedImporters).flatMap(({ directDependencies }) => directDependencies),
updatedCatalogs)
// waiting till package requests are finished
async function waitTillAllFetchingsFinish (): Promise<void> {

View File

@@ -559,16 +559,8 @@ async function resolveDependenciesOfImporterDependency (
// snapshot to ensure all projects using the same cataloged dependency get the
// same version.
if (catalogLookup != null) {
const existingVersion = getCatalogExistingVersionFromSnapshot(catalogLookup, ctx.wantedLockfile, extendedWantedDep.wantedDependency)
// If there's an existing version, always use it to prevent "pnpm update"
// from updating the catalog protocol. A future change will remove this
// condition to support updating specifiers in pnpm-workspace.yaml
// functionality.
extendedWantedDep.wantedDependency.bareSpecifier = existingVersion != null
? replaceVersionInBareSpecifier(catalogLookup.specifier, existingVersion)
: catalogLookup.specifier
extendedWantedDep.preferredVersion = existingVersion
extendedWantedDep.wantedDependency.bareSpecifier = catalogLookup.specifier
extendedWantedDep.preferredVersion = getCatalogExistingVersionFromSnapshot(catalogLookup, ctx.wantedLockfile, extendedWantedDep.wantedDependency)
}
const result = await resolveDependenciesOfDependency(
@@ -578,12 +570,6 @@ async function resolveDependenciesOfImporterDependency (
...importer.options,
parentPkgAliases: importer.parentPkgAliases,
pickLowestVersion: pickLowestVersion && !importer.updatePackageManifest,
// Cataloged dependencies cannot be upgraded yet since they require
// updating the pnpm-workspace.yaml file. This will be handled in a future
// version of pnpm.
updateToLatest: catalogLookup != null
? false
: importer.options.updateToLatest,
pinnedVersion: importer.pinnedVersion,
},
extendedWantedDep
@@ -881,16 +867,8 @@ async function resolveDependenciesOfDependency (
// as an importer separately, and we can rely on that process keeping the
// importers lockfile catalog snapshots up to date.
if (catalogLookup != null) {
const existingVersion = getCatalogExistingVersionFromSnapshot(catalogLookup, ctx.wantedLockfile, extendedWantedDep.wantedDependency)
// If there's an existing version, always use it to prevent "pnpm update"
// from updating the catalog protocol. A future change will remove this
// condition to support updating specifiers in pnpm-workspace.yaml
// functionality.
extendedWantedDep.wantedDependency.bareSpecifier = existingVersion != null
? replaceVersionInBareSpecifier(catalogLookup.specifier, existingVersion)
: catalogLookup.specifier
extendedWantedDep.preferredVersion = existingVersion
extendedWantedDep.wantedDependency.bareSpecifier = catalogLookup.specifier
extendedWantedDep.preferredVersion = getCatalogExistingVersionFromSnapshot(catalogLookup, ctx.wantedLockfile, extendedWantedDep.wantedDependency)
}
}

View File

@@ -242,7 +242,7 @@ export async function resolveDependencyTree<T> (
if (existingCatalog != null) {
if (existingCatalog !== normalizedBareSpecifier) {
globalWarn(
`Skip adding ${alias} to catalogs.${saveCatalogName} because it already exists as ${existingCatalog}`
`Skip adding ${alias} to the default catalog because it already exists as ${existingCatalog}. Please use \`pnpm update\` to update the catalogs.`
)
}
} else if (saveCatalogName != null && normalizedBareSpecifier != null && version != null) {