diff --git a/.changeset/legal-rooms-care.md b/.changeset/legal-rooms-care.md new file mode 100644 index 0000000000..0b8b5c1eb4 --- /dev/null +++ b/.changeset/legal-rooms-care.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolve-dependencies": patch +pnpm: patch +--- + +Fix usages of the [`catalog:` protocol](https://pnpm.io/catalogs) in [injected local workspace packages](https://pnpm.io/package_json#dependenciesmetainjected). This previously errored with `ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`. [#8715](https://github.com/pnpm/pnpm/issues/8715) diff --git a/pkg-manager/core/test/catalogs.ts b/pkg-manager/core/test/catalogs.ts index 5493ad31e3..77ae8daa00 100644 --- a/pkg-manager/core/test/catalogs.ts +++ b/pkg-manager/core/test/catalogs.ts @@ -719,6 +719,162 @@ test('lockfile catalog snapshots should remove unused entries', async () => { } }) +// Regression test for https://github.com/pnpm/pnpm/issues/8715 +// +// Catalogs on injected deps require more consideration since the injected dep +// is no longer seen as an "importer". The catalog protocol is traditionally +// only for "importers" (i.e. packages matching the `packages` filter in +// pnpm-workspace.yaml). +// +// Since injected deps copy the workspace package into the node_modules/.pnpm +// dir, a bit more work has to be done to make catalogs usable on these unique +// packages. +// +// Example of a package at packages/project2 getting "injected". +// +// node_modules/.pnpm/project2@file+packages+project2/node_modules/project2 +// +test('catalogs work in injected dep', async () => { + expect.hasAssertions() + + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([ + { + name: 'project1', + dependencies: { + project2: 'workspace:*', + }, + dependenciesMeta: { + project2: { injected: true }, + }, + }, + { + name: 'project2', + dependencies: { + 'is-positive': 'catalog:', + }, + }, + ]) + + const install = () => mutateModules(installProjects(projects), { + ...options, + lockfileOnly: true, + // This setting turns injected deps into regular symlinked workspace + // packages if peer dependencies aren't resolved differently. + dedupeInjectedDeps: false, + catalogs: { + default: { 'is-positive': '1.0.0' }, + }, + }) + + // This should run without "is-positive@catalog: isn't supported by any + // available resolver." errors. + await expect(install()).resolves.not.toThrow() + + const lockfile = readLockfile() + + // The resolved catalogs should be correct. + expect(lockfile.catalogs).toStrictEqual({ + default: { + 'is-positive': { specifier: '1.0.0', version: '1.0.0' }, + }, + }) + + expect(lockfile.importers).toEqual({ + // Check that project2 was indeed injected into project1. Otherwise this + // test wouldn't be checking the correct scenario. + project1: { + dependencies: { + project2: { specifier: 'workspace:*', version: 'file:project2' }, + }, + dependenciesMeta: { + project2: { injected: true }, + }, + }, + project2: { + dependencies: { + 'is-positive': { specifier: 'catalog:', version: '1.0.0' }, + }, + }, + }) + + // Double check the correct version of is-positive as requested from the + // catalog was installed and not the latest. + expect(lockfile.snapshots).toStrictEqual({ + 'is-positive@1.0.0': {}, + 'project2@file:project2': { + dependencies: { 'is-positive': '1.0.0' }, + }, + }) +}) + +test('catalogs work when inject-workspace-packages=true', async () => { + expect.hasAssertions() + + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([ + { + name: 'project1', + dependencies: { + project2: 'workspace:*', + }, + }, + { + name: 'project2', + dependencies: { + 'is-positive': 'catalog:', + }, + }, + ]) + + const install = () => mutateModules(installProjects(projects), { + ...options, + lockfileOnly: true, + // This setting turns injected deps into regular symlinked workspace + // packages if peer dependencies aren't resolved differently. + dedupeInjectedDeps: false, + injectWorkspacePackages: true, + catalogs: { + default: { 'is-positive': '1.0.0' }, + }, + }) + + // This should run without "is-positive@catalog: isn't supported by any + // available resolver." errors. + await expect(install()).resolves.not.toThrow() + + const lockfile = readLockfile() + + // The resolved catalogs should be correct. + expect(lockfile.catalogs).toStrictEqual({ + default: { + 'is-positive': { specifier: '1.0.0', version: '1.0.0' }, + }, + }) + + expect(lockfile.importers).toEqual({ + // Check that project2 was indeed injected into project1. Otherwise this + // test wouldn't be checking the correct scenario. + project1: { + dependencies: { + project2: { specifier: 'workspace:*', version: 'file:project2' }, + }, + }, + project2: { + dependencies: { + 'is-positive': { specifier: 'catalog:', version: '1.0.0' }, + }, + }, + }) + + // Double check the correct version of is-positive as requested from the + // catalog was installed and not the latest. + expect(lockfile.snapshots).toStrictEqual({ + 'is-positive@1.0.0': {}, + 'project2@file:project2': { + dependencies: { 'is-positive': '1.0.0' }, + }, + }) +}) + describe('add', () => { test('adding is-positive@catalog: works', async () => { const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts index 0d25293efc..c0bb9eb0d8 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts @@ -1,5 +1,5 @@ import path from 'path' -import { matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver' +import { type CatalogResolution, matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver' import { deprecationLogger, progressLogger, @@ -190,6 +190,7 @@ export type PkgAddress = { depIsLinked: boolean isNew: boolean isLinkedDependency?: false + resolvedVia?: string nodeId: NodeId pkgId: PkgResolutionId normalizedPref?: string // is returned only for root dependencies @@ -250,7 +251,7 @@ export interface ResolvedPackage { } } -type ParentPkg = Pick +type ParentPkg = Pick export type ParentPkgAliases = Record @@ -551,18 +552,10 @@ async function resolveDependenciesOfImporterDependency ( const originalPref = extendedWantedDep.wantedDependency.pref if (catalogLookup != null) { - // The lockfile from a previous installation may have already resolved this - // cataloged dependency. Reuse the exact version in the lockfile catalog - // snapshot to ensure all projects using the same cataloged dependency get - // the same version. - const existingCatalogResolution = ctx.wantedLockfile.catalogs - ?.[catalogLookup.catalogName] - ?.[extendedWantedDep.wantedDependency.alias] - const replacementPref = existingCatalogResolution?.specifier === catalogLookup.specifier - ? replaceVersionInPref(catalogLookup.specifier, existingCatalogResolution.version) - : catalogLookup.specifier - - extendedWantedDep.wantedDependency.pref = replacementPref + extendedWantedDep.wantedDependency.pref = getCatalogReplacementPref( + catalogLookup, + ctx.wantedLockfile, + extendedWantedDep.wantedDependency) } const result = await resolveDependenciesOfDependency( @@ -830,6 +823,44 @@ async function resolveDependenciesOfDependency ( supportedArchitectures: options.supportedArchitectures, parentIds: options.parentIds, } + + // The catalog protocol is normally replaced when resolving the dependencies + // of importers. However, when a workspace package is "injected", it becomes a + // "file:" dependency and is no longer an "importer" from the perspective of + // pnpm. + // + // To allow the catalog protocol to still be used for injected workspace + // packages, it's necessary to check if the parent package was an injected + // workspace package and replace the catalog: protocol for the current package. + const isInjectedWorkspacePackage = options.parentPkg.resolvedVia === 'workspace' && + options.parentPkg.pkgId.startsWith('file:') + if (isInjectedWorkspacePackage) { + const catalogLookup = matchCatalogResolveResult(ctx.catalogResolver(extendedWantedDep.wantedDependency), { + found: (result) => result.resolution, + unused: () => undefined, + misconfiguration: (result) => { + throw result.error + }, + }) + + // The standard process for replacing the catalog protocol when resolving + // the dependencies of "importers" stores the catalog lookup in the + // dependency resolution result. This allows the catalogs snapshot section + // of the wanted lockfile to be kept up to date. + // + // We can do a simple replacement here instead and discard the catalog + // lookup object. It's not necessary to store this information for injected + // workspace packages. The injected workspace package will still be resolved + // as an importer separately, and we can rely on that process keeping the + // importers lockfile catalog snapshots up to date. + if (catalogLookup != null) { + extendedWantedDep.wantedDependency.pref = getCatalogReplacementPref( + catalogLookup, + ctx.wantedLockfile, + extendedWantedDep.wantedDependency) + } + } + const resolveDependencyResult = await resolveDependency(extendedWantedDep.wantedDependency, ctx, resolveDependencyOpts) if (resolveDependencyResult == null) return { resolveDependencyResult: null } @@ -1530,6 +1561,7 @@ async function resolveDependency ( return { alias: wantedDependency.alias || pkg.name, depIsLinked, + resolvedVia: pkgResponse.body.resolvedVia, isNew, nodeId, normalizedPref: options.currentDepth === 0 ? pkgResponse.body.normalizedPref : undefined, @@ -1651,3 +1683,22 @@ function peerDependenciesWithoutOwn (pkg: PackageManifest): PeerDependencies { } return result } + +function getCatalogReplacementPref ( + catalogLookup: CatalogResolution, + wantedLockfile: LockfileObject, + wantedDependency: WantedDependency +): string { + // The lockfile from a previous installation may have already resolved this + // cataloged dependency. Reuse the exact version in the lockfile catalog + // snapshot to ensure all projects using the same cataloged dependency get the + // same version. + const existingCatalogResolution = wantedLockfile.catalogs + ?.[catalogLookup.catalogName] + ?.[wantedDependency.alias] + const replacementPref = existingCatalogResolution?.specifier === catalogLookup.specifier + ? replaceVersionInPref(catalogLookup.specifier, existingCatalogResolution.version) + : catalogLookup.specifier + + return replacementPref +}