diff --git a/.changeset/fancy-files-wish.md b/.changeset/fancy-files-wish.md new file mode 100644 index 00000000000..e1ff186cfb3 --- /dev/null +++ b/.changeset/fancy-files-wish.md @@ -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`. diff --git a/pkg-manager/core/test/catalogs.ts b/pkg-manager/core/test/catalogs.ts index 8f7076ea7fc..e9d59b0b196 100644 --- a/pkg-manager/core/test/catalogs.ts +++ b/pkg-manager/core/test/catalogs.ts @@ -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,67 +1341,103 @@ 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([{ + test('update works on cataloged dependency', async () => { + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ name: 'project1', dependencies: { '@pnpm.e2e/foo': 'catalog:', }, }]) - const { updatedManifest } = await addDependenciesToPackage( + 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. + default: { '@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.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' }, + }) + + const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage( projects['project1' as ProjectId], ['@pnpm.e2e/foo'], { - ...options, + ...mutateOpts, 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. + // 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': '^1.3.0', + }, + }) + + // The lockfile should also contain the updated ^1.3.0 reference. + expect(readLockfile()).toEqual(expect.objectContaining({ + catalogs: { default: { '@pnpm.e2e/foo': { specifier: '^1.3.0', version: '1.3.0' } } }, + packages: { '@pnpm.e2e/foo@1.3.0': expect.objectContaining({}) }, + })) + + // Ensure the old 1.0.0 version is no longer used. + expect(Object.keys(readLockfile().snapshots)).toEqual(['@pnpm.e2e/foo@1.3.0']) }) - test('update does not upgrade cataloged dependency', async () => { + test('update works on named catalog', async () => { const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ name: 'project1', dependencies: { - '@pnpm.e2e/foo': 'catalog:', + '@pnpm.e2e/foo': 'catalog:foo', }, }]) - const catalogs = { - default: { '@pnpm.e2e/foo': '1.0.0' }, - } const mutateOpts = { ...options, lockfileOnly: true, - catalogs, + 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. + foo: { '@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.foo['@pnpm.e2e/foo'] = '^1.0.0' await mutateModules(installProjects(projects), mutateOpts) - expect(readLockfile().catalogs.default).toEqual({ + // 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' }, }) - // 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 +1446,34 @@ 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:', + '@pnpm.e2e/foo': 'catalog:foo', }, }) - - // 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({ + foo: { + '@pnpm.e2e/foo': '^1.3.0', + }, }) + + // The lockfile should also contain the updated ^1.3.0 reference. + expect(readLockfile()).toEqual(expect.objectContaining({ + catalogs: { foo: { '@pnpm.e2e/foo': { specifier: '^1.3.0', version: '1.3.0' } } }, + packages: { '@pnpm.e2e/foo@1.3.0': expect.objectContaining({}) }, + })) + + // Ensure the old 1.0.0 version is no longer used. + expect(Object.keys(readLockfile().snapshots)).toEqual(['@pnpm.e2e/foo@1.3.0']) }) - test('update latest does not modify catalog: protocol', async () => { + 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,113 @@ 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@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 1.0.0 as the specifier. We'll then change this to ^1.0.0. + '@pnpm.e2e/foo': '1.0.0', + // Start by using 100.0.0 as the specifier. We'll then change this to ^100.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', + '@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. + expect(readLockfile()).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({}), + }, + })) - expect(Object.keys(readLockfile().snapshots)).toEqual(['@pnpm.e2e/foo@1.0.0']) + // Ensure the old 1.0.0 version is no longer used. + expect(Object.keys(readLockfile().snapshots)).toEqual([ + '@pnpm.e2e/bar@100.1.0', + '@pnpm.e2e/foo@1.0.0', + ]) }) }) diff --git a/pkg-manager/resolve-dependencies/src/getCatalogSnapshots.ts b/pkg-manager/resolve-dependencies/src/getCatalogSnapshots.ts index 8ca0a1f5dc7..4d70a90388d 100644 --- a/pkg-manager/resolve-dependencies/src/getCatalogSnapshots.ts +++ b/pkg-manager/resolve-dependencies/src/getCatalogSnapshots.ts @@ -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, } } diff --git a/pkg-manager/resolve-dependencies/src/index.ts b/pkg-manager/resolve-dependencies/src/index.ts index 3999a50009e..4c045f52615 100644 --- a/pkg-manager/resolve-dependencies/src/index.ts +++ b/pkg-manager/resolve-dependencies/src/index.ts @@ -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 { diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts index 64f5e862ef4..ef7b0ee61ba 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts @@ -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) } } diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts b/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts index 0f873e641da..5e001c64795 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts @@ -242,7 +242,7 @@ export async function resolveDependencyTree ( 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) {