From c9b8632ea609bb152901f6ef1c48eb8d0dff431e Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Mon, 17 Aug 2026 01:28:21 +0200 Subject: [PATCH] feat(config): declare each registry once in the registries setting (#13942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registries do not all lay out tarball URLs the way the npm registry does. JFrog Artifactory repeats the scope in a scoped package's tarball filename (`@acme/widget/-/@acme/widget-1.0.0.tgz`) where npm strips it. pnpm cannot rebuild such a URL, so it writes it out for every scoped package instead of omitting it, and the lockfile carries a host-specific URL per dependency. pnpm had no place to record such a fact, because it had no place to describe a registry at all — only three ways to name one. `registries` mapped a scope to a URL, `namedRegistries` mapped a bare-specifier prefix to a URL, and neither could carry anything else. `registries` now declares a registry once, keyed by its URL, with every fact about it in the entry: a `serverType`, the `scopes` routed to it, and the `prefix` it answers to. `serverType` has three states: undeclared strict; only the exact canonical URL is reconstructible npm also serves the percent-encoded scoped path artifactory repeats the scope in the tarball filename registry.npmjs.org resolves to `npm` as a built-in, so its behavior is unchanged and the old hostname check becomes that one default rather than a special case in the predicate. `npm` cannot be the default: asserting npmjs-compatibility is a claim only the operator can make. The URL is the key because every fact in an entry is a fact about that server. Keying the layout by scope would bind it to whoever the scope currently points at, so two developers whose scope resolves differently would write lockfiles that disagree about which URLs may be omitted. `scopes` and `prefix` are routes to the registry and are inverted at config-read time into the two lookups the rest of pnpm already queries, leaving the resolver, installer, and lockfile layers untouched and the precedence chain (builtin < .npmrc < yaml < `_auth` < CLI) unchanged. The layout is declared, never inferred. Sniffing the registry URL cannot work: a virtual repository serves both layouts at once, depending on whether each package was synced from upstream or published locally, so no registry-level signal — route, response header, or probe — decides it. Declaring it also keeps a wrong guess a fixable misconfiguration instead of a silent breakage. `serverType` feeds a single URL builder that both sides of the lockfile use: the writer omits a tarball URL only when the builder reproduces it, and the reader rebuilds it with the same call. They therefore agree by construction, so pnpm never has to assume a registry serves some second URL as well. The setting lives in pnpm-workspace.yaml rather than .npmrc because the lockfile depends on it: one developer omitting URLs that another reconstructs differently would break a frozen install. A `serverType` in the global config.yaml is ignored for the same reason, while the routes declared alongside it are kept. Credentials are rejected there — the file is committed — and still belong in .npmrc. The registry URL is the map key, so the request-destination env gate applies to keys as well as values. Credentials and unknown fields are refused after parsing, since a parse error renders the offending source line verbatim. A map whose values are all strings is the older `: ` shape and is still read as one. Mixing the two shapes in one map is refused, and so is a URL-keyed entry written as a string. `namedRegistries` is deprecated in favor of `prefix` and is read only for prefixes `registries` does not declare; a prefix stays singular because it is the registry's identity in a lockfile dep path. An entry that routes nothing to itself and matches no configured registry is reported as a warning rather than silently ignored; it is a warning and not an error because a shared config dependency can legitimately describe registries a given project does not use. Config dependencies and pnpr-server-mode resolution pass no server type; in both, the writer and the reader share that default, so they stay consistent. Closes pnpm/get-npm-tarball-url#16. Supersedes pnpm/pnpm#13920. --- .changeset/drop-modules-yaml-registries.md | 18 + .changeset/pnpr-registry-declarations.md | 18 + .changeset/registries-by-scope-by-prefix.md | 60 +++ .changeset/registry-server-type.md | 43 ++ Cargo.lock | 1 + pnpm-lock.yaml | 13 + pnpm/crates/cli/src/cli_args/changelog.rs | 2 +- pnpm/crates/cli/src/cli_args/config.rs | 2 +- pnpm/crates/cli/src/cli_args/config/tests.rs | 8 +- .../cli/src/cli_args/config_warnings.rs | 59 ++- .../cli/src/cli_args/config_warnings/tests.rs | 75 +++ .../cli/src/cli_args/deps_tree/build.rs | 22 +- .../src/cli_args/deps_tree/get_tree/tests.rs | 1 + .../cli/src/cli_args/deps_tree/pkg_info.rs | 17 +- .../src/cli_args/deps_tree/pkg_info/tests.rs | 2 + pnpm/crates/cli/src/cli_args/dlx.rs | 2 +- pnpm/crates/cli/src/cli_args/global.rs | 3 +- pnpm/crates/cli/src/cli_args/install.rs | 4 +- pnpm/crates/cli/src/cli_args/list.rs | 7 +- pnpm/crates/cli/src/cli_args/pipelines.rs | 2 + pnpm/crates/cli/src/cli_args/publish.rs | 2 +- .../cli/src/cli_args/publish/recursive.rs | 9 +- .../src/cli_args/self_update/install_pnpm.rs | 2 +- pnpm/crates/cli/src/cli_args/why.rs | 9 +- pnpm/crates/cli/src/config_deps.rs | 2 +- pnpm/crates/cli/src/config_overrides.rs | 4 +- pnpm/crates/cli/src/config_overrides/tests.rs | 18 +- pnpm/crates/config/Cargo.toml | 1 + pnpm/crates/config/src/lib.rs | 46 +- pnpm/crates/config/src/npmrc_auth.rs | 4 +- pnpm/crates/config/src/npmrc_auth/tests.rs | 2 +- pnpm/crates/config/src/tests.rs | 26 +- pnpm/crates/config/src/workspace_yaml.rs | 203 +++++++- .../config/src/workspace_yaml/registries.rs | 328 +++++++++++++ .../crates/config/src/workspace_yaml/tests.rs | 461 +++++++++++++++++- .../src/install_package_by_snapshot.rs | 43 +- .../src/install_package_by_snapshot/tests.rs | 4 +- .../install_package_from_registry/tests.rs | 7 +- .../env-installer/src/install_config_deps.rs | 9 +- .../env-installer/src/manifest_lockfile.rs | 11 +- .../src/resolve_and_install_config_deps.rs | 38 +- pnpm/crates/env-installer/src/tests.rs | 2 +- pnpm/crates/lockfile/src/resolution.rs | 186 +++++-- pnpm/crates/lockfile/src/resolution/tests.rs | 195 +++++++- pnpm/crates/modules-yaml/src/lib.rs | 8 - pnpm/crates/napi/src/config.rs | 4 +- pnpm/crates/napi/src/read_config.rs | 4 +- pnpm/crates/napi/src/read_config/tests.rs | 2 +- pnpm/crates/napi/src/resolve.rs | 15 +- pnpm/crates/network/src/auth.rs | 23 + pnpm/crates/package-manager/src/add/tests.rs | 8 +- .../src/build_resolution_verifiers.rs | 6 +- .../src/build_resolution_verifiers/tests.rs | 2 +- .../src/dependencies_graph_to_lockfile.rs | 39 +- .../dependencies_graph_to_lockfile/tests.rs | 55 ++- .../src/fast_update_catalog_versions/tests.rs | 1 + .../src/fast_update_overrides.rs | 19 +- .../src/fast_update_overrides/tests.rs | 1 + .../src/install/modules_state.rs | 1 - .../package-manager/src/install/tests.rs | 15 +- .../src/install_with_fresh_lockfile.rs | 17 +- .../install_with_fresh_lockfile/resolve.rs | 13 +- .../resolver_setup.rs | 16 +- .../crates/package-manager/src/patch/tests.rs | 2 +- pnpm/crates/package-manager/src/update.rs | 6 +- pnpm/crates/pnpr-client/src/lib.rs | 31 +- pnpm/crates/pnpr-client/src/tests.rs | 2 +- pnpm/crates/pnpr-client/tests/integration.rs | 83 +++- .../src/lockfile_reuse.rs | 28 +- .../src/lockfile_reuse/tests.rs | 16 +- .../src/resolve_dependency_tree/walk.rs | 7 +- .../resolve_dependency_tree/workspace_ctx.rs | 27 +- .../src/resolve_workspace.rs | 21 +- .../src/resolve_workspace/tests.rs | 5 +- .../src/create_npm_resolution_verifier.rs | 20 +- .../create_npm_resolution_verifier/tests.rs | 11 +- .../resolving-npm-resolver/src/errors.rs | 13 + .../src/fetch_attestation_published_at.rs | 5 +- .../src/fetch_full_metadata.rs | 6 + pnpm/crates/resolving-npm-resolver/src/lib.rs | 2 +- .../src/named_registry.rs | 10 +- .../src/named_registry_resolver.rs | 6 +- .../src/named_registry_resolver/tests.rs | 6 +- .../src/npm_resolver.rs | 4 +- .../src/npm_resolver/tests.rs | 4 +- pnpm/crates/tarball/src/download.rs | 12 + pnpm/crates/tarball/src/error.rs | 14 + pnpm/crates/tarball/src/zip_archive.rs | 7 + .../src/workspace_resolution.rs | 5 +- .../after-install/src/extendBuildOptions.ts | 10 +- pnpm11/building/after-install/src/index.ts | 2 - pnpm11/building/commands/src/build/rebuild.ts | 2 +- .../building/commands/src/build/recursive.ts | 2 +- .../commands/src/policy/approveBuilds.ts | 4 +- pnpm11/building/commands/test/build/index.ts | 29 +- .../building/commands/test/build/recursive.ts | 8 - pnpm11/config/commands/src/configGet.ts | 2 +- pnpm11/config/commands/test/configGet.test.ts | 2 +- .../config/normalize-registries/package.json | 8 +- .../config/normalize-registries/src/index.ts | 95 +++- .../test/toRegistryDeclarations.test.ts | 69 +++ .../normalize-registries/test/tsconfig.json | 18 + .../pick-registry-for-package/src/index.ts | 16 +- .../test/knownRegistries.test.ts | 36 +- pnpm11/config/reader/src/Config.ts | 18 +- .../reader/src/getOptionsFromRootManifest.ts | 308 +++++++++++- pnpm11/config/reader/src/index.ts | 88 +++- pnpm11/config/reader/src/localConfig.ts | 2 +- .../reader/src/packageManagerRegistries.ts | 8 +- pnpm11/config/reader/src/quoteAndJoin.ts | 4 + .../test/getOptionsFromRootManifest.test.ts | 243 ++++++++- pnpm11/config/reader/test/index.ts | 238 +++++++-- pnpm11/core/constants/src/index.ts | 4 +- pnpm11/core/types/src/misc.ts | 81 ++- pnpm11/core/types/src/package.ts | 17 +- .../compliance/commands/src/audit/audit.ts | 6 +- .../commands/src/audit/publishTimes.ts | 2 +- .../commands/src/audit/signatures.ts | 2 +- .../commands/src/licenses/licensesList.ts | 8 +- .../deps/compliance/commands/src/sbom/sbom.ts | 8 +- .../compliance/commands/test/audit/index.ts | 2 +- .../test/audit/preserveReferenceOverrides.ts | 4 +- .../commands/test/audit/utils/options.ts | 6 +- .../commands/test/licenses/utils/index.ts | 2 +- .../compliance/commands/test/sbom/index.ts | 4 +- .../commands/test/sbom/utils/index.ts | 2 +- .../license-scanner/src/getPkgInfo.ts | 8 +- .../license-scanner/src/licenses.ts | 10 +- .../src/lockfileToLicenseNodeTree.ts | 14 +- .../license-scanner/test/getPkgInfo.spec.ts | 10 +- .../license-scanner/test/licenses.spec.ts | 16 +- .../compliance/sbom/src/collectComponents.ts | 18 +- pnpm11/deps/compliance/sbom/src/purl.ts | 4 +- .../sbom/test/collectComponents.test.ts | 28 +- .../sbom/test/getPkgMetadata.test.ts | 2 +- pnpm11/deps/graph-builder/package.json | 1 + .../graph-builder/src/lockfileToDepGraph.ts | 20 +- .../test/lockfileToDepGraph.test.ts | 2 +- pnpm11/deps/graph-builder/tsconfig.json | 3 + .../commands/src/fetchPackageInfo.ts | 2 +- .../commands/src/outdated/outdated.ts | 2 +- pnpm11/deps/inspection/commands/test/bugs.ts | 2 +- pnpm11/deps/inspection/commands/test/docs.ts | 2 +- .../commands/test/outdated/index.ts | 4 +- .../test/outdated/minimumReleaseAge.test.ts | 2 +- pnpm11/deps/inspection/commands/test/repo.ts | 2 +- pnpm11/deps/inspection/commands/test/view.ts | 2 +- pnpm11/deps/inspection/list/src/index.ts | 32 +- .../tree-builder/src/buildDependenciesTree.ts | 17 +- .../tree-builder/src/buildDependentsTree.ts | 21 +- .../inspection/tree-builder/src/getPkgInfo.ts | 10 +- .../inspection/tree-builder/src/getTree.ts | 6 +- .../tree-builder/test/getPkgInfo.test.ts | 2 +- .../tree-builder/test/getTree.test.ts | 16 +- pnpm11/deps/path/src/index.ts | 8 +- .../signatures/src/verifySignatures.ts | 2 +- .../commands/src/self-updater/installPnpm.ts | 14 +- .../commands/src/self-updater/selfUpdate.ts | 4 +- .../self-updater/verifyPnpmEngineIdentity.ts | 18 +- pnpm11/engine/pm/commands/src/with/with.ts | 4 +- .../test/self-updater/selfUpdate.test.ts | 166 +++---- .../verifyPnpmEngineIdentity.test.ts | 10 +- .../engine/runtime/commands/src/env/node.ts | 2 +- pnpm11/exec/commands/src/dlx.ts | 12 +- .../commands/test/dlx.createCacheKey.test.ts | 16 +- pnpm11/exec/commands/test/dlx.e2e.ts | 8 +- pnpm11/global/commands/src/globalAdd.ts | 4 +- pnpm11/global/commands/test/globalAdd.test.ts | 4 +- pnpm11/global/packages/src/cacheKey.ts | 4 +- pnpm11/hooks/types/src/index.ts | 9 +- pnpm11/installing/client/test/index.ts | 4 +- pnpm11/installing/commands/src/install.ts | 2 +- pnpm11/installing/commands/src/installDeps.ts | 2 +- pnpm11/installing/commands/src/recursive.ts | 2 +- pnpm11/installing/commands/src/remove.ts | 2 +- .../commands/src/verifyPacquetIdentity.ts | 12 +- pnpm11/installing/commands/test/add.ts | 2 +- pnpm11/installing/commands/test/addJsr.ts | 4 +- pnpm11/installing/commands/test/fetch.ts | 2 +- pnpm11/installing/commands/test/import.ts | 2 +- .../commands/test/importRecursive.ts | 2 +- pnpm11/installing/commands/test/install.ts | 2 +- .../commands/test/peerDependencies.ts | 2 +- pnpm11/installing/commands/test/prune.ts | 2 +- .../installing/commands/test/saveCatalog.ts | 2 +- .../commands/test/update/interactive.ts | 2 +- .../commands/test/update/issue-7415.ts | 2 +- pnpm11/installing/commands/test/update/jsr.ts | 4 +- pnpm11/installing/context/src/index.ts | 22 +- pnpm11/installing/context/test/index.ts | 2 +- .../src/getPeerDependencyIssues.ts | 6 +- .../src/install/extendInstallOptions.ts | 22 +- .../deps-installer/src/install/index.ts | 25 +- .../deps-installer/src/install/link.ts | 6 +- .../src/install/tryFastUpdateOverrides.ts | 25 +- .../src/install/validateModules.ts | 4 +- .../deps-installer/test/breakingChanges.ts | 2 +- .../deps-installer/test/install/auth.ts | 16 +- .../test/install/catalogVersionFastUpdate.ts | 2 +- .../test/install/composedFastUpdate.ts | 2 +- .../test/install/cyclicPeerDeterminism.ts | 12 +- .../test/install/deepRecursive.ts | 4 +- .../test/install/lifecycleScripts.ts | 16 +- .../deps-installer/test/install/misc.ts | 2 +- .../test/install/optionalDependencies.ts | 4 +- .../test/install/overrideRangeFastUpdate.ts | 8 +- .../install/patchedDependenciesFastUpdate.ts | 2 +- .../test/install/prereleaseWeights.ts | 8 +- .../install/tryFastUpdateOverrides.test.ts | 6 +- .../deps-installer/test/lockfile.ts | 4 +- .../test/namedRegistriesLockfileFormat.ts | 10 +- .../deps-installer/test/utils/testDefaults.ts | 12 +- pnpm11/installing/deps-resolver/src/index.ts | 4 +- .../deps-resolver/src/resolveDependencies.ts | 35 +- .../src/resolveDependencyTree.ts | 28 +- .../deps-resolver/src/updateLockfile.ts | 28 +- .../test/resolveDependencyTree.test.ts | 6 +- .../deps-resolver/test/updateLockfile.test.ts | 16 +- pnpm11/installing/deps-restorer/package.json | 1 + pnpm11/installing/deps-restorer/src/index.ts | 24 +- .../src/lockfileToHoistedDepGraph.ts | 9 +- .../test/lockfileToHoistedDepGraph.test.ts | 2 +- .../deps-restorer/test/utils/testDefaults.ts | 2 +- pnpm11/installing/deps-restorer/tsconfig.json | 3 + .../env-installer/src/installConfigDeps.ts | 18 +- .../env-installer/src/migrateConfigDeps.ts | 8 +- .../src/resolveAndInstallConfigDeps.ts | 14 +- .../env-installer/src/resolveConfigDeps.ts | 6 +- .../src/resolveManifestDependencies.ts | 6 +- .../src/resolveOptionalSubdeps.ts | 8 +- .../src/resolvePackageManagerIntegrities.ts | 6 +- .../env-installer/test/installConfigDeps.ts | 40 +- .../test/resolveAndInstallConfigDeps.test.ts | 2 +- .../test/resolveConfigDeps.test.ts | 12 +- .../resolvePackageManagerIntegrities.test.ts | 2 +- pnpm11/installing/modules-yaml/src/index.ts | 12 +- pnpm11/installing/modules-yaml/test/index.ts | 41 +- .../package-requester/test/index.ts | 8 +- .../read-projects-context/src/index.ts | 4 - pnpm11/lockfile/to-pnp/src/index.ts | 6 +- pnpm11/lockfile/to-pnp/test/index.ts | 8 +- .../utils/src/pkgSnapshotToResolution.ts | 29 +- .../utils/src/toLockfileResolution.ts | 17 +- .../utils/test/pkgSnapshotToResolution.ts | 30 +- .../utils/test/toLockfileResolution.ts | 113 +++-- pnpm11/patching/commands/src/patch.ts | 2 +- pnpm11/patching/commands/src/writePackage.ts | 2 +- pnpm11/patching/commands/test/patch.test.ts | 2 +- pnpm11/pnpm/src/switchCliVersion.test.ts | 26 +- pnpm11/pnpm/src/switchCliVersion.ts | 8 +- pnpm11/pnpm/src/syncEnvLockfile.test.ts | 18 +- pnpm11/pnpm/src/syncEnvLockfile.ts | 2 +- pnpm11/pnpm/test/dlx.ts | 8 +- pnpm11/registry-access/commands/src/access.ts | 46 +- .../commands/src/deprecation/common.ts | 6 +- .../registry-access/commands/src/distTag.ts | 10 +- pnpm11/registry-access/commands/src/owner.ts | 10 +- pnpm11/registry-access/commands/src/search.ts | 6 +- .../commands/src/star/common.ts | 6 +- .../commands/src/star/stars.ts | 2 +- pnpm11/registry-access/commands/src/team.ts | 6 +- .../registry-access/commands/src/unpublish.ts | 6 +- pnpm11/registry-access/commands/src/whoami.ts | 6 +- .../registry-access/commands/test/access.ts | 66 +-- .../registry-access/commands/test/dist-tag.ts | 2 +- pnpm11/registry-access/commands/test/owner.ts | 32 +- .../registry-access/commands/test/search.ts | 4 +- pnpm11/registry-access/commands/test/star.ts | 28 +- pnpm11/registry-access/commands/test/team.ts | 90 ++-- .../registry-access/commands/test/whoami.ts | 8 +- pnpm11/releasing/commands/src/publish/pack.ts | 4 +- .../commands/src/publish/previousChangelog.ts | 4 +- .../commands/src/publish/publishPackedPkg.ts | 6 +- .../commands/src/publish/recursivePublish.ts | 10 +- .../releasing/commands/src/stage/context.ts | 6 +- .../test/publish/batchPublish.test.ts | 2 +- .../test/publish/createPublishContext.test.ts | 2 +- .../test/publish/oidcOrchestrator.test.ts | 2 +- .../commands/test/publish/publish.ts | 2 +- .../test/publish/publishConfigAccess.test.ts | 2 +- .../test/publish/publishReadme.test.ts | 2 +- .../commands/test/publish/recursivePublish.ts | 4 +- pnpm11/releasing/commands/test/stage.test.ts | 2 +- .../resolving/default-resolver/src/index.ts | 6 +- .../default-resolver/test/customResolver.ts | 24 +- .../resolving/default-resolver/test/index.ts | 2 +- .../default-resolver/test/namedRegistry.ts | 22 +- .../src/createNpmResolutionVerifier.ts | 30 +- pnpm11/resolving/npm-resolver/src/fetch.ts | 2 +- pnpm11/resolving/npm-resolver/src/index.ts | 38 +- .../npm-resolver/src/parseBareSpecifier.ts | 2 +- .../npm-resolver/test/clearCache.test.ts | 6 +- .../test/createNpmResolutionVerifier.test.ts | 16 +- .../npm-resolver/test/distTagsByDate.test.ts | 24 +- .../fixtures/resolve-bloated-metadata.mjs | 2 +- .../test/heldBackUpdateWarning.test.ts | 16 +- .../npm-resolver/test/ifModifiedSince.test.ts | 62 +-- pnpm11/resolving/npm-resolver/test/index.ts | 356 +++++++------- .../npm-resolver/test/metaCache.test.ts | 2 +- .../test/normalizeAbbreviated.test.ts | 12 +- .../test/optionalDependencies.test.ts | 14 +- .../npm-resolver/test/publishedBy.test.ts | 88 ++-- .../npm-resolver/test/resolveJsr.test.ts | 32 +- .../test/resolveNamedRegistry.test.ts | 72 +-- pnpm11/resolving/tarball-url/package.json | 3 + pnpm11/resolving/tarball-url/src/index.ts | 56 ++- .../resolving/tarball-url/test/index.test.ts | 85 +++- pnpm11/resolving/tarball-url/tsconfig.json | 6 +- .../store/commands/src/inspecting/catIndex.ts | 2 +- pnpm11/store/commands/src/store/store.ts | 2 +- .../storeStatus/extendStoreStatusOptions.ts | 10 +- .../test/store/cleanExpiredDlxCache.test.ts | 2 +- pnpm11/store/commands/test/store/storeAdd.ts | 6 +- pnpm11/store/commands/test/store/storePath.ts | 4 +- .../store/commands/test/store/storePrune.ts | 36 +- .../store/commands/test/store/storeStatus.ts | 15 +- .../src/createNewStoreController.ts | 8 +- pnpm11/store/controller/test/index.ts | 4 +- pnpm11/testing/command-defaults/src/index.ts | 2 +- pnpm11/testing/temp-store/src/index.ts | 2 +- pnpr/client/src/resolveViaPnprServer.ts | 14 +- pnpr/crates/pnpr/src/resolver.rs | 46 +- pnpr/crates/pnpr/src/resolver/protocol.rs | 14 +- pnpr/crates/pnpr/src/resolver/tests.rs | 35 +- pnpr/crates/pnpr/src/route.rs | 4 + 325 files changed, 4954 insertions(+), 1978 deletions(-) create mode 100644 .changeset/drop-modules-yaml-registries.md create mode 100644 .changeset/pnpr-registry-declarations.md create mode 100644 .changeset/registries-by-scope-by-prefix.md create mode 100644 .changeset/registry-server-type.md create mode 100644 pnpm/crates/cli/src/cli_args/config_warnings/tests.rs create mode 100644 pnpm/crates/config/src/workspace_yaml/registries.rs create mode 100644 pnpm11/config/normalize-registries/test/toRegistryDeclarations.test.ts create mode 100644 pnpm11/config/normalize-registries/test/tsconfig.json create mode 100644 pnpm11/config/reader/src/quoteAndJoin.ts diff --git a/.changeset/drop-modules-yaml-registries.md b/.changeset/drop-modules-yaml-registries.md new file mode 100644 index 0000000000..ebc4c3242f --- /dev/null +++ b/.changeset/drop-modules-yaml-registries.md @@ -0,0 +1,18 @@ +--- +"@pnpm/building.after-install": major +"@pnpm/building.commands": major +"@pnpm/deps.inspection.tree-builder": major +"@pnpm/installing.context": major +"@pnpm/installing.deps-installer": major +"@pnpm/installing.deps-restorer": major +"@pnpm/installing.modules-yaml": major +"@pnpm/installing.read-projects-context": major +"pacquet": minor +"pnpm": minor +--- + +`node_modules/.modules.yaml` no longer records the registries an install resolved from, and the recorded copy is dropped from the file on the first install that rewrites it. + +It dated from the lockfile format that spelled a dependency's path relative to its registry, where reading an installed tree meant knowing the registries it was installed with. Dependency paths have not carried a registry for several major versions, and the recorded copy outlived its use: `pnpm list`, `pnpm why`, and single-project installs preferred it over the project's own configuration, so a project whose registry had changed since its last install was still read through the old one. + +They now use the configured registries, like every other command already did. diff --git a/.changeset/pnpr-registry-declarations.md b/.changeset/pnpr-registry-declarations.md new file mode 100644 index 0000000000..5926914542 --- /dev/null +++ b/.changeset/pnpr-registry-declarations.md @@ -0,0 +1,18 @@ +--- +"@pnpm/config.normalize-registries": minor +"@pnpm/installing.deps-installer": minor +"@pnpm/pnpr.client": major +"@pnpm/pnpr": minor +"pacquet": minor +"pnpm": minor +--- + +A pnpr resolve request now carries the client's registries the way the `registries` setting declares them — keyed by URL, with the scopes routed to each, the bare-specifier prefix each answers to, and each one's `serverType` — in place of the prefix map it used to send. + +The server routes them through the same inversion the config reader runs, so a pnpr-served install resolves a scoped dependency from the registry that scope is routed to, which it previously could not: only the default registry and the prefix-addressed ones reached the server. A declared `serverType` reaches it too, so the tarball URLs pnpr omits from the lockfile match the ones the client reconstructs. + +Built-in scope routes the project has not pointed elsewhere are not declared, so a pnpr server's allowlist is not asked about `npm.jsr.io` on requests that resolve no JSR package. + +A registry a request only declares is no longer refused up front for being off the server's allowlist — a client describes its whole configuration, including scopes a given resolve never reaches, so a stray `@scope:registry` in a developer's `~/.npmrc` no longer fails every install against a pnpr server that does not serve it. The boundary moves to the fetch itself: an origin the resolve does reach is refused before the request leaves the server, with the same message. + +This changes the resolve and verify-lockfile request bodies. A pnpr server and its clients have to be on matching versions; the protocol is still experimental and unversioned. diff --git a/.changeset/registries-by-scope-by-prefix.md b/.changeset/registries-by-scope-by-prefix.md new file mode 100644 index 0000000000..960bc4fdd4 --- /dev/null +++ b/.changeset/registries-by-scope-by-prefix.md @@ -0,0 +1,60 @@ +--- +"@pnpm/building.after-install": major +"@pnpm/building.commands": major +"@pnpm/config.commands": major +"@pnpm/config.normalize-registries": major +"@pnpm/config.pick-registry-for-package": major +"@pnpm/config.reader": major +"@pnpm/constants": major +"@pnpm/deps.compliance.commands": major +"@pnpm/deps.compliance.license-scanner": major +"@pnpm/deps.compliance.sbom": major +"@pnpm/deps.graph-builder": major +"@pnpm/deps.inspection.commands": major +"@pnpm/deps.inspection.list": major +"@pnpm/deps.inspection.tree-builder": major +"@pnpm/deps.path": major +"@pnpm/deps.security.signatures": major +"@pnpm/engine.pm.commands": major +"@pnpm/engine.runtime.commands": major +"@pnpm/exec.commands": major +"@pnpm/global.commands": major +"@pnpm/global.packages": major +"@pnpm/hooks.types": major +"@pnpm/installing.commands": major +"@pnpm/installing.context": major +"@pnpm/installing.deps-installer": major +"@pnpm/installing.deps-resolver": major +"@pnpm/installing.deps-restorer": major +"@pnpm/installing.env-installer": major +"@pnpm/installing.modules-yaml": major +"@pnpm/installing.read-projects-context": major +"@pnpm/lockfile.to-pnp": major +"@pnpm/lockfile.utils": major +"@pnpm/patching.commands": major +"@pnpm/pnpr.client": major +"@pnpm/registry-access.commands": major +"@pnpm/releasing.commands": major +"@pnpm/resolving.default-resolver": major +"@pnpm/resolving.npm-resolver": major +"@pnpm/store.commands": major +"@pnpm/store.connection-manager": major +"@pnpm/testing.command-defaults": major +"@pnpm/testing.temp-store": major +"@pnpm/types": major +"pacquet": patch +--- + +The three registry lookups are now named for what they are keyed by, so that none of them is called `registries` — a name the `registries` setting itself has taken: + +| before | after | +|---|---| +| `Config.registries` | `Config.registriesByScope` | +| `Config.namedRegistries` | `Config.registriesByPrefix` | +| `Config.registryOptions` | `Config.registryOptionsByUrl` | + +The same rename applies to the `RegistryContext` fields, the `Registries` and `NamedRegistries` types (now `RegistriesByScope` and `RegistriesByPrefix`), `normalizeRegistries` / `normalizeNamedRegistries` (now `normalizeRegistriesByScope` / `normalizeRegistriesByPrefix`), and the `BUILTIN_NAMED_REGISTRIES` constant (now `BUILTIN_REGISTRIES_BY_PREFIX`). + +This is an internal rename: no setting, error code, lockfile field, `.modules.yaml` key, or `.pnpmfile.cjs` hook field changes. A `preResolution` hook still reads `ctx.registries`, which is the name pacquet passes as well. The `registries` and `namedRegistries` settings are read under the names users write them, and `node_modules/.modules.yaml` keeps its `registries` field. + +The pnpr resolve request sends `registriesByPrefix` where it sent `namedRegistries`. A pnpr server and its clients must be on matching versions, which is already the case for an experimental server. diff --git a/.changeset/registry-server-type.md b/.changeset/registry-server-type.md new file mode 100644 index 0000000000..0c06a48aba --- /dev/null +++ b/.changeset/registry-server-type.md @@ -0,0 +1,43 @@ +--- +"@pnpm/config.normalize-registries": minor +"@pnpm/lockfile.utils": major +"@pnpm/resolving.tarball-url": major +"@pnpm/installing.deps-resolver": minor +"@pnpm/installing.deps-installer": minor +"@pnpm/config.reader": minor +"@pnpm/types": minor +"pacquet": minor +"pnpm": minor +--- + +The `registries` setting now declares a registry once, keyed by its URL, with everything about that registry in the entry: how it lays out tarball URLs, the scopes routed to it, and the bare-specifier prefix it answers to. + +```yaml +registries: + https://artifactory.example.com/artifactory/api/npm/npm-virtual/: + serverType: artifactory + scopes: ['@acme', '@acme-internal'] + prefix: work +``` + +- **`serverType`** tells pnpm how the registry lays out its tarball URLs, which decides whether a URL can be omitted from `pnpm-lock.yaml`: + - **undeclared** (the default) — strict. Only the exact canonical URL is treated as reconstructible. + - **`npm`** — the registry behaves like `registry.npmjs.org`, which also serves a scoped package from its percent-encoded path. Declare this for a faithful mirror or caching proxy of the public registry so its tarball URLs can be omitted too. + - **`artifactory`** — JFrog Artifactory repeats the scope in a scoped package's tarball filename (`@acme/widget/-/@acme/widget-1.0.0.tgz`) where the npm registry strips it (`@acme/widget/-/widget-1.0.0.tgz`). Declaring it lets pnpm rebuild that URL, so it is omitted from `pnpm-lock.yaml` instead of being written out for every scoped package [pnpm/get-npm-tarball-url#16](https://github.com/pnpm/get-npm-tarball-url/issues/16). +- **`scopes`** lists the `@`-prefixed scopes that resolve from this registry. A bare `'@'` is the scope-less default registry, the one the `registry` setting names. +- **`prefix`** is the alias a dependency addresses this registry by, as in `"foo": "work:^1.0.0"`. + +The layout is never inferred from the registry URL, so nothing changes unless you declare it; `registry.npmjs.org` continues to behave as `npm` without being declared. Because the lockfile depends on `serverType`, it is read from `pnpm-workspace.yaml` only — a `serverType` in the global `config.yaml` is ignored, so one developer's machine cannot shape a lockfile their collaborators read back with a different layout. Credentials are rejected in this setting, in a key as well as in a field, and still belong in `.npmrc`. An entry that routes nothing to itself and matches no configured registry is reported as a warning rather than silently ignored. + +### Migrating + +The older `registries` shape, a map of `: ` strings, still works and needs no change: + +```yaml +registries: + '@acme': https://npm.acme.example/ +``` + +`namedRegistries` is deprecated in favor of the `prefix` field, and is still read for prefixes `registries` does not declare. + +`toLockfileResolution` and `isCanonicalRegistryTarballUrl` now take their registry and layout as an options object rather than positional arguments, so `@pnpm/lockfile.utils` and `@pnpm/resolving.tarball-url` get a major bump. diff --git a/Cargo.lock b/Cargo.lock index 6fa8822b77..1906769c95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4420,6 +4420,7 @@ dependencies = [ "pnpm-detect-libc", "pnpm-env-replace", "pnpm-fs", + "pnpm-lockfile", "pnpm-network", "pnpm-package-is-installable", "pnpm-package-manifest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 645d65034c..c09bd7c9b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2373,6 +2373,9 @@ importers: specifier: 'catalog:' version: '@pnpm/ramda@0.28.1' devDependencies: + '@jest/globals': + specifier: 'catalog:' + version: 30.4.1(supports-color@10.2.2) '@pnpm/config.normalize-registries': specifier: workspace:* version: 'link:' @@ -3198,6 +3201,9 @@ importers: pnpm11/deps/graph-builder: dependencies: + '@pnpm/config.normalize-registries': + specifier: workspace:* + version: link:../../config/normalize-registries '@pnpm/config.package-is-installable': specifier: workspace:* version: link:../../config/package-is-installable @@ -6116,6 +6122,9 @@ importers: '@pnpm/building.policy': specifier: workspace:* version: link:../../building/policy + '@pnpm/config.normalize-registries': + specifier: workspace:* + version: link:../../config/normalize-registries '@pnpm/config.package-is-installable': specifier: workspace:* version: link:../../config/package-is-installable @@ -9047,6 +9056,10 @@ importers: version: 'link:' pnpm11/resolving/tarball-url: + dependencies: + '@pnpm/types': + specifier: workspace:* + version: link:../../core/types devDependencies: '@jest/globals': specifier: 'catalog:' diff --git a/pnpm/crates/cli/src/cli_args/changelog.rs b/pnpm/crates/cli/src/cli_args/changelog.rs index f09c36f025..f9b79d8c5c 100644 --- a/pnpm/crates/cli/src/cli_args/changelog.rs +++ b/pnpm/crates/cli/src/cli_args/changelog.rs @@ -233,7 +233,7 @@ fn registry_for(config: &Config, name: &str) -> String { let registry = name .strip_prefix('@') .and_then(|rest| rest.split('/').next()) - .and_then(|scope| config.registries.get(&format!("@{scope}"))) + .and_then(|scope| config.registries_by_scope.get(&format!("@{scope}"))) .cloned() .unwrap_or_else(|| config.registry.clone()); if registry.ends_with('/') { registry } else { format!("{registry}/") } diff --git a/pnpm/crates/cli/src/cli_args/config.rs b/pnpm/crates/cli/src/cli_args/config.rs index f319b1644e..4359ef54fb 100644 --- a/pnpm/crates/cli/src/cli_args/config.rs +++ b/pnpm/crates/cli/src/cli_args/config.rs @@ -491,7 +491,7 @@ fn lookup_config(config: &Config, key: &str, is_scoped: bool) -> Option { if let Some(scope) = key.strip_suffix(":registry") { // Prefer the merged `registries` map so this reports the same URL // resolvers/publish use (pnpm/pnpm#11492). - if let Some(merged) = config.registries.get(scope) { + if let Some(merged) = config.registries_by_scope.get(scope) { return Some(Value::String(merged.clone())); } } diff --git a/pnpm/crates/cli/src/cli_args/config/tests.rs b/pnpm/crates/cli/src/cli_args/config/tests.rs index a1ee860a76..fa380f90e5 100644 --- a/pnpm/crates/cli/src/cli_args/config/tests.rs +++ b/pnpm/crates/cli/src/cli_args/config/tests.rs @@ -141,19 +141,19 @@ fn set_registries_and_named_registries_global_writes_config_yaml() { ) .unwrap(); - let named_registries = json!({ "work": "https://work.example.com/" }); + let registries_by_prefix = json!({ "work": "https://work.example.com/" }); config_set( &config, tmp.path(), flags(true, None, true), "named-registries", - Some(named_registries.to_string()), + Some(registries_by_prefix.to_string()), ) .unwrap(); assert_eq!( read_yaml(&config_dir.join("config.yaml")).unwrap(), - json!({ "registries": registries, "namedRegistries": named_registries }), + json!({ "registries": registries, "namedRegistries": registries_by_prefix }), ); } @@ -517,7 +517,7 @@ fn get_scoped_registry_from_auth_and_merged() { // merged `registries` block wins over the raw .npmrc value (pnpm/pnpm#11492) let mut merged = config_for_get(&[], &[("@scope:registry", "https://from-npmrc.example.com/")]); merged - .registries + .registries_by_scope .insert("@scope".to_string(), "https://from-workspace-yaml.example.com/".to_string()); assert_eq!( config_get(&merged, flags(false, None, false), "@scope:registry").unwrap(), diff --git a/pnpm/crates/cli/src/cli_args/config_warnings.rs b/pnpm/crates/cli/src/cli_args/config_warnings.rs index 8912f383a2..743e34b894 100644 --- a/pnpm/crates/cli/src/cli_args/config_warnings.rs +++ b/pnpm/crates/cli/src/cli_args/config_warnings.rs @@ -6,8 +6,14 @@ //! captures. Warnings emitted through the reporter stay on stdout; only //! config-load warnings belong here. +use pnpm_config::Config; use pnpm_default_reporter::colors::Colors; -use std::io::{IsTerminal, Write}; +use pnpm_network::redact_and_sanitize; +use pnpm_resolving_npm_resolver::BUILTIN_REGISTRIES_BY_PREFIX; +use std::{ + collections::BTreeSet, + io::{IsTerminal, Write}, +}; /// Write a `[WARN]`-labelled config-load warning to stderr. Best-effort: /// a warning must never abort the command, so a failed write (a closed @@ -22,3 +28,54 @@ pub(crate) fn emit_config_warning(message: &str) { }; let _ = writeln!(std::io::stderr(), "{} {message}", colors.warn_label()); } + +/// Warn about `registries` entries that name no configured registry. +/// +/// Such an entry is inert — the wrong URL, a stale entry, a scope that moved — +/// and silently doing nothing is the failure mode users cannot debug. A warning +/// rather than an error: a shared config dependency can legitimately describe +/// registries a given project does not use. +pub(crate) fn warn_unmatched_registry_options(config: &Config) { + if let Some(message) = unmatched_registry_options_warning(config) { + emit_config_warning(&message); + } +} + +/// The message [`warn_unmatched_registry_options`] emits, or [`None`] when +/// every entry matches. Split out so the wording is testable without +/// capturing stderr. +fn unmatched_registry_options_warning(config: &Config) -> Option { + if config.registry_options_by_url.is_empty() { + return None; + } + let configured: BTreeSet<&str> = config + .registries_by_scope + .values() + .chain(config.registries_by_prefix.values()) + .map(String::as_str) + .chain(BUILTIN_REGISTRIES_BY_PREFIX.iter().map(|(_, url)| *url)) + .collect(); + let unmatched = config + .registry_options_by_url + .keys() + .filter(|registry| !configured.contains(registry.as_str())) + .map(|registry| format!(r#""{}""#, redact_and_sanitize(registry))) + .collect::>(); + if unmatched.is_empty() { + return None; + } + // A registry URL can carry `user:pass@` credentials, so neither list may be + // echoed raw into a terminal or a CI log. + let configured = configured + .iter() + .map(|registry| format!(r#""{}""#, redact_and_sanitize(registry))) + .collect::>() + .join(", "); + Some(format!( + r#"The following "registries" entries do not match any configured registry and were ignored: {}. The configured registries are: {configured}."#, + unmatched.join(", "), + )) +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/cli/src/cli_args/config_warnings/tests.rs b/pnpm/crates/cli/src/cli_args/config_warnings/tests.rs new file mode 100644 index 0000000000..90bff2c7da --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/config_warnings/tests.rs @@ -0,0 +1,75 @@ +use super::unmatched_registry_options_warning; +use pnpm_config::Config; +use pnpm_lockfile::{RegistryOptions, RegistryServerType}; +use pretty_assertions::assert_eq; + +fn config_with(registries: &[(&str, &str)], registry_options_by_url: &[&str]) -> Config { + let mut config = Config::new(); + config.registries_by_scope = + registries.iter().map(|(scope, url)| ((*scope).to_string(), (*url).to_string())).collect(); + config.registry_options_by_url = registry_options_by_url + .iter() + .map(|registry| { + ( + (*registry).to_string(), + RegistryOptions { server_type: Some(RegistryServerType::Artifactory) }, + ) + }) + .collect(); + config +} + +#[test] +fn no_warning_when_every_entry_matches_a_configured_registry() { + let config = config_with( + &[("default", "https://npm.example.com/"), ("@acme", "https://acme.example.com/")], + &["https://npm.example.com/", "https://acme.example.com/"], + ); + assert_eq!(unmatched_registry_options_warning(&config), None); +} + +#[test] +fn no_warning_without_any_registry_options() { + let config = config_with(&[("default", "https://npm.example.com/")], &[]); + assert_eq!(unmatched_registry_options_warning(&config), None); +} + +/// A built-in named registry is a legitimate target even though the user never +/// declared it, so an entry for it must not be reported as unmatched. +#[test] +fn no_warning_for_a_builtin_named_registry() { + let config = + config_with(&[("default", "https://npm.example.com/")], &["https://npm.pkg.github.com/"]); + assert_eq!(unmatched_registry_options_warning(&config), None); +} + +#[test] +fn warns_about_an_entry_matching_no_configured_registry() { + let config = config_with( + &[("default", "https://npm.example.com/")], + &["https://npm.example.com/", "https://typo.example.com/"], + ); + let received = unmatched_registry_options_warning(&config).expect("a warning"); + assert!( + received.contains(r#"were ignored: "https://typo.example.com/"."#), + "the unmatched entry must be named: {received}", + ); + assert!( + received.contains(r#""https://npm.example.com/""#), + "the configured registries must be listed: {received}", + ); +} + +/// A registry URL can carry `user:pass@` credentials, and this warning names +/// every configured registry, so it must not echo one into a CI log. +#[test] +fn redacts_credentials_in_the_warning() { + let config = config_with( + &[("default", "https://ci-user-6e42:hunter2@npm.example.com/")], + &["https://typo.example.com/"], + ); + let received = unmatched_registry_options_warning(&config).expect("a warning"); + assert!(!received.contains("hunter2"), "the password must not be echoed: {received}"); + assert!(!received.contains("ci-user-6e42"), "the username must not be echoed: {received}"); + assert!(received.contains("npm.example.com"), "the host is still named: {received}"); +} diff --git a/pnpm/crates/cli/src/cli_args/deps_tree/build.rs b/pnpm/crates/cli/src/cli_args/deps_tree/build.rs index 52dee5bc64..943928fa01 100644 --- a/pnpm/crates/cli/src/cli_args/deps_tree/build.rs +++ b/pnpm/crates/cli/src/cli_args/deps_tree/build.rs @@ -4,14 +4,14 @@ //! `buildDependenciesTree`. use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, io, path::{Path, PathBuf}, }; use miette::{Context, IntoDiagnostic}; use pnpm_fs::lexical_normalize; -use pnpm_lockfile::{Lockfile, ProjectSnapshot}; +use pnpm_lockfile::{Lockfile, ProjectSnapshot, RegistryOptions}; use pnpm_modules_yaml::{ DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, Host, IncludedDependencies, Modules, read_modules_manifest, @@ -27,8 +27,6 @@ use super::{ search::Searcher, }; -pub(crate) const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org/"; - /// The lockfiles and modules-manifest state one tree build runs /// against. Owns the loaded lockfiles; [`LoadedState::env`] borrows them. pub(crate) struct LoadedState { @@ -85,17 +83,14 @@ impl LoadedState { &'a self, lockfile_dir: &Path, virtual_store_dir_max_length: usize, + registries_by_scope: &BTreeMap, + registry_options_by_url: BTreeMap, ) -> Option> { let lockfile = self.lockfile_to_use()?; - let mut registries = HashMap::new(); - registries.insert("default".to_string(), DEFAULT_REGISTRY.to_string()); - if let Some(modules_registries) = - self.modules.as_ref().and_then(|modules| modules.registries.as_ref()) - { - for (key, url) in modules_registries { - registries.insert(key.clone(), url.clone()); - } - } + let registries: HashMap = registries_by_scope + .iter() + .map(|(scope, registry)| (scope.clone(), registry.clone())) + .collect(); let virtual_store_dir = match &self.modules { Some(modules) if !modules.virtual_store_dir.is_empty() => { let dir = PathBuf::from(&modules.virtual_store_dir); @@ -115,6 +110,7 @@ impl LoadedState { }, ), registries, + registry_options_by_url, skipped: self .modules .as_ref() diff --git a/pnpm/crates/cli/src/cli_args/deps_tree/get_tree/tests.rs b/pnpm/crates/cli/src/cli_args/deps_tree/get_tree/tests.rs index c1db925196..bc02543591 100644 --- a/pnpm/crates/cli/src/cli_args/deps_tree/get_tree/tests.rs +++ b/pnpm/crates/cli/src/cli_args/deps_tree/get_tree/tests.rs @@ -67,6 +67,7 @@ fn mock_env<'a>(dir: &Path, lockfile: &'a Lockfile) -> PkgInfoEnv<'a> { modules_dir: dir.join("node_modules"), virtual_store_dir: dir.join("node_modules/.pnpm"), virtual_store_dir_max_length: 120, + registry_options_by_url: std::collections::BTreeMap::new(), registries: HashMap::from([( "default".to_string(), "https://mock-registry-for-testing.example/".to_string(), diff --git a/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info.rs b/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info.rs index a6026fb0a2..8ae2bebaf1 100644 --- a/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info.rs +++ b/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info.rs @@ -4,13 +4,14 @@ //! `resolvePackagePath`. use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, path::{Path, PathBuf}, }; use pnpm_fs::{is_subdir, lexical_normalize}; use pnpm_lockfile::{ - Lockfile, LockfileResolution, PkgNameVerPeer, npm_tarball_url, pick_registry_for_package, + Lockfile, LockfileResolution, PkgNameVerPeer, RegistryOptions, TarballUrlOptions, + npm_tarball_url, pick_registry_for_package, registry_server_type, }; use super::{ @@ -32,6 +33,9 @@ pub(crate) struct PkgInfoEnv<'a> { pub virtual_store_dir_max_length: usize, /// Registry URLs keyed by `default` / `@scope`. pub registries: HashMap, + /// Per-registry tarball layouts from the `registries` setting, used + /// when a lockfile entry's omitted tarball URL is rebuilt for display. + pub registry_options_by_url: BTreeMap, /// depPaths of packages skipped by the installer (unsupported /// platform optional deps). pub skipped: HashSet, @@ -193,7 +197,14 @@ fn resolved_tarball_url( LockfileResolution::Tarball(tarball) => Some(tarball.tarball.clone()), LockfileResolution::Registry(_) => { let registry = pick_registry_for_package(&env.registries, name, None); - Some(npm_tarball_url(name, version, ®istry)) + Some(npm_tarball_url( + name, + version, + TarballUrlOptions { + registry: ®istry, + server_type: registry_server_type(&env.registry_options_by_url, ®istry), + }, + )) } _ => None, } diff --git a/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info/tests.rs b/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info/tests.rs index 2b6f88b5d3..1f8585fd92 100644 --- a/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info/tests.rs +++ b/pnpm/crates/cli/src/cli_args/deps_tree/pkg_info/tests.rs @@ -32,6 +32,7 @@ fn get_pkg_info_handles_missing_pkg_snapshot_without_crashing() { modules_dir: PathBuf::new(), virtual_store_dir: PathBuf::from(".pnpm"), virtual_store_dir_max_length: 120, + registry_options_by_url: std::collections::BTreeMap::new(), registries: HashMap::from([( "default".to_string(), "https://registry.npmjs.org/".to_string(), @@ -98,6 +99,7 @@ fn resolve_package_path_rejects_traversal_in_lockfile_derived_names() { modules_dir: dir.path().join("node_modules"), virtual_store_dir: virtual_store_dir.clone(), virtual_store_dir_max_length: 120, + registry_options_by_url: std::collections::BTreeMap::new(), registries: HashMap::from([( "default".to_string(), "https://registry.npmjs.org/".to_string(), diff --git a/pnpm/crates/cli/src/cli_args/dlx.rs b/pnpm/crates/cli/src/cli_args/dlx.rs index 02ee97ec62..b9c8548193 100644 --- a/pnpm/crates/cli/src/cli_args/dlx.rs +++ b/pnpm/crates/cli/src/cli_args/dlx.rs @@ -596,7 +596,7 @@ async fn run_package_manager( /// cache key. fn build_registries_map(config: &Config) -> BTreeMap { let mut map = config.resolved_registries(); - for (name, url) in &config.named_registries { + for (name, url) in &config.registries_by_prefix { map.insert(name.clone(), url.clone()); } map diff --git a/pnpm/crates/cli/src/cli_args/global.rs b/pnpm/crates/cli/src/cli_args/global.rs index d4647874fe..3062c203dc 100644 --- a/pnpm/crates/cli/src/cli_args/global.rs +++ b/pnpm/crates/cli/src/cli_args/global.rs @@ -797,7 +797,8 @@ fn warn_global(message: &str) { /// global cache key. fn registries_with_default(config: &Config) -> Vec<(String, String)> { let mut registries = vec![("default".to_string(), config.registry.clone())]; - registries.extend(config.registries.iter().map(|(key, value)| (key.clone(), value.clone()))); + registries + .extend(config.registries_by_scope.iter().map(|(key, value)| (key.clone(), value.clone()))); registries } diff --git a/pnpm/crates/cli/src/cli_args/install.rs b/pnpm/crates/cli/src/cli_args/install.rs index c82dc76a5b..7fa0892e8e 100644 --- a/pnpm/crates/cli/src/cli_args/install.rs +++ b/pnpm/crates/cli/src/cli_args/install.rs @@ -921,7 +921,7 @@ async fn install_via_pnpr_inner( } else { let verify_opts = VerifyLockfileOptions { registry: resolve_registry.clone(), - named_registries: state.config.named_registries.clone(), + registries: state.config.registry_declarations(), authorization: state.config.auth_headers.for_url(pnpr_server), overrides: overrides.clone(), lockfile: lockfile.clone(), @@ -1040,7 +1040,7 @@ async fn install_via_pnpr_inner( let opts = ResolveProjectsOptions { projects, registry: resolve_registry, - named_registries: state.config.named_registries.clone(), + registries: state.config.registry_declarations(), // Only the caller's identity to pnpr is sent. Upstream registry // credentials are never forwarded: pnpr selects them from its own // route policy, so they stay out of the request body. diff --git a/pnpm/crates/cli/src/cli_args/list.rs b/pnpm/crates/cli/src/cli_args/list.rs index 1f1add93f6..43cbba2170 100644 --- a/pnpm/crates/cli/src/cli_args/list.rs +++ b/pnpm/crates/cli/src/cli_args/list.rs @@ -275,7 +275,12 @@ impl ListArgs { Some(config.modules_dir.as_path()), self.lockfile_only, )?; - let env = state.env(lockfile_dir, config.virtual_store_dir_max_length as usize); + let env = state.env( + lockfile_dir, + config.virtual_store_dir_max_length as usize, + &config.resolved_registries(), + config.registry_options_by_url.clone(), + ); let mut hierarchies: Vec<(PathBuf, DependenciesHierarchy)> = Vec::new(); if self.depth == RecursionLimit::ProjectsOnly || env.is_none() { diff --git a/pnpm/crates/cli/src/cli_args/pipelines.rs b/pnpm/crates/cli/src/cli_args/pipelines.rs index 719dfaf4f0..d5260a7542 100644 --- a/pnpm/crates/cli/src/cli_args/pipelines.rs +++ b/pnpm/crates/cli/src/cli_args/pipelines.rs @@ -16,6 +16,7 @@ use super::{ use crate::{ State, cli_args::{ + config_warnings::warn_unmatched_registry_options, legacy_pnpm_field::warn_ignored_pnpm_manifest_fields, override_version_references::warn_deprecated_override_version_references, reporter::{ReporterType, reporter_emit}, @@ -660,6 +661,7 @@ pub(crate) fn derive_config_root_and_package_manager_to_sync( // knows the root manifest's directory. warn_ignored_pnpm_manifest_fields(root_manifest.as_ref()); warn_deprecated_override_version_references(cfg, reporter_emit(reporter)); + warn_unmatched_registry_options(cfg); let package_manager_to_sync = root_manifest .as_ref() .and_then(|manifest| package_manager_to_sync(manifest, &config_root, cfg.pm_on_fail)); diff --git a/pnpm/crates/cli/src/cli_args/publish.rs b/pnpm/crates/cli/src/cli_args/publish.rs index 256cfd8b88..903cc61c49 100644 --- a/pnpm/crates/cli/src/cli_args/publish.rs +++ b/pnpm/crates/cli/src/cli_args/publish.rs @@ -333,7 +333,7 @@ impl PublishArgs { ) -> PublishPackedPkgOptions { PublishPackedPkgOptions { default_registry: config.registry.clone(), - scoped_registries: config.registries.clone(), + scoped_registries: config.registries_by_scope.clone(), access: self.flags.access.as_deref().and_then(Access::parse), tag: self.flags.tag.clone().unwrap_or_else(|| "latest".to_owned()), otp, diff --git a/pnpm/crates/cli/src/cli_args/publish/recursive.rs b/pnpm/crates/cli/src/cli_args/publish/recursive.rs index 8297365bd2..fd99f9a291 100644 --- a/pnpm/crates/cli/src/cli_args/publish/recursive.rs +++ b/pnpm/crates/cli/src/cli_args/publish/recursive.rs @@ -169,9 +169,12 @@ async fn is_already_published( .get("publishConfig") .and_then(|publish_config| publish_config.get("registry")) .and_then(Value::as_str); - let Ok(registry) = - find_registry_info(name, &config.registry, &config.registries, publish_config_registry) - else { + let Ok(registry) = find_registry_info( + name, + &config.registry, + &config.registries_by_scope, + publish_config_registry, + ) else { return false; }; let outcome = fetch_full_metadata( diff --git a/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs index a5f62da097..6a27895289 100644 --- a/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs +++ b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs @@ -341,7 +341,7 @@ mod tests; /// [`crate::config_deps`]'s `for_package_manager` context. fn apply_package_manager_bootstrap(cfg: &mut Config, bootstrap: &PackageManagerBootstrap) { cfg.registry.clone_from(&bootstrap.registry); - cfg.registries.clone_from(&bootstrap.registries); + cfg.registries_by_scope.clone_from(&bootstrap.registries); cfg.proxy.clone_from(&bootstrap.proxy); cfg.tls.clone_from(&bootstrap.tls); cfg.tls_by_uri.clone_from(&bootstrap.tls_by_uri); diff --git a/pnpm/crates/cli/src/cli_args/why.rs b/pnpm/crates/cli/src/cli_args/why.rs index 806c541d5b..1538bb5259 100644 --- a/pnpm/crates/cli/src/cli_args/why.rs +++ b/pnpm/crates/cli/src/cli_args/why.rs @@ -108,9 +108,12 @@ impl WhyArgs { let loaded = LoadedState::load(&lockfile_dir, Some(state.config.modules_dir.as_path()), false)?; - let Some(env) = - loaded.env(&lockfile_dir, state.config.virtual_store_dir_max_length as usize) - else { + let Some(env) = loaded.env( + &lockfile_dir, + state.config.virtual_store_dir_max_length as usize, + &state.config.resolved_registries(), + state.config.registry_options_by_url.clone(), + ) else { return Ok(()); }; let lockfile = env.current_lockfile; diff --git a/pnpm/crates/cli/src/config_deps.rs b/pnpm/crates/cli/src/config_deps.rs index 5fa40fb8db..7832f85989 100644 --- a/pnpm/crates/cli/src/config_deps.rs +++ b/pnpm/crates/cli/src/config_deps.rs @@ -347,7 +347,7 @@ impl EnvInstallerContext { }; let resolver = NpmResolver { registries: registries.clone(), - named_registries: HashMap::new(), + registries_by_prefix: HashMap::new(), http_client: Arc::clone(&http_client), auth_headers: Arc::clone(&auth_headers), meta_cache: Arc::new(InMemoryPackageMetaCache::default()), diff --git a/pnpm/crates/cli/src/config_overrides.rs b/pnpm/crates/cli/src/config_overrides.rs index 5f1977358d..5db0e13113 100644 --- a/pnpm/crates/cli/src/config_overrides.rs +++ b/pnpm/crates/cli/src/config_overrides.rs @@ -197,7 +197,7 @@ impl ConfigOverrides { config.scope = Some(scope.clone()); } for (scope, registry) in &self.registries { - config.registries.insert(scope.clone(), registry.clone()); + config.registries_by_scope.insert(scope.clone(), registry.clone()); config.package_manager_bootstrap.registries.insert(scope.clone(), registry.clone()); } if let Some(value) = self.deploy_all_files { @@ -296,7 +296,7 @@ fn scoped_registry_key(key: &str) -> Option<&str> { pub(crate) fn apply_registry_override(config: &mut Config, registry: &str) { let registry = normalize_registry_url(registry); config.registry.clone_from(®istry); - config.registries.insert("default".to_string(), registry.clone()); + config.registries_by_scope.insert("default".to_string(), registry.clone()); config.package_manager_bootstrap.registry.clone_from(®istry); config.package_manager_bootstrap.registries.insert("default".to_string(), registry); } diff --git a/pnpm/crates/cli/src/config_overrides/tests.rs b/pnpm/crates/cli/src/config_overrides/tests.rs index f055d7938c..ad986adf73 100644 --- a/pnpm/crates/cli/src/config_overrides/tests.rs +++ b/pnpm/crates/cli/src/config_overrides/tests.rs @@ -23,7 +23,10 @@ fn extract_separates_config_tokens_from_argv() { overrides.apply(&mut config); assert_eq!(config.registry, "https://example.test/"); assert_eq!(config.package_manager_bootstrap.registry, "https://example.test/"); - assert_eq!(config.registries.get("default").map(String::as_str), Some("https://example.test/")); + assert_eq!( + config.registries_by_scope.get("default").map(String::as_str), + Some("https://example.test/"), + ); assert_eq!( config.package_manager_bootstrap.registries.get("default").map(String::as_str), Some("https://example.test/"), @@ -97,7 +100,10 @@ fn registry_cli_override_normalizes_and_sets_every_registry_slot() { // No trailing slash on the input; it is normalized on the way in. apply_registry_override(&mut config, "https://cli.example"); assert_eq!(config.registry, "https://cli.example/"); - assert_eq!(config.registries.get("default").map(String::as_str), Some("https://cli.example/")); + assert_eq!( + config.registries_by_scope.get("default").map(String::as_str), + Some("https://cli.example/"), + ); assert_eq!(config.package_manager_bootstrap.registry, "https://cli.example/"); assert_eq!( config.package_manager_bootstrap.registries.get("default").map(String::as_str), @@ -116,7 +122,7 @@ fn extract_applies_scoped_registry_overrides() { let mut config = Config::default(); overrides.apply(&mut config); assert_eq!( - config.registries.get("@private").map(String::as_str), + config.registries_by_scope.get("@private").map(String::as_str), Some("https://private.example/npm/"), ); assert_eq!( @@ -130,14 +136,16 @@ fn scoped_registry_override_wins_over_existing_config() { let (overrides, _) = ConfigOverrides::extract(argv(["--config.@private:registry=https://cli.example/npm/"])); let mut config = Config::default(); - config.registries.insert("@private".to_string(), "https://workspace.example/npm/".to_string()); + config + .registries_by_scope + .insert("@private".to_string(), "https://workspace.example/npm/".to_string()); config .package_manager_bootstrap .registries .insert("@private".to_string(), "https://json-env.example/npm/".to_string()); overrides.apply(&mut config); assert_eq!( - config.registries.get("@private").map(String::as_str), + config.registries_by_scope.get("@private").map(String::as_str), Some("https://cli.example/npm/"), ); assert_eq!( diff --git a/pnpm/crates/config/Cargo.toml b/pnpm/crates/config/Cargo.toml index c1a6c63dc1..f2a8b47150 100644 --- a/pnpm/crates/config/Cargo.toml +++ b/pnpm/crates/config/Cargo.toml @@ -16,6 +16,7 @@ pnpm-config-dir = { workspace = true } pnpm-detect-libc = { workspace = true } pnpm-env-replace = { workspace = true } pnpm-fs = { workspace = true } +pnpm-lockfile = { workspace = true } pnpm-network = { workspace = true } pnpm-package-is-installable = { workspace = true } pnpm-package-manifest = { workspace = true } diff --git a/pnpm/crates/config/src/lib.rs b/pnpm/crates/config/src/lib.rs index eff8103847..569257e35a 100644 --- a/pnpm/crates/config/src/lib.rs +++ b/pnpm/crates/config/src/lib.rs @@ -23,6 +23,7 @@ pub use crate::{ use crate::npmrc_auth::NpmrcAuth; use indexmap::IndexMap; use pipe_trait::Pipe; +use pnpm_lockfile::RegistryOptions; use pnpm_patching::{ CalcPatchHashError, PatchGroupRecord, ResolvePatchedDependenciesError, calc_patch_hashes, resolve_and_group, @@ -54,7 +55,9 @@ use crate::defaults::{ pub use workspace_yaml::{ AllowBuild, AuditSettings, GLOBAL_CONFIG_YAML_FILENAME, LoadWorkspaceYamlError, PackageExtension, PeerDependencyMeta, PeerDependencyRules, UpdateConfig, UpdateSettings, - WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings, decided_allow_builds, workspace_root_or, + WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings, decided_allow_builds, + registries::{self, RegistryDeclaration, RegistryEntry, RegistryLookups}, + workspace_root_or, }; fn default_ci(detect_ci: fn() -> bool) -> bool { @@ -1142,9 +1145,9 @@ pub struct Config { pub scope: Option, /// Scoped registry routes keyed by `@scope`, populated from - /// `.npmrc` `@scope:registry=...` and - /// `pnpm-workspace.yaml#registries`. - pub registries: BTreeMap, + /// `.npmrc` `@scope:registry=...` and the scopes a + /// `pnpm-workspace.yaml#registries` entry declares. + pub registries_by_scope: BTreeMap, /// User-defined named-registry aliases from /// `pnpm-workspace.yaml#namedRegistries`. Maps each alias name @@ -1154,8 +1157,18 @@ pub struct Config { /// GitHub Packages) and rejects malformed URLs at construction /// time with `ERR_PNPM_INVALID_NAMED_REGISTRY_URL`. /// - /// The `namedRegistries` setting. - pub named_registries: BTreeMap, + /// The `prefix` a `registries` entry declares, or the deprecated + /// `namedRegistries` setting. + pub registries_by_prefix: BTreeMap, + + /// Non-secret per-registry settings from + /// `pnpm-workspace.yaml#registries`, keyed by registry URL with a + /// trailing slash. Deliberately separate from the auth config: that one + /// carries credentials, and the install and lockfile layers that need a + /// registry's tarball layout must not be handed its secrets. + /// + /// The `registries` setting. + pub registry_options_by_url: BTreeMap, /// Resolved proxy configuration — `https-proxy`, `http-proxy`, and /// `no-proxy` (plus the legacy `proxy` key and env-var fallbacks), @@ -2081,6 +2094,21 @@ impl Config { Self::default() } + /// The registries this config declares, in the shape the `registries` + /// setting is written in — what a pnpr server is told about them. + /// + /// The default registry is not among them: it travels as the request's + /// own `registry` field. + #[must_use] + pub fn registry_declarations(&self) -> BTreeMap { + registries::to_declarations(&RegistryLookups { + registries_by_scope: self.registries_by_scope.clone(), + default_registry: None, + registries_by_prefix: self.registries_by_prefix.clone(), + registry_options_by_url: self.registry_options_by_url.clone(), + }) + } + /// Overlay the CLI's proxy flags onto the merged keys and re-resolve. /// /// Only the empty string reads as unset here. A flag carries its value @@ -2172,7 +2200,7 @@ impl Config { /// configured scoped routes keyed by `@scope`. #[must_use] pub fn resolved_registries(&self) -> BTreeMap { - let mut registries = self.registries.clone(); + let mut registries = self.registries_by_scope.clone(); registries.insert("default".to_string(), self.registry.clone()); registries } @@ -2833,7 +2861,7 @@ impl Config { if let Some(registry) = env_registry_override { let normalized = if registry.ends_with('/') { registry } else { format!("{registry}/") }; - self.registries.insert("default".to_string(), normalized.clone()); + self.registries_by_scope.insert("default".to_string(), normalized.clone()); self.package_manager_bootstrap.registry.clone_from(&normalized); self.package_manager_bootstrap.registries.insert("default".to_string(), normalized); } @@ -2991,7 +3019,7 @@ fn build_package_manager_bootstrap( trusted_auth.build_auth_headers(&mut config)?; Ok(PackageManagerBootstrap { registry: config.registry, - registries: config.registries, + registries: config.registries_by_scope, proxy: config.proxy, proxy_keys: config.proxy_keys, tls: config.tls, diff --git a/pnpm/crates/config/src/npmrc_auth.rs b/pnpm/crates/config/src/npmrc_auth.rs index 7aeb631151..2a7b9f2e2e 100644 --- a/pnpm/crates/config/src/npmrc_auth.rs +++ b/pnpm/crates/config/src/npmrc_auth.rs @@ -555,7 +555,7 @@ impl NpmrcAuth { config.registry = if registry.ends_with('/') { registry } else { format!("{registry}/") }; } - config.registries.append(&mut self.scoped_registries); + config.registries_by_scope.append(&mut self.scoped_registries); for message in std::mem::take(&mut self.warnings) { tracing::warn!(target: "pacquet::npmrc", "{message}"); } @@ -570,7 +570,7 @@ impl NpmrcAuth { if scope == "default" { config.registry.clone_from(&url); } - config.registries.insert(scope, url); + config.registries_by_scope.insert(scope, url); } } diff --git a/pnpm/crates/config/src/npmrc_auth/tests.rs b/pnpm/crates/config/src/npmrc_auth/tests.rs index f75bbbd709..34f079a735 100644 --- a/pnpm/crates/config/src/npmrc_auth/tests.rs +++ b/pnpm/crates/config/src/npmrc_auth/tests.rs @@ -94,7 +94,7 @@ fn parses_scoped_registry_and_applies() { let mut config = Config::new(); auth.apply_to::(&mut config); assert_eq!( - config.registries.get("@private").map(String::as_str), + config.registries_by_scope.get("@private").map(String::as_str), Some("https://private.example/npm/"), ); } diff --git a/pnpm/crates/config/src/tests.rs b/pnpm/crates/config/src/tests.rs index efff8663c4..e06bf70e71 100644 --- a/pnpm/crates/config/src/tests.rs +++ b/pnpm/crates/config/src/tests.rs @@ -496,7 +496,7 @@ namedRegistries: assert_eq!(config.registry, "https://trusted.example.com/npm/"); assert_eq!(config.pnpr_server.as_deref(), Some("https://trusted.example.com/pnpr/")); assert_eq!( - config.named_registries.get("work").map(String::as_str), + config.registries_by_prefix.get("work").map(String::as_str), Some("https://trusted.example.com/work/"), ); } @@ -700,7 +700,7 @@ pub fn json_env_default_scope_routes_default_registry() { assert_eq!(config.registry, "https://my-npm-proxy.example/"); assert_eq!( - config.registries.get("default").map(String::as_str), + config.registries_by_scope.get("default").map(String::as_str), Some("https://my-npm-proxy.example/"), ); assert_eq!( @@ -723,7 +723,7 @@ pub fn json_env_scoped_entry_routes_that_scope() { let config = load_with_fake_env(project.path()); assert_eq!( - config.registries.get("@org").map(String::as_str), + config.registries_by_scope.get("@org").map(String::as_str), Some("https://npm.pkg.github.com/"), ); } @@ -749,7 +749,7 @@ pub fn json_env_env_default_wins_over_workspace_yaml_default() { assert_eq!(config.registry, "https://my-npm-proxy.example/"); assert_eq!( - config.registries.get("default").map(String::as_str), + config.registries_by_scope.get("default").map(String::as_str), Some("https://my-npm-proxy.example/"), ); } @@ -783,7 +783,7 @@ pub fn global_config_yaml_registries_cannot_redirect_json_env_token() { let config = load_with_fake_env(project.path()); assert_eq!( - config.registries.get("@victim-scope").map(String::as_str), + config.registries_by_scope.get("@victim-scope").map(String::as_str), Some("https://npm.pkg.github.com/"), ); assert_eq!( @@ -833,7 +833,7 @@ pub fn global_config_yaml_auth_configures_registry_auth() { ); assert_eq!(config.registry, "https://global-auth.example.com/"); assert_eq!( - config.registries.get("@org").map(String::as_str), + config.registries_by_scope.get("@org").map(String::as_str), Some("https://global-auth.example.com/"), ); } @@ -905,7 +905,7 @@ pub fn json_env_env_registry_flag_wins_over_json_env_default() { assert_eq!(config.registry, "https://cli-registry.example/"); assert_eq!( - config.registries.get("default").map(String::as_str), + config.registries_by_scope.get("default").map(String::as_str), Some("https://cli-registry.example/"), ); assert_eq!( @@ -958,7 +958,7 @@ pub fn json_env_inferred_registries_flow_to_bootstrap() { /// End-to-end: a scoped env JSON entry overrides a repo-controlled /// `pnpm-workspace.yaml` scoped registry in the main cascade, and the /// token is pinned to the env-declared host. Asserts -/// `config.registries["@scope"]` — not just auth headers — so a regression +/// `config.registries_by_scope["@scope"]` — not just auth headers — so a regression /// that breaks routing while leaving auth-header pinning intact is caught. #[test] pub fn json_env_env_scoped_wins_over_workspace_yaml_scoped() { @@ -976,7 +976,7 @@ pub fn json_env_env_scoped_wins_over_workspace_yaml_scoped() { let config = load_with_fake_env(project.path()); assert_eq!( - config.registries.get("@victim-scope").map(String::as_str), + config.registries_by_scope.get("@victim-scope").map(String::as_str), Some("https://registry.npmjs.org/"), ); assert_eq!( @@ -1036,7 +1036,7 @@ pub fn json_env_overrides_user_bootstrap_scoped_registry() { Some("https://my-npm-proxy.example/"), ); assert_eq!( - config.registries.get("@org").map(String::as_str), + config.registries_by_scope.get("@org").map(String::as_str), Some("https://my-npm-proxy.example/"), ); } @@ -3253,7 +3253,7 @@ pub fn package_manager_bootstrap_ignores_workspace_yaml_registries() { "workspace yaml drives normal installs", ); assert_eq!( - config.registries.get("@evil").map(String::as_str), + config.registries_by_scope.get("@evil").map(String::as_str), Some("https://attacker-scoped.example.com/"), ); assert_eq!( @@ -3310,7 +3310,7 @@ pub fn package_manager_bootstrap_honors_env_registry() { /// When `PNPM_CONFIG_REGISTRY` is set without a trailing slash, the env /// override normalizes it before storing — matching pnpm, which treats /// `https://r` and `https://r/` as the same registry. The slash must be -/// appended consistently to `config.registry`, `config.registries`, and +/// appended consistently to `config.registry`, `config.registries_by_scope`, and /// the bootstrap map so downstream lookups (auth-header pinning, /// `package_manager_bootstrap`) all key against the normalized form. #[test] @@ -3323,7 +3323,7 @@ pub fn env_registry_override_appends_missing_trailing_slash() { assert_eq!(config.registry, "https://env.example.com/"); assert_eq!( - config.registries.get("default").map(String::as_str), + config.registries_by_scope.get("default").map(String::as_str), Some("https://env.example.com/"), ); assert_eq!( diff --git a/pnpm/crates/config/src/workspace_yaml.rs b/pnpm/crates/config/src/workspace_yaml.rs index 4ccee5c486..5407f7cc56 100644 --- a/pnpm/crates/config/src/workspace_yaml.rs +++ b/pnpm/crates/config/src/workspace_yaml.rs @@ -11,9 +11,11 @@ use indexmap::IndexMap; use miette::Diagnostic; use pipe_trait::Pipe; use pnpm_env_replace::env_replace_lossy; +use pnpm_network::redact_and_sanitize; use pnpm_package_is_installable::SupportedArchitectures; use pnpm_store_dir::StoreDir; use pnpm_workspace_state::ConfigDependency; +use registries::RegistryEntry; use serde::{Deserialize, Deserializer}; use std::{ collections::{BTreeMap, BTreeSet, HashMap}, @@ -37,6 +39,62 @@ where Option::::deserialize(deserializer).map(Some) } +/// Whether the authority of `url` carries a `user:pass@` prefix. The authority +/// ends at the first `/`, `?`, or `#`, so a later `@` in the path is not one. +/// +/// Both the full form and the scheme-less `//host/` form count. The latter is +/// the shape `.npmrc` scopes settings with, so it is the one a user is most +/// likely to reach for here. +fn registry_url_has_userinfo(url: &str) -> bool { + userinfo_end(url).is_some() +} + +/// The offset just past the `user:pass@` of `url`, or [`None`] when its +/// authority carries none. Splitting it out keeps the detection and the +/// redaction below agreeing on what the authority is. +fn userinfo_end(url: &str) -> Option { + let authority_start = authority_start_of(url)?; + let authority = &url[authority_start..]; + let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len()); + authority[..authority_end].rfind('@').map(|at| authority_start + at + 1) +} + +/// Where the authority of `url` begins, or [`None`] if it has none. +/// +/// The scheme is anchored at the start rather than found by searching for the +/// first `://`: a `://` inside the path (`//host/a://b`) would otherwise be +/// taken for the separator, and the real authority — credentials and all — +/// would go unexamined. +fn authority_start_of(url: &str) -> Option { + if let Some(scheme_end) = url.find("://") { + let scheme = &url[..scheme_end]; + let mut chars = scheme.chars(); + let starts_with_letter = chars.next().is_some_and(|first| first.is_ascii_alphabetic()); + let rest_is_scheme = chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '.' | '-') + }); + if starts_with_letter && rest_is_scheme { + return Some(scheme_end + "://".len()); + } + } + url.starts_with("//").then_some("//".len()) +} + +/// `url` with any `user:pass@` removed, safe to put in a message. +/// +/// [`redact_and_sanitize`] only recognizes an authority after a `://`, and +/// deliberately so: it runs over arbitrary prose, where a bare `//` is more +/// often a comment or a path than a URL. Here the string is known to be a +/// registry URL, so the scheme-less `//host/` form can be handled too. +fn redact_registry_url(url: &str) -> String { + match (authority_start_of(url), userinfo_end(url)) { + (Some(authority_start), Some(userinfo_end)) => { + redact_and_sanitize(&format!("{}{}", &url[..authority_start], &url[userinfo_end..])) + } + _ => redact_and_sanitize(url), + } +} + /// The value of an `allowBuilds` entry. /// /// pnpm scaffolds an entry per ignored build with the placeholder string @@ -151,7 +209,10 @@ pub struct WorkspaceSettings { pub lockfile_include_tarball_url: Option, pub registry: Option, pub scope: Option, - pub registries: Option>, + /// The registries the project declares. Keyed by registry URL, with the + /// routes to each registry inside its entry; a map of plain strings is the + /// older `: ` shape and is read as one. + pub registries: Option>, pub pnpr_server: Option, pub https_proxy: Option, pub http_proxy: Option, @@ -163,6 +224,10 @@ pub struct WorkspaceSettings { /// name (`gh`, `work`, ...); inner string is the registry URL the /// alias resolves against. Merged on top of pnpm's built-in /// defaults at resolver construction. + /// + /// Deprecated in favor of the `prefix` field of a + /// [`crate::RegistryDeclaration`], + /// and only read for the prefixes `registries` does not declare. pub named_registries: Option>, /// Structured registry auth (`_auth`). Honored **only** from the global @@ -745,6 +810,60 @@ pub enum LoadWorkspaceYamlError { #[error(source)] source: Box, }, + /// The registry URL is redacted before it reaches this variant. + #[display("The \"registries\" key {registry} embeds credentials")] + #[diagnostic( + code(ERR_PNPM_INVALID_SETTING), + help("Put them in an .npmrc file instead, so they are not committed.") + )] + CredentialsInRegistryKey { registry: String }, + /// The registry URL is redacted before it reaches this variant. + #[display( + r#"The "registries[{registry:?}].{field}" setting is not allowed in pnpm-workspace.yaml"# + )] + #[diagnostic( + code(ERR_PNPM_INVALID_SETTING), + help("Set it in an .npmrc file instead, so it is not committed.") + )] + SecretInRegistryDeclaration { registry: String, field: String }, + /// The registry URL is redacted before it reaches this variant. + #[display(r#"The "registries[{registry:?}].{field}" setting is not a known registry setting"#)] + #[diagnostic( + code(ERR_PNPM_INVALID_SETTING), + help(r#"A registry declares "serverType", "scopes", and "prefix"."#) + )] + UnknownRegistryDeclarationField { registry: String, field: String }, + #[display(r#"The "registries" setting mixes registry declarations with ": " entries ({scopes})"#)] + #[diagnostic( + code(ERR_PNPM_INVALID_SETTING), + help( + r#"Key every entry by registry URL and list the scopes routed to it under "scopes"."# + ) + )] + MixedRegistriesShapes { scopes: String }, + /// The registry URL is redacted before it reaches this variant. + #[display(r#"The "registries[{registry:?}]" entry is a string"#)] + #[diagnostic( + code(ERR_PNPM_INVALID_SETTING), + help( + r#"A registry URL keys a declaration, e.g. {{ serverType: "artifactory" }}. A string value routes a scope, and a URL is not a scope."# + ) + )] + StringValuedRegistryDeclaration { registry: String }, + /// The registry URL is redacted before it reaches this variant. + #[display(r#"The "registries[{registry:?}].scopes" setting should list "@"-prefixed scopes, but got {scope:?}"#)] + #[diagnostic( + code(ERR_PNPM_INVALID_SETTING), + help(r#"A bare "@" is the scope-less default registry."#) + )] + RegistryScopeWithoutAtSign { registry: String, scope: String }, + /// The registry URLs are redacted before they reach this variant. + #[display("The scope {scope:?} is routed to two registries: {registries}")] + #[diagnostic(code(ERR_PNPM_INVALID_SETTING))] + ScopeRoutedTwice { scope: String, registries: String }, + #[display("The prefix {prefix:?} is declared by two registries")] + #[diagnostic(code(ERR_PNPM_INVALID_SETTING))] + PrefixDeclaredTwice { prefix: String }, #[display("Invalid `_auth` setting: {source}")] InvalidJsonAuth { #[error(source)] @@ -810,10 +929,23 @@ impl WorkspaceSettings { let mut settings: WorkspaceSettings = serde_saphyr::from_str(&text) .map_err(Box::new) .map_err(|source| LoadWorkspaceYamlError::ParseYaml { path, source })?; + settings.validate_registries()?; settings.clear_workspace_only_fields(); Ok(Some(settings)) } + /// Reject a `registries` map pnpm would read as something other than what + /// it says. See [`registries::validate`] for the rules. + /// + /// Checked after parsing rather than in a `Deserialize` impl on purpose: + /// `serde_saphyr` renders the offending source line verbatim under its + /// errors, so rejecting at parse time would print the very credential + /// being rejected into the terminal and any CI log. + fn validate_registries(&self) -> Result<(), LoadWorkspaceYamlError> { + let Some(entries) = self.registries.as_ref() else { return Ok(()) }; + registries::validate(entries) + } + /// Zero out the release-age and trust policies for `self-update`. /// /// `self-update` replaces the pnpm binary every later install runs @@ -849,6 +981,16 @@ impl WorkspaceSettings { /// hoisted` in `~/.config/pnpm/config.yaml` and pacquet would /// honor it while pnpm wouldn't — anti-parity. pub fn clear_workspace_only_fields(&mut self) { + // Only the layout half of a registry declaration is workspace-only: it + // decides which tarball URLs are omitted from the lockfile, so a + // machine-local setting would make one developer write a lockfile + // their collaborators read back with a different layout. The routes to + // the registry are a legitimate global preference. + for entry in self.registries.iter_mut().flat_map(BTreeMap::values_mut) { + if let RegistryEntry::Declaration(declaration) = entry { + declaration.server_type = None; + } + } self.versioning = None; self.hoist = None; self.hoist_pattern = None; @@ -909,10 +1051,11 @@ impl WorkspaceSettings { Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), Err(source) => return Err(LoadWorkspaceYamlError::ReadFile { path, source }), }; - let settings = text + let settings: WorkspaceSettings = text .pipe_as_ref(serde_saphyr::from_str) .map_err(Box::new) .map_err(|source| LoadWorkspaceYamlError::ParseYaml { path, source })?; + settings.validate_registries()?; Ok(Some(settings)) } @@ -943,7 +1086,7 @@ impl WorkspaceSettings { substitute_optional_string::(&mut self.proxy); substitute_json_string::(&mut self.no_proxy); substitute_json_string::(&mut self.noproxy); - substitute_optional_string_map::(&mut self.registries); + substitute_registry_entries::(&mut self.registries); substitute_optional_string_map::(&mut self.named_registries); } @@ -963,11 +1106,12 @@ impl WorkspaceSettings { self.registry = None; } if let Some(registries) = self.registries.as_mut() { - registries.retain(|_, value| !has_env_placeholder(value)); + registries::retain_without_env_placeholders(registries, has_env_placeholder); } if let Some(named_registries) = self.named_registries.as_mut() { named_registries.retain(|_, value| !has_env_placeholder(value)); } + if self.pnpr_server.as_deref().is_some_and(has_env_placeholder) { self.pnpr_server = None; } @@ -1134,15 +1278,16 @@ impl WorkspaceSettings { if let Some(v) = self.store_dir { config.store_dir = StoreDir::from(resolve(base_dir, &v)); } - if let Some(registries) = self.registries { - for (scope, registry) in registries { - let registry = normalize_registry_url(®istry); - if scope == "default" { - config.registry = registry; - } else { - config.registries.insert(scope, registry); - } + let mut declared_prefixes = false; + if let Some(entries) = self.registries { + let lookups = registries::into_lookups(entries); + declared_prefixes = !lookups.registries_by_prefix.is_empty(); + if let Some(registry) = lookups.default_registry { + config.registry = registry; } + config.registries_by_scope.extend(lookups.registries_by_scope); + config.registries_by_prefix.extend(lookups.registries_by_prefix); + config.registry_options_by_url.extend(lookups.registry_options_by_url); } if let Some(v) = self.registry { config.registry = normalize_registry_url(&v); @@ -1154,7 +1299,17 @@ impl WorkspaceSettings { config.pnpr_server = Some(v); } if let Some(v) = self.named_registries { - config.named_registries = v; + if declared_prefixes { + tracing::warn!( + target: "pacquet::config", + r#"Both the "registries" and "namedRegistries" settings declare registry prefixes. The deprecated "namedRegistries" setting is only read for prefixes "registries" does not declare."#, + ); + } + // A prefix a `registries` entry declares wins: this is the + // deprecated spelling of the same thing. + for (name, registry) in v { + config.registries_by_prefix.entry(name).or_insert(registry); + } } // Anchor patch-file path resolution against the workspace dir @@ -1374,6 +1529,26 @@ fn substitute_optional_string_map(value: &mut Option(value: &mut Option>) { + let Some(map) = value.take() else { return }; + *value = Some( + map.into_iter() + .map(|(key, entry)| match entry { + RegistryEntry::ScopeRoute(url) => { + let (substituted, _) = env_replace_lossy::(&url); + (key, RegistryEntry::ScopeRoute(substituted)) + } + RegistryEntry::Declaration(declaration) => { + let (substituted, _) = env_replace_lossy::(&key); + (substituted, RegistryEntry::Declaration(declaration)) + } + }) + .collect(), + ); +} + fn substitute_optional_inner_string(value: &mut Option>) { if let Some(Some(value)) = value { let (substituted, _) = env_replace_lossy::(value); @@ -1413,5 +1588,7 @@ pub fn workspace_root_or(start: &Path) -> PathBuf { .unwrap_or_else(|| start.to_path_buf()) } +pub mod registries; + #[cfg(test)] mod tests; diff --git a/pnpm/crates/config/src/workspace_yaml/registries.rs b/pnpm/crates/config/src/workspace_yaml/registries.rs new file mode 100644 index 0000000000..6bc8da0664 --- /dev/null +++ b/pnpm/crates/config/src/workspace_yaml/registries.rs @@ -0,0 +1,328 @@ +//! The `registries` setting: one entry per registry, keyed by its URL. +//! +//! Every fact in an entry is a fact about that server — how it lays out +//! tarball URLs, and which routes reach it — so a registry is declared once +//! and each fact is stated in one place. [`RegistryLookups`] is the inverse of +//! the routes, because a scope resolves to exactly one registry while a +//! registry serves many. + +use super::LoadWorkspaceYamlError; +use crate::workspace_yaml::{ + normalize_registry_url, redact_registry_url, registry_url_has_userinfo, +}; +use pnpm_lockfile::{RegistryOptions, RegistryServerType}; +use serde::{ + Deserialize, Deserializer, + de::{MapAccess, Visitor, value::MapAccessDeserializer}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; + +/// The bare scope standing for the registry that packages resolve from when +/// no scope matches — the one the `registry` setting names. +pub const DEFAULT_REGISTRY_SCOPE: &str = "@"; + +/// Credentials and TLS material stay in `.npmrc`, which is not committed. +/// `registries` lives in `pnpm-workspace.yaml`, which is, so accepting these +/// would invite secrets into version control. Rejecting is better than +/// ignoring: a silently dropped `_authToken` reads as configured. +const SECRET_REGISTRY_FIELDS: &[&str] = &[ + "_auth", + "_authToken", + "_password", + "username", + "tokenHelper", + "ca", + "cafile", + "cert", + "certfile", + "key", + "keyfile", +]; + +/// One entry of the `registries` map. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(untagged)] +pub enum RegistryEntry { + /// The older `: ` shape, which is the scope lookup already. + ScopeRoute(String), + Declaration(RegistryDeclaration), +} + +/// Everything a project declares about one registry. +/// +/// Unknown fields are captured rather than refused by serde on purpose, for +/// the same reason this module validates after parsing: a parse error renders +/// the offending source line verbatim, so refusing an `_authToken` field at +/// parse time would print the very credential being refused. +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryDeclaration { + #[serde(default)] + pub server_type: Option, + /// The scopes routed here, `@`-prefixed. A bare `@` is the scope-less + /// default registry, the one the `registry` setting names. + #[serde(default)] + pub scopes: Option>, + /// The bare-specifier prefix this registry answers to, as in + /// `"foo": "work:^1.0.0"`. + #[serde(default)] + pub prefix: Option, + #[serde(flatten)] + pub unknown: BTreeMap, +} + +impl Eq for RegistryDeclaration {} + +impl<'de> Deserialize<'de> for RegistryEntry { + fn deserialize>(deserializer: Deser) -> Result { + deserializer.deserialize_any(RegistryEntryVisitor) + } +} + +/// Written out rather than derived as `#[serde(untagged)]` so that the error +/// from a malformed declaration survives: an untagged enum reports only that +/// no variant matched, which would hide, say, a `scopes` that is not a list. +struct RegistryEntryVisitor; + +impl<'de> Visitor<'de> for RegistryEntryVisitor { + type Value = RegistryEntry; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a registry URL or a registry declaration") + } + + fn visit_str(self, value: &str) -> Result { + Ok(RegistryEntry::ScopeRoute(value.to_owned())) + } + + fn visit_map>(self, map: Map) -> Result { + RegistryDeclaration::deserialize(MapAccessDeserializer::new(map)) + .map(RegistryEntry::Declaration) + } +} + +/// The three lookups the rest of pnpm reads, split out of the declarations. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RegistryLookups { + /// Scope-routed URLs, `@`-prefixed and normalized. + pub registries_by_scope: BTreeMap, + /// The registry a bare `@` routed to, if any. + pub default_registry: Option, + /// Prefix-addressed URLs, deliberately kept as written: a named + /// registry's URL is what a lockfile's recorded tarball URLs are matched + /// against, so normalizing it would change what an existing lockfile + /// verifies against. + pub registries_by_prefix: BTreeMap, + pub registry_options_by_url: BTreeMap, +} + +/// Reject a `registries` map pnpm would otherwise read as something other +/// than what it says. +pub fn validate(entries: &BTreeMap) -> Result<(), LoadWorkspaceYamlError> { + let scope_routes: Vec<&String> = entries + .iter() + .filter(|(_, entry)| matches!(entry, RegistryEntry::ScopeRoute(_))) + .map(|(scope, _)| scope) + .collect(); + if scope_routes.len() == entries.len() { + for scope in scope_routes { + // A scope routes to a registry, so a URL in that position routes + // nothing and would sit there inert. It is the declaration shape, + // half-written. + if looks_like_registry_url(scope) { + return Err(LoadWorkspaceYamlError::StringValuedRegistryDeclaration { + registry: redact_registry_url(scope), + }); + } + } + return Ok(()); + } + if !scope_routes.is_empty() { + return Err(LoadWorkspaceYamlError::MixedRegistriesShapes { + scopes: quote_and_join(scope_routes.into_iter().map(String::as_str)), + }); + } + + validate_declarations(entries.iter().filter_map(|(registry, entry)| match entry { + RegistryEntry::Declaration(declaration) => Some((registry, declaration)), + RegistryEntry::ScopeRoute(_) => None, + })) +} + +/// The per-declaration half of [`validate`], over declarations alone. +/// +/// A pnpr request carries this shape and no other: its `registries` map is +/// always keyed by URL, which is what lets the server's boundary checks read +/// a key as a fetch target. +pub fn validate_declarations<'a>( + entries: impl IntoIterator, +) -> Result<(), LoadWorkspaceYamlError> { + let mut routed_scopes: BTreeMap<&str, String> = BTreeMap::new(); + let mut declared_prefixes: BTreeSet<&str> = BTreeSet::new(); + for (registry, declaration) in entries { + let redacted = redact_registry_url(registry); + if let Some(field) = declaration + .unknown + .keys() + .find(|field| SECRET_REGISTRY_FIELDS.contains(&field.as_str())) + { + return Err(LoadWorkspaceYamlError::SecretInRegistryDeclaration { + registry: redacted, + field: field.clone(), + }); + } + if let Some(field) = declaration.unknown.keys().next() { + return Err(LoadWorkspaceYamlError::UnknownRegistryDeclarationField { + registry: redacted, + field: field.clone(), + }); + } + // The map lives in the committed pnpm-workspace.yaml, and it already + // refuses credential fields for that reason; a credential in the key + // is the same secret in the same file. + if registry_url_has_userinfo(registry) { + return Err(LoadWorkspaceYamlError::CredentialsInRegistryKey { registry: redacted }); + } + let normalized = normalize_registry_url(registry); + for scope in declaration.scopes.iter().flatten() { + if !scope.starts_with(DEFAULT_REGISTRY_SCOPE) { + return Err(LoadWorkspaceYamlError::RegistryScopeWithoutAtSign { + registry: redacted, + scope: scope.clone(), + }); + } + if let Some(other) = routed_scopes.get(scope.as_str()) + && other != &normalized + { + return Err(LoadWorkspaceYamlError::ScopeRoutedTwice { + scope: scope.clone(), + registries: quote_and_join([other.as_str(), normalized.as_str()]), + }); + } + routed_scopes.insert(scope, normalized.clone()); + } + if let Some(prefix) = declaration.prefix.as_deref() + && !declared_prefixes.insert(prefix) + { + return Err(LoadWorkspaceYamlError::PrefixDeclaredTwice { prefix: prefix.to_owned() }); + } + } + Ok(()) +} + +/// Split validated declarations into the lookups. Infallible: every rejection +/// happens in [`validate`], at load time, where the offending file is known. +#[must_use] +pub fn into_lookups(entries: BTreeMap) -> RegistryLookups { + let mut lookups = RegistryLookups::default(); + let mut declarations = BTreeMap::new(); + for (registry, entry) in entries { + match entry { + RegistryEntry::ScopeRoute(url) => { + let url = normalize_registry_url(&url); + if registry == "default" { + lookups.default_registry = Some(url); + } else { + lookups.registries_by_scope.insert(registry, url); + } + } + RegistryEntry::Declaration(declaration) => { + declarations.insert(registry, declaration); + } + } + } + extend_lookups_with_declarations(&mut lookups, declarations); + lookups +} + +/// The declarations-only half of [`into_lookups`], for a pnpr request, whose +/// `registries` map is always keyed by URL. +#[must_use] +pub fn declarations_into_lookups( + entries: BTreeMap, +) -> RegistryLookups { + let mut lookups = RegistryLookups::default(); + extend_lookups_with_declarations(&mut lookups, entries); + lookups +} + +fn extend_lookups_with_declarations( + lookups: &mut RegistryLookups, + entries: BTreeMap, +) { + for (registry, declaration) in entries { + let normalized = normalize_registry_url(®istry); + if let Some(server_type) = declaration.server_type { + lookups + .registry_options_by_url + .insert(normalized.clone(), RegistryOptions { server_type: Some(server_type) }); + } + for scope in declaration.scopes.into_iter().flatten() { + if scope == DEFAULT_REGISTRY_SCOPE { + lookups.default_registry = Some(normalized.clone()); + } else { + lookups.registries_by_scope.insert(scope, normalized.clone()); + } + } + if let Some(prefix) = declaration.prefix { + lookups.registries_by_prefix.insert(prefix, registry); + } + } +} + +/// Rebuild the declarations from the lookups they were split into, for a +/// client that has to describe its registries to a pnpr server. +/// +/// The inverse of [`into_lookups`], minus the default registry: that one +/// travels as the request's own `registry` field, so a bare +/// [`DEFAULT_REGISTRY_SCOPE`] is not re-emitted here. +/// +/// Entries are keyed by the URL each lookup holds rather than by a normalized +/// one, so a registry a prefix addresses without a trailing slash stays the +/// URL the client resolves against. +#[must_use] +pub fn to_declarations(lookups: &RegistryLookups) -> BTreeMap { + let mut declarations: BTreeMap = BTreeMap::new(); + for (scope, registry) in &lookups.registries_by_scope { + if scope == "default" { + continue; + } + declarations + .entry(registry.clone()) + .or_default() + .scopes + .get_or_insert_with(Vec::new) + .push(scope.clone()); + } + for (prefix, registry) in &lookups.registries_by_prefix { + declarations.entry(registry.clone()).or_default().prefix = Some(prefix.clone()); + } + for (registry, options) in &lookups.registry_options_by_url { + declarations.entry(registry.clone()).or_default().server_type = options.server_type; + } + declarations +} + +/// Drop the entries whose request destination carries an unexpanded `${VAR}`. +/// The destination is the value of a scope route and the key of a +/// declaration, so which half is gated follows the entry's shape. +pub fn retain_without_env_placeholders( + entries: &mut BTreeMap, + has_env_placeholder: impl Fn(&str) -> bool, +) { + entries.retain(|registry, entry| match entry { + RegistryEntry::ScopeRoute(url) => !has_env_placeholder(url), + RegistryEntry::Declaration(_) => !has_env_placeholder(registry), + }); +} + +fn looks_like_registry_url(key: &str) -> bool { + key.contains("://") || key.starts_with("//") +} + +fn quote_and_join<'a>(values: impl IntoIterator) -> String { + values.into_iter().map(|value| format!("{value:?}")).collect::>().join(", ") +} diff --git a/pnpm/crates/config/src/workspace_yaml/tests.rs b/pnpm/crates/config/src/workspace_yaml/tests.rs index 77ff8cf323..f2ea5106e1 100644 --- a/pnpm/crates/config/src/workspace_yaml/tests.rs +++ b/pnpm/crates/config/src/workspace_yaml/tests.rs @@ -1,14 +1,18 @@ -use super::{AllowBuild, LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings}; +use super::{ + AllowBuild, LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings, + registries::{RegistryDeclaration, RegistryEntry}, +}; use crate::{ AuditLevel, CatalogMode, Config, GlobalShims, GlobalShimsSetting, HoistingLimits, LinkWorkspacePackages, NodeLinker, NodePackageMapType, ResolutionMode, ScriptsPrependNodePath, ShimPolicy, TrustPolicy, api::EnvVar, }; use pipe_trait::Pipe; +use pnpm_lockfile::{RegistryOptions, RegistryServerType}; use pnpm_store_dir::StoreDir; use pnpm_workspace_state::{ConfigDependency, ConfigDependencyDetail}; use pretty_assertions::assert_eq; -use std::{fs, path::Path}; +use std::{collections::BTreeMap, fs, path::Path}; #[test] fn parses_common_settings_from_yaml() { @@ -274,10 +278,9 @@ userAgent: my-agent/2.0 assert_eq!(config.user_agent, "my-agent/2.0"); } -/// `namedRegistries` is the per-alias registry-URL map from -/// `pnpm-workspace.yaml`. The deserializer reads the camelCase key -/// and `apply_to` writes the map onto [`Config::named_registries`] -/// verbatim. +/// `namedRegistries` is the deprecated spelling of a registry's `prefix`. The +/// deserializer reads the camelCase key it still carries, and `apply_to` +/// writes the map onto [`Config::registries_by_prefix`] verbatim. #[test] fn parses_named_registries_from_yaml_and_applies() { let yaml = r" @@ -286,18 +289,18 @@ namedRegistries: work: https://npm.work.example.com/ "; let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); - let named = settings.named_registries.as_ref().expect("named_registries present"); + let named = settings.named_registries.as_ref().expect("namedRegistries present"); assert_eq!(named.get("gh").map(String::as_str), Some("https://npm.pkg.ghes.example.com/")); assert_eq!(named.get("work").map(String::as_str), Some("https://npm.work.example.com/")); let mut config = Config::new(); settings.apply_to(&mut config, Path::new("/irrelevant")); assert_eq!( - config.named_registries.get("gh").map(String::as_str), + config.registries_by_prefix.get("gh").map(String::as_str), Some("https://npm.pkg.ghes.example.com/"), ); assert_eq!( - config.named_registries.get("work").map(String::as_str), + config.registries_by_prefix.get("work").map(String::as_str), Some("https://npm.work.example.com/"), ); } @@ -312,19 +315,19 @@ registries: let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); let registries = settings.registries.as_ref().expect("registries present"); assert_eq!( - registries.get("default").map(String::as_str), - Some("https://default.example.com/npm"), + registries.get("default"), + Some(&RegistryEntry::ScopeRoute("https://default.example.com/npm".to_owned())), ); assert_eq!( - registries.get("@private").map(String::as_str), - Some("https://private.example.com/npm"), + registries.get("@private"), + Some(&RegistryEntry::ScopeRoute("https://private.example.com/npm".to_owned())), ); let mut config = Config::new(); settings.apply_to(&mut config, Path::new("/irrelevant")); assert_eq!(config.registry, "https://default.example.com/npm/"); assert_eq!( - config.registries.get("@private").map(String::as_str), + config.registries_by_scope.get("@private").map(String::as_str), Some("https://private.example.com/npm/"), ); } @@ -365,19 +368,19 @@ namedRegistries: assert_eq!(config.registry, "https://registry.npmjs.org/"); assert_eq!(config.proxy, pnpm_network::ProxyConfig::default()); assert_eq!( - config.registries.get("@safe").map(String::as_str), + config.registries_by_scope.get("@safe").map(String::as_str), Some("https://safe.example.com/npm/"), ); - assert_eq!(config.registries.get("@work"), None); + assert_eq!(config.registries_by_scope.get("@work"), None); assert_eq!( - config.named_registries.get("stable").map(String::as_str), + config.registries_by_prefix.get("stable").map(String::as_str), Some("https://registry.example.com/npm/"), ); assert_eq!( - config.named_registries.get("literal").map(String::as_str), + config.registries_by_prefix.get("literal").map(String::as_str), Some("https://registry.example.com/${/npm/"), ); - assert_eq!(config.named_registries.get("work"), None); + assert_eq!(config.registries_by_prefix.get("work"), None); } #[test] @@ -449,11 +452,11 @@ namedRegistries: Some(pnpm_network::NoProxySetting::List(vec!["internal.example.com".to_string()])), ); assert_eq!( - config.named_registries.get("stable").map(String::as_str), + config.registries_by_prefix.get("stable").map(String::as_str), Some("https://registry.example.com/npm/"), ); assert_eq!( - config.named_registries.get("work").map(String::as_str), + config.registries_by_prefix.get("work").map(String::as_str), Some("https://internal.example.com/work/"), ); } @@ -1869,3 +1872,419 @@ fn parses_save_settings_from_yaml_and_applies() { assert_eq!(global.save_peer, None); assert_eq!(global.save_catalog_name, None); } + +/// `apply_to` keys the per-registry options by registry URL with a trailing +/// slash so a lookup by the registry a package resolved from matches. +#[test] +fn parses_registry_declarations_from_yaml_and_normalizes_the_keys() { + let yaml = r" +registries: + https://artifactory.example/artifactory/api/npm/npm-virtual: {serverType: artifactory} + https://npm.example.com/: {serverType: npm} +"; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let mut config = Config::new(); + settings.apply_to(&mut config, Path::new("/irrelevant")); + assert_eq!( + config + .registry_options_by_url + .get("https://artifactory.example/artifactory/api/npm/npm-virtual/") + .map(|options| options.server_type), + Some(Some(RegistryServerType::Artifactory)), + ); + assert_eq!( + config + .registry_options_by_url + .get("https://npm.example.com/") + .map(|options| options.server_type), + Some(Some(RegistryServerType::Npm)), + ); +} + +/// Credentials belong in `.npmrc`, which is not committed. Refused after +/// parsing, not by `deny_unknown_fields`: a parse error renders the offending +/// source line verbatim, which would print the very token being refused. +#[test] +fn rejects_credentials_in_a_registry_declaration() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.example.com/: {_authToken: hunter2}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("credentials in a declaration must not load") + .to_string(); + assert!(error.contains("_authToken"), "the field is named: {error}"); + assert!(!error.contains("hunter2"), "the token must not be echoed: {error}"); +} + +/// A misspelled field would otherwise sit there doing nothing. +#[test] +fn rejects_an_unknown_registry_declaration_field() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.example.com/: {scope: '@acme'}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("an unknown declaration field must not load") + .to_string(); + assert!(error.contains("scope"), "the field is named: {error}"); +} + +#[test] +fn rejects_an_unknown_registry_server_type() { + let yaml = r" +registries: + https://npm.example.com/: {serverType: nexus} +"; + let received = serde_saphyr::from_str::(yaml); + assert!(received.is_err(), "an unknown serverType must not parse"); +} + +/// The registry URL is the key here, so the untrusted-environment gate has to +/// drop the entry rather than expanding a placeholder into a request URL. The +/// variable resolves, so a dropped entry is distinguishable from one expanded +/// to an empty string — expanding it is how a token would reach an +/// attacker-chosen host from a committed pnpm-workspace.yaml. +#[test] +fn drops_a_registry_declaration_whose_url_has_an_env_placeholder() { + struct EnvWithToken; + impl EnvVar for EnvWithToken { + fn var(name: &str) -> Option { + (name == "PNPM_TEST_REGISTRY_TOKEN").then(|| "super-secret-token".to_owned()) + } + } + + let yaml = r" +registries: + https://evil.example.com/${PNPM_TEST_REGISTRY_TOKEN}/: {serverType: artifactory} + https://npm.example.com/: {serverType: artifactory} +"; + let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + settings.substitute_env_untrusted::(); + let entries = settings.registries.as_ref().expect("registries present"); + assert_eq!(entries.len(), 1); + assert!(entries.contains_key("https://npm.example.com/")); + assert!( + !entries.keys().any(|registry| registry.contains("super-secret-token")), + "the token must never be expanded into a registry URL: {entries:?}", + ); +} + +/// A declared `serverType` is workspace-only: it decides which tarball URLs +/// are omitted from the lockfile, so a user's global `config.yaml` must not +/// shape a lockfile their collaborators read back with a different layout. The +/// routes the same entry declares are a legitimate global preference and stay. +#[test] +fn registry_server_type_cleared_as_workspace_only_field() { + let yaml = r" +registries: + https://artifactory.example/artifactory/api/npm/npm-virtual/: + serverType: artifactory + scopes: ['@acme'] +"; + let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + settings.clear_workspace_only_fields(); + let entries = settings.registries.as_ref().expect("registries present"); + let RegistryEntry::Declaration(declaration) = entries.values().next().expect("one declaration") + else { + panic!("expected a declaration: {entries:?}") + }; + assert_eq!(declaration.server_type, None); + assert_eq!(declaration.scopes.as_deref(), Some(["@acme".to_owned()].as_slice())); +} + +/// A credential in the key is the same secret in the same committed file as a +/// credential in a field, so both are refused. The check runs after parsing so +/// the error carries a redacted URL instead of serde's verbatim source line. +#[test] +fn rejects_a_registry_key_that_embeds_credentials() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://ci-user-6e42:hunter2@npm.example.com/: {serverType: artifactory}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a key with credentials must not load") + .to_string(); + assert!(!error.contains("hunter2"), "the password must not be echoed: {error}"); + assert!(!error.contains("ci-user-6e42"), "the username must not be echoed: {error}"); + assert!(error.contains("npm.example.com"), "the host is still named: {error}"); +} + +/// `.npmrc` scopes settings with a scheme-less `//host/`, and this setting's +/// own error points users at that syntax, so it is the form they are most +/// likely to write — and it must not slip past the check. +#[test] +fn rejects_a_scheme_less_registry_key_that_embeds_credentials() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n //ci-user-6e42:hunter2@npm.example.com/: {serverType: artifactory}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a scheme-less key with credentials must not load") + .to_string(); + assert!(!error.contains("hunter2"), "the password must not be echoed: {error}"); + assert!(!error.contains("ci-user-6e42"), "the username must not be echoed: {error}"); +} + +/// A later `@` in the path is not userinfo. +#[test] +fn accepts_a_registry_key_with_an_at_sign_in_the_path() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.example.com/scope@1/: {serverType: artifactory}\n", + ) + .unwrap(); + + let settings = WorkspaceSettings::load_at(dir.path()).unwrap().expect("settings"); + assert!(settings.registries.is_some()); +} + +/// Searching for the first `://` would find the one in the path and parse the +/// authority from there, leaving the real credentials unexamined. +#[test] +fn rejects_a_scheme_less_key_whose_path_contains_a_scheme_separator() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n '//ci-user-6e42:hunter2@npm.example.com/a://b': {serverType: artifactory}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("credentials must not slip past a path scheme separator") + .to_string(); + assert!(!error.contains("hunter2"), "the password must not be echoed: {error}"); + assert!(!error.contains("ci-user-6e42"), "the username must not be echoed: {error}"); +} + +/// A scope resolves to one registry while a registry serves many, so the +/// declaration reads the way it is written and the lookup is its inverse. A +/// bare `@` is the scope-less default registry. +#[test] +fn routes_the_scopes_a_registry_declares() { + let yaml = r" +registries: + https://npm.corp.example: + serverType: npm + scopes: ['@', '@foo', '@bar'] +"; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let mut config = Config::new(); + settings.apply_to(&mut config, Path::new("/irrelevant")); + assert_eq!(config.registry, "https://npm.corp.example/"); + assert_eq!( + config.registries_by_scope.get("@foo").map(String::as_str), + Some("https://npm.corp.example/"), + ); + assert_eq!( + config.registries_by_scope.get("@bar").map(String::as_str), + Some("https://npm.corp.example/"), + ); +} + +#[test] +fn rejects_a_scope_declared_without_its_at_sign() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.corp.example/: {scopes: [foo]}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a scope without its @ must not load") + .to_string(); + assert!(error.contains(r#""foo""#), "the scope is named: {error}"); +} + +#[test] +fn rejects_one_scope_routed_to_two_registries() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.corp.example/: {scopes: ['@foo']}\n https://artifactory.example/: {scopes: ['@foo']}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a scope routed twice must not load") + .to_string(); + assert!(error.contains("routed to two registries"), "{error}"); +} + +/// Keyed by the URL as written: a named registry's URL is what a lockfile's +/// recorded tarball URLs are matched against, so normalizing it here would +/// change what an existing lockfile verifies against. +#[test] +fn reads_a_declared_prefix_as_a_named_registry() { + let yaml = r" +registries: + https://npm.corp.example: {prefix: work, serverType: artifactory} +"; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let mut config = Config::new(); + settings.apply_to(&mut config, Path::new("/irrelevant")); + assert_eq!( + config.registries_by_prefix.get("work").map(String::as_str), + Some("https://npm.corp.example"), + ); + assert_eq!( + config + .registry_options_by_url + .get("https://npm.corp.example/") + .map(|options| options.server_type), + Some(Some(RegistryServerType::Artifactory)), + ); +} + +#[test] +fn rejects_one_prefix_declared_by_two_registries() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.corp.example/: {prefix: work}\n https://artifactory.example/: {prefix: work}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a prefix declared twice must not load") + .to_string(); + assert!(error.contains("declared by two registries"), "{error}"); +} + +/// The deprecated spelling of the same thing, so a declared prefix wins. +#[test] +fn a_declared_prefix_wins_over_named_registries() { + let yaml = r" +namedRegistries: + work: https://stale.example/ + other: https://other.example/ +registries: + https://npm.corp.example/: {prefix: work} +"; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let mut config = Config::new(); + settings.apply_to(&mut config, Path::new("/irrelevant")); + assert_eq!( + config.registries_by_prefix.get("work").map(String::as_str), + Some("https://npm.corp.example/"), + ); + assert_eq!( + config.registries_by_prefix.get("other").map(String::as_str), + Some("https://other.example/"), + ); +} + +#[test] +fn rejects_a_registries_map_that_mixes_both_shapes() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n '@acme': https://npm.example.com/\n https://artifactory.example/: {serverType: artifactory}\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a mixed registries map must not load") + .to_string(); + assert!(error.contains("mixes registry declarations"), "{error}"); + assert!(error.contains(r#""@acme""#), "the scope-routed entry is named: {error}"); +} + +/// A scope routes to a registry, so a URL in that position routes nothing and +/// would sit there inert. It is the declaration shape, half-written. +#[test] +fn rejects_a_url_keyed_registries_entry_written_as_a_string() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(WORKSPACE_MANIFEST_FILENAME), + "registries:\n https://npm.example.com/: artifactory\n", + ) + .unwrap(); + + let error = WorkspaceSettings::load_at(dir.path()) + .expect_err("a URL-keyed string entry must not load") + .to_string(); + assert!(error.contains("is a string"), "{error}"); +} + +/// The inverse of the split a `registries` setting goes through, for the +/// request a pnpr server reads. Every route to one registry lands in its entry. +#[test] +fn rebuilds_the_declarations_from_the_lookups() { + let mut config = Config::new(); + config.registries_by_scope = BTreeMap::from([ + ("default".to_owned(), "https://registry.npmjs.org/".to_owned()), + ("@acme".to_owned(), "https://npm.corp.example/".to_owned()), + ("@acme-internal".to_owned(), "https://npm.corp.example/".to_owned()), + ("@other".to_owned(), "https://npm.other.example/".to_owned()), + ]); + config.registries_by_prefix = + BTreeMap::from([("work".to_owned(), "https://npm.corp.example/".to_owned())]); + config.registry_options_by_url = BTreeMap::from([( + "https://npm.corp.example/".to_owned(), + RegistryOptions { server_type: Some(RegistryServerType::Artifactory) }, + )]); + + let declarations = config.registry_declarations(); + assert_eq!( + declarations.get("https://npm.corp.example/"), + Some(&RegistryDeclaration { + scopes: Some(vec!["@acme".to_owned(), "@acme-internal".to_owned()]), + prefix: Some("work".to_owned()), + server_type: Some(RegistryServerType::Artifactory), + unknown: BTreeMap::new(), + }), + ); + assert_eq!( + declarations.get("https://npm.other.example/"), + Some(&RegistryDeclaration { + scopes: Some(vec!["@other".to_owned()]), + ..RegistryDeclaration::default() + }), + ); + // The default registry travels as the request's own `registry` field. + assert!(!declarations.contains_key("https://registry.npmjs.org/"), "{declarations:?}"); +} + +/// A declaration map survives the round trip through the lookups it is split +/// into, which is what makes it safe to rebuild one for a pnpr request. +#[test] +fn declarations_round_trip_through_the_lookups() { + let yaml = r" +registries: + https://npm.corp.example/: + serverType: artifactory + scopes: ['@acme'] + prefix: work + https://npm.other.example/: + scopes: ['@other'] +"; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let entries = settings.registries.clone().expect("registries present"); + let mut config = Config::new(); + settings.apply_to(&mut config, Path::new("/irrelevant")); + + let rebuilt = config.registry_declarations(); + let original: std::collections::BTreeMap = entries + .into_iter() + .map(|(registry, entry)| match entry { + RegistryEntry::Declaration(declaration) => (registry, declaration), + RegistryEntry::ScopeRoute(_) => panic!("declarations only"), + }) + .collect(); + assert_eq!(rebuilt, original); +} diff --git a/pnpm/crates/deps-restorer/src/install_package_by_snapshot.rs b/pnpm/crates/deps-restorer/src/install_package_by_snapshot.rs index 19697471b4..0d3bced091 100644 --- a/pnpm/crates/deps-restorer/src/install_package_by_snapshot.rs +++ b/pnpm/crates/deps-restorer/src/install_package_by_snapshot.rs @@ -766,27 +766,28 @@ pub fn tarball_url_and_integrity<'a>( // A registry-qualified key (`@:`) // reconstructs its tarball from its named registry; everything // else routes by scope. - let (registry, version) = if let Some((registry_name, version)) = - package_key.suffix.registry_qualified() - { - let registry = pnpm_resolving_npm_resolver::BUILTIN_NAMED_REGISTRIES - .iter() - .find(|(name, _)| *name == registry_name) - .map(|(_, url)| (*url).to_string()) - .pipe(|builtin| config.named_registries.get(registry_name).cloned().or(builtin)) - .ok_or_else(|| InstallPackageBySnapshotError::MissingNamedRegistry { - package_key: package_key.to_string(), - registry_name: registry_name.to_string(), - })?; - (registry, version.to_string()) - } else { - let registries: HashMap = - config.resolved_registries().into_iter().collect(); - ( - pick_registry_for_package(®istries, &name, None), - package_key.suffix.version().to_string(), - ) - }; + let (registry, version) = + if let Some((registry_name, version)) = package_key.suffix.registry_qualified() { + let registry = pnpm_resolving_npm_resolver::BUILTIN_REGISTRIES_BY_PREFIX + .iter() + .find(|(name, _)| *name == registry_name) + .map(|(_, url)| (*url).to_string()) + .pipe(|builtin| { + config.registries_by_prefix.get(registry_name).cloned().or(builtin) + }) + .ok_or_else(|| InstallPackageBySnapshotError::MissingNamedRegistry { + package_key: package_key.to_string(), + registry_name: registry_name.to_string(), + })?; + (registry, version.to_string()) + } else { + let registries: HashMap = + config.resolved_registries().into_iter().collect(); + ( + pick_registry_for_package(®istries, &name, None), + package_key.suffix.version().to_string(), + ) + }; let registry = registry.strip_suffix('/').unwrap_or(®istry); let bare_name = package_key.name.bare.as_str(); let tarball_url = format!("{registry}/{name}/-/{bare_name}-{version}.tgz"); diff --git a/pnpm/crates/deps-restorer/src/install_package_by_snapshot/tests.rs b/pnpm/crates/deps-restorer/src/install_package_by_snapshot/tests.rs index f7dcc2e565..5fd8cc4c3f 100644 --- a/pnpm/crates/deps-restorer/src/install_package_by_snapshot/tests.rs +++ b/pnpm/crates/deps-restorer/src/install_package_by_snapshot/tests.rs @@ -51,7 +51,9 @@ fn emits_resolved_with_supplied_identifiers() { fn registry_resolution_uses_scoped_registry_tarball_base() { let mut config = Config::new(); config.registry = "https://default.example/npm/".to_string(); - config.registries.insert("@private".to_string(), "https://private.example/npm/".to_string()); + config + .registries_by_scope + .insert("@private".to_string(), "https://private.example/npm/".to_string()); let integrity = DUMMY_SHA512.parse().expect("parse integrity"); let resolution = LockfileResolution::Registry(RegistryResolution { integrity }); diff --git a/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs b/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs index a91f5d0264..4b6bc680b5 100644 --- a/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs +++ b/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs @@ -75,9 +75,10 @@ fn create_config( lockfile_include_tarball_url: false, registry: "https://registry.npmjs.com/".to_string(), scope: None, - registries: Default::default(), + registries_by_scope: Default::default(), pnpr_server: None, - named_registries: Default::default(), + registries_by_prefix: Default::default(), + registry_options_by_url: Default::default(), auto_install_peers: false, auto_install_peers_from_highest_match: false, exclude_links_from_lockfile: false, @@ -193,7 +194,7 @@ async fn resolve_via_mock( registries.insert("default".to_string(), registry.to_string()); let resolver = NpmResolver { registries, - named_registries: HashMap::new(), + registries_by_prefix: HashMap::new(), http_client, auth_headers: Default::default(), meta_cache: Arc::new(InMemoryPackageMetaCache::default()), diff --git a/pnpm/crates/env-installer/src/install_config_deps.rs b/pnpm/crates/env-installer/src/install_config_deps.rs index 1f054d6015..074bf9e2f8 100644 --- a/pnpm/crates/env-installer/src/install_config_deps.rs +++ b/pnpm/crates/env-installer/src/install_config_deps.rs @@ -14,7 +14,7 @@ use pnpm_graph_hasher::{ calc_global_virtual_store_path_with_subdeps, calc_leaf_global_virtual_store_path, join_global_virtual_store_path, }; -use pnpm_lockfile::{EnvLockfile, LockfileResolution, npm_tarball_url}; +use pnpm_lockfile::{EnvLockfile, LockfileResolution, TarballUrlOptions, npm_tarball_url}; use pnpm_package_is_installable::{ InstallabilityOptions, PackageInstallabilityManifest, check_package, }; @@ -463,9 +463,10 @@ fn integrity_and_tarball( registry: &str, ) -> Option<(Integrity, String)> { match resolution { - LockfileResolution::Registry(registry_resolution) => { - Some((registry_resolution.integrity.clone(), npm_tarball_url(name, version, registry))) - } + LockfileResolution::Registry(registry_resolution) => Some(( + registry_resolution.integrity.clone(), + npm_tarball_url(name, version, TarballUrlOptions { registry, server_type: None }), + )), LockfileResolution::Tarball(tarball) => { let integrity = tarball.integrity.clone()?; Some((integrity, tarball.tarball.clone())) diff --git a/pnpm/crates/env-installer/src/manifest_lockfile.rs b/pnpm/crates/env-installer/src/manifest_lockfile.rs index e2ea9f8f9f..84d2d98fe9 100644 --- a/pnpm/crates/env-installer/src/manifest_lockfile.rs +++ b/pnpm/crates/env-installer/src/manifest_lockfile.rs @@ -1,4 +1,6 @@ -use pnpm_lockfile::{BundledDependencies, PackageMetadata, PeerDependencyMeta, StringOrList}; +use pnpm_lockfile::{ + BundledDependencies, LockfileFormOptions, PackageMetadata, PeerDependencyMeta, StringOrList, +}; use pnpm_resolving_resolver_base::ResolveResult; use serde_json::Value; use std::collections::HashMap; @@ -15,8 +17,11 @@ pub(crate) fn package_metadata( resolution: result.resolution.to_lockfile_form( name, version, - registry, - lockfile_include_tarball_url, + LockfileFormOptions { + registry, + server_type: None, + include_tarball_url: lockfile_include_tarball_url, + }, ), version: None, engines: read_engines(manifest), diff --git a/pnpm/crates/env-installer/src/resolve_and_install_config_deps.rs b/pnpm/crates/env-installer/src/resolve_and_install_config_deps.rs index 670f8e1ce8..0d388d23d9 100644 --- a/pnpm/crates/env-installer/src/resolve_and_install_config_deps.rs +++ b/pnpm/crates/env-installer/src/resolve_and_install_config_deps.rs @@ -15,8 +15,8 @@ use crate::{ verify_env_lockfile::write_verified_env_lockfile, }; use pnpm_lockfile::{ - EnvLockfile, LockfileResolution, PackageKey, PackageMetadata, SnapshotEntry, - SpecifierAndResolution, TarballResolution, npm_tarball_url, + EnvLockfile, LockfileFormOptions, LockfileResolution, PackageKey, PackageMetadata, + SnapshotEntry, SpecifierAndResolution, TarballResolution, TarballUrlOptions, npm_tarball_url, }; use pnpm_reporter::Reporter; use pnpm_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency}; @@ -24,6 +24,13 @@ use pnpm_workspace_state::ConfigDependency; use ssri::Integrity; use std::collections::BTreeMap; +/// Config deps keep the npm tarball layout: `registries` is a workspace +/// setting, and config deps are resolved before workspace settings apply. The +/// writer and the reader here agree because both use this same default. +fn npm_lockfile_form(registry: &str) -> LockfileFormOptions<'_> { + LockfileFormOptions { registry, server_type: None, include_tarball_url: false } +} + /// Resolve + install the config dependencies declared in /// `pnpm-workspace.yaml` (`config_deps`). pub async fn resolve_and_install_config_deps( @@ -55,10 +62,13 @@ pub async fn resolve_and_install_config_deps( if !has_config_dep(&env_lockfile, name) { let (version, integrity) = parse_integrity(name, &detail.integrity)?; let registry = opts.pick_registry(name); - let tarball = detail - .tarball - .clone() - .unwrap_or_else(|| npm_tarball_url(name, &version, registry)); + let tarball = detail.tarball.clone().unwrap_or_else(|| { + npm_tarball_url( + name, + &version, + TarballUrlOptions { registry, server_type: None }, + ) + }); migrate_into_lockfile( &mut env_lockfile, name, @@ -74,7 +84,11 @@ pub async fn resolve_and_install_config_deps( if !has_config_dep(&env_lockfile, name) { let (version, integrity) = parse_integrity(name, value)?; let registry = opts.pick_registry(name); - let tarball = npm_tarball_url(name, &version, registry); + let tarball = npm_tarball_url( + name, + &version, + TarballUrlOptions { registry, server_type: None }, + ); migrate_into_lockfile( &mut env_lockfile, name, @@ -166,9 +180,11 @@ async fn resolve_one( ); env_lockfile.packages.insert( key.clone(), - registry_package_metadata( - result.resolution.to_lockfile_form(name, &version, registry, false), - ), + registry_package_metadata(result.resolution.to_lockfile_form( + name, + &version, + npm_lockfile_form(registry), + )), ); let optional_subdeps = match result.manifest.as_deref() { @@ -205,7 +221,7 @@ fn migrate_into_lockfile( git_hosted: None, path: None, }) - .to_lockfile_form(name, version, registry, false); + .to_lockfile_form(name, version, npm_lockfile_form(registry)); env_lockfile.packages.insert(key.clone(), registry_package_metadata(resolution)); env_lockfile.snapshots.insert(key, SnapshotEntry::default()); Ok(()) diff --git a/pnpm/crates/env-installer/src/tests.rs b/pnpm/crates/env-installer/src/tests.rs index 02d302c28d..75b752142d 100644 --- a/pnpm/crates/env-installer/src/tests.rs +++ b/pnpm/crates/env-installer/src/tests.rs @@ -55,7 +55,7 @@ fn build_resolver(registry: &str) -> (NpmResolver, Tem registries.insert("default".to_string(), registry.to_string()); let resolver = NpmResolver { registries, - named_registries: std::collections::HashMap::new(), + registries_by_prefix: std::collections::HashMap::new(), http_client: Arc::new(ThrottledClient::default()), auth_headers: Arc::new(AuthHeaders::default()), meta_cache: Arc::new(InMemoryPackageMetaCache::default()), diff --git a/pnpm/crates/lockfile/src/resolution.rs b/pnpm/crates/lockfile/src/resolution.rs index 2447d9f035..266b401676 100644 --- a/pnpm/crates/lockfile/src/resolution.rs +++ b/pnpm/crates/lockfile/src/resolution.rs @@ -2,7 +2,10 @@ use derive_more::{Display, From, TryInto}; use pipe_trait::Pipe; use serde::{Deserialize, Serialize}; use ssri::Integrity; -use std::collections::{BTreeMap, HashMap}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashMap}, +}; /// For tarball hosted remotely or locally. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -351,20 +354,21 @@ impl LockfileResolution { /// Convert an in-memory resolution into the form written to the lockfile. /// /// For a registry tarball whose URL is reconstructible from `name`, - /// `version`, and `registry`, the URL is dropped and only `{integrity}` is - /// kept — pnpm derives the tarball URL on demand. The URL is preserved when - /// `include_tarball_url` is set, when it is a `file:` tarball, when it is - /// git-hosted, or when it does not match the derived URL (e.g. private - /// registries with non-standard tarball paths). Non-tarball resolutions and - /// integrity-less tarballs pass through unchanged. + /// `version`, and the registry, the URL is dropped and only `{integrity}` + /// is kept — pnpm derives the tarball URL on demand. The URL is preserved + /// when [`LockfileFormOptions::include_tarball_url`] is set, when it is a + /// `file:` tarball, when it is git-hosted, or when it does not match the + /// derived URL (e.g. private registries with non-standard tarball paths). + /// Non-tarball resolutions and integrity-less tarballs pass through + /// unchanged. #[must_use] pub fn to_lockfile_form( &self, name: &str, version: &str, - registry: &str, - include_tarball_url: bool, + opts: LockfileFormOptions<'_>, ) -> LockfileResolution { + let LockfileFormOptions { registry, server_type, include_tarball_url } = opts; let LockfileResolution::Tarball(tarball) = self else { return self.clone() }; let Some(integrity) = tarball.integrity.as_ref() else { return self.clone() }; @@ -378,7 +382,12 @@ impl LockfileResolution { if !include_tarball_url && !git_hosted && !tarball.tarball.starts_with("file:") - && is_canonical_registry_tarball_url(&tarball.tarball, name, version, registry) + && is_canonical_registry_tarball_url( + &tarball.tarball, + name, + version, + TarballUrlOptions { registry, server_type }, + ) { return LockfileResolution::Registry(RegistryResolution { integrity: integrity.clone(), @@ -397,48 +406,163 @@ impl LockfileResolution { } } +/// The software serving a registry, declared through the `registries` +/// setting. Modeled as an [`Option`] everywhere it is threaded, because +/// "behaves like the npm registry" is a claim only the operator can make: +/// +/// - [`None`] — strict. Only the exact canonical URL is reconstructible. This +/// is how every registry but registry.npmjs.org is read by default. +/// - [`RegistryServerType::Npm`] — behaves like registry.npmjs.org, which +/// serves a scoped package from the percent-encoded path as well as the +/// unencoded one. A faithful mirror or caching proxy of it is this. +/// - [`RegistryServerType::Artifactory`] — repeats the scope in a scoped +/// package's tarball filename. +/// +/// Only layouts pnpm can rebuild a URL for belong here. A registry that serves +/// tarballs from a content-derived path (GitHub Packages +/// `/download////`) has no variant: the digest is +/// a fact about the bytes rather than about the package's identity, so its URLs +/// are kept in the lockfile instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RegistryServerType { + Npm, + Artifactory, +} + +/// Non-secret, per-registry settings from the `registries` setting. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryOptions { + #[serde(default)] + pub server_type: Option, +} + +/// registry.npmjs.org is the one registry whose layout pnpm knows without +/// being told, so it is a row of data rather than a hostname comparison. A +/// declared server type wins over it. +const DEFAULT_REGISTRY_SERVER_TYPES: &[(&str, RegistryServerType)] = + &[("https://registry.npmjs.org/", RegistryServerType::Npm)]; + +/// The layout the user declared for `registry`, or [`None`] for none. +/// +/// Built-in layouts are deliberately not applied here — they belong with the +/// predicate that acts on them, so that a code path which never threads +/// `registryOptions` still gets them. +/// +/// `registry_options_by_url` is keyed by registry URL with a trailing slash, the way +/// the config reader normalizes it, so the lookup normalizes its query to +/// match. +#[must_use] +pub fn registry_server_type( + registry_options_by_url: &BTreeMap, + registry: &str, +) -> Option { + let key = if registry.ends_with('/') { + Cow::Borrowed(registry) + } else { + Cow::Owned(format!("{registry}/")) + }; + registry_options_by_url.get(key.as_ref()).copied().unwrap_or_default().server_type +} + +/// A declared server type wins; otherwise the built-in layout of a known +/// registry applies, and an unknown registry is read strictly. +fn effective_server_type(opts: TarballUrlOptions<'_>) -> Option { + opts.server_type.or_else(|| { + let registry = if opts.registry.ends_with('/') { + Cow::Borrowed(opts.registry) + } else { + Cow::Owned(format!("{}/", opts.registry)) + }; + DEFAULT_REGISTRY_SERVER_TYPES + .iter() + .find(|(default_registry, _)| *default_registry == registry.as_ref()) + .map(|(_, server_type)| *server_type) + }) +} + +/// Everything needed to decide which registry a package came from and what +/// that registry does: the scope-routed URLs, the `:`-addressed aliases, +/// and the declared per-registry settings. +/// +/// Threaded as one value rather than as three parameters so a consumer cannot +/// be handed the routing without the settings — dropping the settings is +/// silent, the tarball URL is simply rebuilt in the wrong layout — and so a +/// new per-registry setting reaches every consumer by being added here. +/// +/// The counterpart of the TypeScript CLI's [`RegistryContext`]. +#[derive(Debug, Default, Clone)] +pub struct RegistryContext { + pub registries: HashMap, + /// As the user wrote it; built-in aliases are merged in at lookup. + pub registries_by_prefix: HashMap, + pub registry_options_by_url: BTreeMap, +} + +/// Where a package's tarball lives: the registry it resolved from, and the URL +/// layout that registry serves. +#[derive(Debug, Clone, Copy)] +pub struct TarballUrlOptions<'a> { + pub registry: &'a str, + pub server_type: Option, +} + +/// Inputs to [`LockfileResolution::to_lockfile_form`]. +#[derive(Debug, Clone, Copy)] +pub struct LockfileFormOptions<'a> { + pub registry: &'a str, + pub server_type: Option, + /// Keep the tarball URL even when it is reconstructible. + pub include_tarball_url: bool, +} + /// Derive the canonical npm registry tarball URL for `name@version`. Port of /// the [`get-npm-tarball-url`](https://www.npmjs.com/package/get-npm-tarball-url) /// package pnpm uses. +/// +/// This is the single source of the URL shape: the lockfile writer drops a +/// tarball URL only when this function rebuilds it, and the lockfile reader +/// rebuilds it with this function. Both sides therefore agree by construction, +/// including under a non-npm [`RegistryServerType`]. #[must_use] -pub fn npm_tarball_url(name: &str, version: &str, registry: &str) -> String { +pub fn npm_tarball_url(name: &str, version: &str, opts: TarballUrlOptions<'_>) -> String { + let TarballUrlOptions { registry, server_type } = opts; let registry = if registry.ends_with('/') { registry.to_string() } else { format!("{registry}/") }; - let scopeless = match name.strip_prefix('@') { - Some(scoped) => scoped.split_once('/').map_or(name, |(_, bare)| bare), - None => name, + // Artifactory keeps the scope in the filename of a scoped package's tarball + // (`@acme/widget/-/@acme/widget-1.0.0.tgz`); the npm layout strips it. + let filename_name = match server_type { + Some(RegistryServerType::Artifactory) => name, + Some(RegistryServerType::Npm) | None => match name.strip_prefix('@') { + Some(scoped) => scoped.split_once('/').map_or(name, |(_, bare)| bare), + None => name, + }, }; let version = version.split_once('+').map_or(version, |(base, _)| base); - format!("{registry}{name}/-/{scopeless}-{version}.tgz") + format!("{registry}{name}/-/{filename_name}-{version}.tgz") } -/// Whether `tarball` is the canonical npm registry URL derived from `name`, -/// `version`, and `registry` — i.e. it can be dropped from the lockfile and -/// rebuilt on demand. +/// Whether `tarball` is the URL [`npm_tarball_url`] rebuilds for `name` and +/// `version` — i.e. it can be dropped from the lockfile and rebuilt on demand. fn is_canonical_registry_tarball_url( tarball: &str, name: &str, version: &str, - registry: &str, + opts: TarballUrlOptions<'_>, ) -> bool { - let expected = npm_tarball_url(name, version, registry); + let expected = npm_tarball_url(name, version, opts); let expected = remove_protocol(&expected); let actual = remove_protocol(tarball); - // registry.npmjs.org serves a scoped package from both the encoded and the - // unencoded path; elsewhere only the encoded one may work. - // See . + // A registry behaving like registry.npmjs.org serves a scoped package from + // both the encoded and the unencoded path. A registry that has not been + // declared to behave like it may serve only the encoded one, so its URL is + // kept. See . expected == actual - || (is_public_npm_registry(registry) + || (effective_server_type(opts) == Some(RegistryServerType::Npm) && expected == actual.replace("%2f", "/").replace("%2F", "/")) } -const PUBLIC_NPM_REGISTRY_HOST: &str = "registry.npmjs.org"; - -fn is_public_npm_registry(registry: &str) -> bool { - let registry = remove_protocol(registry); - registry.strip_suffix('/').unwrap_or(registry) == PUBLIC_NPM_REGISTRY_HOST -} - /// Default-vs-scope routing for an npm package. /// /// Routing rules: diff --git a/pnpm/crates/lockfile/src/resolution/tests.rs b/pnpm/crates/lockfile/src/resolution/tests.rs index 0216d469bf..bed4957979 100644 --- a/pnpm/crates/lockfile/src/resolution/tests.rs +++ b/pnpm/crates/lockfile/src/resolution/tests.rs @@ -1,8 +1,9 @@ use super::{ BinaryArchive, BinaryResolution, BinarySpec, DirectoryResolution, GitResolution, - LockfileResolution, PlatformAssetResolution, PlatformAssetTarget, PlatformSelector, - RegistryResolution, TarballResolution, VariationsResolution, is_git_hosted_tarball_url, - libc_matches, select_platform_variant, + LockfileFormOptions, LockfileResolution, PlatformAssetResolution, PlatformAssetTarget, + PlatformSelector, RegistryOptions, RegistryResolution, RegistryServerType, TarballResolution, + TarballUrlOptions, VariationsResolution, is_git_hosted_tarball_url, libc_matches, + npm_tarball_url, registry_server_type, select_platform_variant, }; use crate::serialize_yaml; use pretty_assertions::assert_eq; @@ -12,6 +13,12 @@ use text_block_macros::text_block; const GIT_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +/// No declared server type — the strict default every registry but +/// registry.npmjs.org is read with. +fn undeclared_form(registry: &str, include_tarball_url: bool) -> LockfileFormOptions<'_> { + LockfileFormOptions { registry, server_type: None, include_tarball_url } +} + fn integrity(integrity_str: &str) -> Integrity { integrity_str.parse().expect("parse integrity string") } @@ -754,7 +761,11 @@ fn to_lockfile_form_drops_reconstructible_registry_tarball() { git_hosted: None, path: None, }); - let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", false); + let actual = resolution.to_lockfile_form( + "foo", + "1.0.0", + undeclared_form("https://registry.npmjs.org/", false), + ); assert_eq!( actual, LockfileResolution::Registry(RegistryResolution { integrity: integrity(SHA512) }), @@ -773,7 +784,11 @@ fn to_lockfile_form_keeps_git_hosted_subdirectory_path() { git_hosted: Some(true), path: Some("/packages/foo".to_string()), }); - let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", false); + let actual = resolution.to_lockfile_form( + "foo", + "1.0.0", + undeclared_form("https://registry.npmjs.org/", false), + ); assert_eq!(actual, resolution); } @@ -787,7 +802,11 @@ fn to_lockfile_form_keeps_git_hosted_subdirectory_path_when_including_tarball_ur git_hosted: Some(true), path: Some("/packages/foo".to_string()), }); - let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", true); + let actual = resolution.to_lockfile_form( + "foo", + "1.0.0", + undeclared_form("https://registry.npmjs.org/", true), + ); assert_eq!(actual, resolution); } @@ -803,8 +822,11 @@ fn to_lockfile_form_keeps_scoped_tarball_with_percent_encoded_scope_separator() git_hosted: None, path: None, }); - let actual = - resolution.to_lockfile_form("@babel/core", "7.0.0", "https://npm.example.com/", false); + let actual = resolution.to_lockfile_form( + "@babel/core", + "7.0.0", + undeclared_form("https://npm.example.com/", false), + ); assert_eq!(actual, resolution, "{tarball_url} must survive verbatim"); } } @@ -824,8 +846,7 @@ fn to_lockfile_form_drops_scoped_tarball_with_percent_encoding_on_the_public_reg let actual = resolution.to_lockfile_form( "@babel/core", "7.0.0", - "https://registry.npmjs.org/", - false, + undeclared_form("https://registry.npmjs.org/", false), ); assert_eq!( actual, @@ -848,7 +869,11 @@ fn to_lockfile_form_keeps_tarball_with_trailing_scheme_separator() { git_hosted: None, path: None, }); - let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", false); + let actual = resolution.to_lockfile_form( + "foo", + "1.0.0", + undeclared_form("https://registry.npmjs.org/", false), + ); assert_eq!( actual, LockfileResolution::Tarball(TarballResolution { @@ -929,3 +954,151 @@ fn deserialize_rejects_malformed_builtin_resolution() { dbg!(&received); assert!(received.is_err(), "a git resolution without a commit must not parse"); } + +const ARTIFACTORY_REGISTRY: &str = "https://artifactory.example/artifactory/api/npm/npm-virtual/"; + +/// The npm and Artifactory layouts differ only in a scoped package's filename. +#[test] +fn npm_tarball_url_keeps_the_scope_in_the_artifactory_filename() { + for (name, version, expected) in [ + ("@acme/widget", "1.2.3", "@acme/widget/-/@acme/widget-1.2.3.tgz"), + ("@acme/widget", "1.2.3+build.4", "@acme/widget/-/@acme/widget-1.2.3.tgz"), + ("@acme/widget", "1.2.3-beta.1", "@acme/widget/-/@acme/widget-1.2.3-beta.1.tgz"), + ("widget", "1.2.3", "widget/-/widget-1.2.3.tgz"), + ] { + let received = npm_tarball_url( + name, + version, + TarballUrlOptions { + registry: ARTIFACTORY_REGISTRY, + server_type: Some(RegistryServerType::Artifactory), + }, + ); + assert_eq!(received, format!("{ARTIFACTORY_REGISTRY}{expected}")); + } +} + +#[test] +fn npm_tarball_url_matches_the_npm_layout_for_an_unscoped_package() { + for server_type in [None, Some(RegistryServerType::Npm), Some(RegistryServerType::Artifactory)] + { + let received = npm_tarball_url( + "widget", + "1.2.3", + TarballUrlOptions { registry: ARTIFACTORY_REGISTRY, server_type }, + ); + assert_eq!(received, format!("{ARTIFACTORY_REGISTRY}widget/-/widget-1.2.3.tgz")); + } +} + +fn artifactory_form(include_tarball_url: bool) -> LockfileFormOptions<'static> { + LockfileFormOptions { + registry: ARTIFACTORY_REGISTRY, + server_type: Some(RegistryServerType::Artifactory), + include_tarball_url, + } +} + +#[test] +fn to_lockfile_form_drops_the_artifactory_url_of_a_scoped_package() { + let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/@acme/widget-1.2.3.tgz"); + let resolution = LockfileResolution::Tarball(TarballResolution { + tarball, + integrity: Some(integrity(SHA512)), + git_hosted: None, + path: None, + }); + let actual = resolution.to_lockfile_form("@acme/widget", "1.2.3", artifactory_form(false)); + assert_eq!( + actual, + LockfileResolution::Registry(RegistryResolution { integrity: integrity(SHA512) }), + ); +} + +/// Under the Artifactory layout the npm-layout URL is the one pnpm cannot +/// rebuild, so it has to survive verbatim — the inverse of the default. +#[test] +fn to_lockfile_form_keeps_the_npm_layout_url_on_an_artifactory_registry() { + let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/widget-1.2.3.tgz"); + let resolution = LockfileResolution::Tarball(TarballResolution { + tarball, + integrity: Some(integrity(SHA512)), + git_hosted: None, + path: None, + }); + let actual = resolution.to_lockfile_form("@acme/widget", "1.2.3", artifactory_form(false)); + assert_eq!(actual, resolution); +} + +#[test] +fn to_lockfile_form_keeps_the_artifactory_url_on_a_registry_left_on_the_npm_layout() { + let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/@acme/widget-1.2.3.tgz"); + let resolution = LockfileResolution::Tarball(TarballResolution { + tarball, + integrity: Some(integrity(SHA512)), + git_hosted: None, + path: None, + }); + let actual = resolution.to_lockfile_form( + "@acme/widget", + "1.2.3", + undeclared_form(ARTIFACTORY_REGISTRY, false), + ); + assert_eq!(actual, resolution); +} + +#[test] +fn to_lockfile_form_keeps_the_artifactory_url_when_include_tarball_url_is_set() { + let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/@acme/widget-1.2.3.tgz"); + let resolution = LockfileResolution::Tarball(TarballResolution { + tarball, + integrity: Some(integrity(SHA512)), + git_hosted: None, + path: None, + }); + let actual = resolution.to_lockfile_form("@acme/widget", "1.2.3", artifactory_form(true)); + assert_eq!(actual, resolution); +} + +#[test] +fn registry_server_type_is_undeclared_by_default_and_tolerates_a_missing_trailing_slash() { + let options = BTreeMap::from([( + ARTIFACTORY_REGISTRY.to_string(), + RegistryOptions { server_type: Some(RegistryServerType::Artifactory) }, + )]); + assert_eq!( + registry_server_type(&options, ARTIFACTORY_REGISTRY.trim_end_matches('/')), + Some(RegistryServerType::Artifactory), + ); + assert_eq!(registry_server_type(&options, "https://npm.example.com/"), None); + assert_eq!(registry_server_type(&BTreeMap::new(), ARTIFACTORY_REGISTRY), None); +} + +/// A registry declared to behave like registry.npmjs.org gets its leniency: +/// the percent-encoded scoped path is reconstructible there too. Undeclared, +/// the same URL must survive — the registry may serve only the encoded path. +/// See . +#[test] +fn to_lockfile_form_drops_the_encoded_scoped_path_only_when_the_registry_is_declared_npm() { + let registry = "https://npm.example.com/"; + let resolution = LockfileResolution::Tarball(TarballResolution { + tarball: format!("{registry}@babel%2Fcore/-/core-7.0.0.tgz"), + integrity: Some(integrity(SHA512)), + git_hosted: None, + path: None, + }); + + let declared_npm = LockfileFormOptions { + registry, + server_type: Some(RegistryServerType::Npm), + include_tarball_url: false, + }; + assert_eq!( + resolution.to_lockfile_form("@babel/core", "7.0.0", declared_npm), + LockfileResolution::Registry(RegistryResolution { integrity: integrity(SHA512) }), + ); + assert_eq!( + resolution.to_lockfile_form("@babel/core", "7.0.0", undeclared_form(registry, false)), + resolution, + ); +} diff --git a/pnpm/crates/modules-yaml/src/lib.rs b/pnpm/crates/modules-yaml/src/lib.rs index d51280aa61..81935cc20f 100644 --- a/pnpm/crates/modules-yaml/src/lib.rs +++ b/pnpm/crates/modules-yaml/src/lib.rs @@ -165,12 +165,6 @@ pub struct Modules { #[serde(default)] pub pruned_at: String, - // TODO: the strict manifest shape that the write path takes tightens - // this to a required `Registries`. Revisit when the install-pipeline - // port supplies a producer that always populates `default`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub registries: Option>, - /// Legacy: the v5-era flag used to mean "hoist everything publicly." /// Replaced by [`Self::public_hoist_pattern`]. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -244,8 +238,6 @@ pub struct ModulesLayout { #[serde(default)] pub pruned_at: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub registries: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] pub public_hoist_pattern: Option>, /// Legacy: the v5-era flag used to mean "hoist everything publicly." /// Needed by [`read_modules_layout`] to apply the same normalization as diff --git a/pnpm/crates/napi/src/config.rs b/pnpm/crates/napi/src/config.rs index c8a8b60ccf..c09e609edd 100644 --- a/pnpm/crates/napi/src/config.rs +++ b/pnpm/crates/napi/src/config.rs @@ -260,11 +260,11 @@ fn build_config(dir: &Path, overlay: &ConfigOverlay) -> Result ResolvedConfig { // The default registry lives in `config.registry`; the `registries` // map carries it only when something set a `default` key explicitly. - let default_entry = (!config.registries.contains_key("default")) + let default_entry = (!config.registries_by_scope.contains_key("default")) .then(|| ("default".to_string(), config.registry.clone())); let registries = default_entry .iter() .map(|(name, url)| (name, url)) - .chain(config.registries.iter()) + .chain(config.registries_by_scope.iter()) .map(|(name, url)| { let uri = nerf_dart(url); let scoped = by_scope.get(&uri); diff --git a/pnpm/crates/napi/src/read_config/tests.rs b/pnpm/crates/napi/src/read_config/tests.rs index 21f6c6bb58..7dd967ec9c 100644 --- a/pnpm/crates/napi/src/read_config/tests.rs +++ b/pnpm/crates/napi/src/read_config/tests.rs @@ -9,7 +9,7 @@ use super::{import_method_name, project_config}; fn config_with_auth(by_scope: AuthHeadersByScope) -> Config { Config { registry: "https://reg.example/npm/".to_string(), - registries: BTreeMap::from([( + registries_by_scope: BTreeMap::from([( "@scope".to_string(), "https://reg.example/scoped/".to_string(), )]), diff --git a/pnpm/crates/napi/src/resolve.rs b/pnpm/crates/napi/src/resolve.rs index fb530de4ee..4e37a9b754 100644 --- a/pnpm/crates/napi/src/resolve.rs +++ b/pnpm/crates/napi/src/resolve.rs @@ -158,7 +158,7 @@ fn run_resolve_blocking( // `config.registries` alone omits it, which would leave the picker with a // host-less `/pkg` URL. registries: config.resolved_registries().into_iter().collect(), - named_registries: config.named_registries.clone().into_iter().collect(), + registries_by_prefix: config.registries_by_prefix.clone().into_iter().collect(), http_client: Arc::clone(&http_client), auth_headers: Arc::clone(&config.auth_headers), meta_cache: shared_in_memory_cache(), @@ -206,13 +206,14 @@ fn run_resolve_blocking( // built-ins (today: `gh:` → GitHub Packages). A malformed URL here // fails fast with `ERR_PNPM_INVALID_NAMED_REGISTRY_URL`, matching the // install path. - let user_named_registries: HashMap = - config.named_registries.iter().map(|(name, url)| (name.clone(), url.clone())).collect(); - let merged_named_registries = - merge_named_registries(&user_named_registries).map_err(|error| to_napi_error(&error))?; - let named_registry_aliases: HashSet = merged_named_registries.keys().cloned().collect(); + let user_registries_by_prefix: HashMap = + config.registries_by_prefix.iter().map(|(name, url)| (name.clone(), url.clone())).collect(); + let merged_registries_by_prefix = merge_named_registries(&user_registries_by_prefix) + .map_err(|error| to_napi_error(&error))?; + let named_registry_aliases: HashSet = + merged_registries_by_prefix.keys().cloned().collect(); let named_registry_resolver = NamedRegistryResolver { - named_registries: merged_named_registries, + registries_by_prefix: merged_registries_by_prefix, registry_names: named_registry_aliases, http_client: Arc::clone(&http_client), auth_headers: Arc::clone(&config.auth_headers), diff --git a/pnpm/crates/network/src/auth.rs b/pnpm/crates/network/src/auth.rs index bea5971fa1..3168fde6da 100644 --- a/pnpm/crates/network/src/auth.rs +++ b/pnpm/crates/network/src/auth.rs @@ -48,6 +48,21 @@ pub trait UpstreamRouteHook: Send + Sync { /// route the decision selected. `None` means fetch anonymously. fn authorize(&self, url: &str, package: Option<&str>) -> Option; + /// Whether this deployment may fetch `url` at all. + /// + /// A server resolves on behalf of callers who describe their own + /// registries, so a URL it was told about is not automatically one it may + /// reach. Answered at the fetch itself rather than when the request is + /// read, so a registry a caller merely configures — a scope it never + /// resolves a package from — costs nothing, while one it does resolve + /// from is refused before the request leaves the process. + /// + /// Defaults to `true` for hooks with no such policy (the CLI fetches as + /// the user, who may reach whatever they configured). + fn allows_fetch(&self, _url: &str) -> bool { + true + } + /// Classify the metadata cache scope for a fetch to `url` for package /// `package` (`None` for non-package fetches). Unlike [`Self::authorize`] /// this is a read-only query — it must **not** record into the resolve's @@ -380,6 +395,14 @@ impl AuthHeaders { self } + /// Whether the fetch to `url` is permitted, per an attached + /// [`UpstreamRouteHook`]. Always true without one — see + /// [`UpstreamRouteHook::allows_fetch`]. + #[must_use] + pub fn allows_fetch(&self, url: &str) -> bool { + self.route_hook.as_ref().is_none_or(|hook| hook.allows_fetch(url)) + } + /// Record the route for a metadata/tarball fetch that is about to be /// served from an in-memory or on-disk cache *without* an HTTP /// request, so a server [`UpstreamRouteHook`]'s footprint still diff --git a/pnpm/crates/package-manager/src/add/tests.rs b/pnpm/crates/package-manager/src/add/tests.rs index 899e9b0bc8..76c0b08d2e 100644 --- a/pnpm/crates/package-manager/src/add/tests.rs +++ b/pnpm/crates/package-manager/src/add/tests.rs @@ -88,7 +88,7 @@ async fn add_routes_scoped_packages_to_configured_scoped_registry() { config.modules_dir = modules_dir; config.virtual_store_dir = virtual_store_dir; config.registry = format!("{}/", default_registry.url()); - config.registries.insert("@private".to_string(), scoped_registry_url); + config.registries_by_scope.insert("@private".to_string(), scoped_registry_url); config.minimum_release_age = None; let config = config.leak(); @@ -177,7 +177,7 @@ async fn add_resolves_package_selectors_concurrently_and_reports_in_selector_ord let package_name = format!("@{scope}/{name}"); let mut server = mockito::Server::new_async().await; let registry_url = format!("{}/", server.url()); - config.registries.insert(format!("@{scope}"), registry_url.clone()); + config.registries_by_scope.insert(format!("@{scope}"), registry_url.clone()); let response_body = version_body(&package_name, ®istry_url); let state = Arc::clone(&request_state); @@ -409,7 +409,7 @@ async fn add_reports_resolution_errors_in_selector_order() { for (scope, name, response_delay_ms) in packages { let package_name = format!("@{scope}/{name}"); let mut server = mockito::Server::new_async().await; - config.registries.insert(format!("@{scope}"), format!("{}/", server.url())); + config.registries_by_scope.insert(format!("@{scope}"), format!("{}/", server.url())); let latest_path = format!("/@{scope}%2F{name}/latest"); let latest = server .mock("GET", latest_path.as_str()) @@ -487,7 +487,7 @@ async fn add_does_not_wait_for_a_slower_later_resolution_after_an_error() { for (scope, name, response_delay_ms) in packages { let package_name = format!("@{scope}/{name}"); let mut server = mockito::Server::new_async().await; - config.registries.insert(format!("@{scope}"), format!("{}/", server.url())); + config.registries_by_scope.insert(format!("@{scope}"), format!("{}/", server.url())); let latest_path = format!("/@{scope}%2F{name}/latest"); let latest = server .mock("GET", latest_path.as_str()) diff --git a/pnpm/crates/package-manager/src/build_resolution_verifiers.rs b/pnpm/crates/package-manager/src/build_resolution_verifiers.rs index 9dd66554cd..de7e191f2e 100644 --- a/pnpm/crates/package-manager/src/build_resolution_verifiers.rs +++ b/pnpm/crates/package-manager/src/build_resolution_verifiers.rs @@ -104,8 +104,8 @@ pub fn build_resolution_verifiers( // tarball-prefix routing see the same set. Validated here too: this runs // before the resolver chain that also validates, and on the frozen path // that chain never runs. - let named_registries = merge_named_registries( - &config.named_registries.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + let registries_by_prefix = merge_named_registries( + &config.registries_by_prefix.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), ) .map_err(|source| BuildVerifiersError::InvalidNamedRegistries { source })?; @@ -125,7 +125,7 @@ pub fn build_resolution_verifiers( trust_policy_exclude_patterns: config.trust_policy_exclude.clone().unwrap_or_default(), trust_policy_ignore_after: config.trust_policy_ignore_after, registries, - named_registries, + registries_by_prefix, http_client, auth_headers: auth_override.unwrap_or_else(|| Arc::clone(&config.auth_headers)), cache_dir: Some(config.cache_dir.clone()), diff --git a/pnpm/crates/package-manager/src/build_resolution_verifiers/tests.rs b/pnpm/crates/package-manager/src/build_resolution_verifiers/tests.rs index 7ca4730daa..42ea9cc3df 100644 --- a/pnpm/crates/package-manager/src/build_resolution_verifiers/tests.rs +++ b/pnpm/crates/package-manager/src/build_resolution_verifiers/tests.rs @@ -17,7 +17,7 @@ const FAKE_INTEGRITY: &str = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA #[test] fn reserved_named_registry_is_an_error_not_a_panic() { let mut config = Config::default(); - config.named_registries.insert("workspace".to_string(), "https://npm.example/".to_string()); + config.registries_by_prefix.insert("workspace".to_string(), "https://npm.example/".to_string()); let result = build_resolution_verifiers( &config, diff --git a/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile.rs b/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile.rs index 659eddf86f..da33c553bf 100644 --- a/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile.rs +++ b/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile.rs @@ -14,10 +14,11 @@ use pnpm_catalogs_protocol_parser::parse_catalog_protocol; use pnpm_catalogs_types::Catalogs; use pnpm_lockfile::{ BundledDependencies, CatalogSnapshots, ComVer, ImporterDepVersion, Lockfile, - LockfileResolution, LockfileSettings, LockfileVersion, PackageKey, PackageMetadata, - ParseImporterDepVersionError, ParsePkgNameSuffixError, ParsePkgVerPeerError, - PeerDependencyMeta, PkgName, PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, ResolvedCatalogEntry, - ResolvedDependencyMap, ResolvedDependencySpec, SnapshotDepRef, SnapshotEntry, VersionPart, + LockfileFormOptions, LockfileResolution, LockfileSettings, LockfileVersion, PackageKey, + PackageMetadata, ParseImporterDepVersionError, ParsePkgNameSuffixError, ParsePkgVerPeerError, + PeerDependencyMeta, PkgName, PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, RegistryOptions, + ResolvedCatalogEntry, ResolvedDependencyMap, ResolvedDependencySpec, SnapshotDepRef, + SnapshotEntry, VersionPart, registry_server_type, }; use pnpm_package_manifest::{DependencyGroup, PackageManifest}; use pnpm_resolving_deps_resolver::{ @@ -111,7 +112,8 @@ pub struct GraphToLockfileOptions<'a> { /// user's setting). Registry-qualified package keys route their /// tarball-reconstructibility check through this map instead of the /// default registry. - pub named_registries: &'a HashMap, + pub registries_by_prefix: &'a HashMap, + pub registry_options_by_url: &'a BTreeMap, /// When `true`, registry tarball URLs are kept in the lockfile even when /// reconstructible (the `lockfileIncludeTarballUrl` setting). pub lockfile_include_tarball_url: bool, @@ -215,7 +217,8 @@ pub fn dependencies_graph_to_lockfile( pnpmfile_checksum, catalogs, registry, - named_registries, + registries_by_prefix, + registry_options_by_url, lockfile_include_tarball_url, previous_importers, previous_packages, @@ -230,7 +233,8 @@ pub fn dependencies_graph_to_lockfile( &optional_overrides, &PackageMetadataSources { registry, - named_registries, + registries_by_prefix, + registry_options_by_url, lockfile_include_tarball_url, previous_packages, }, @@ -670,7 +674,8 @@ type PackagesAndSnapshots = /// from, beyond the graph itself. struct PackageMetadataSources<'a> { registry: &'a str, - named_registries: &'a HashMap, + registries_by_prefix: &'a HashMap, + registry_options_by_url: &'a BTreeMap, lockfile_include_tarball_url: bool, previous_packages: Option<&'a HashMap>, } @@ -714,7 +719,7 @@ fn build_packages_and_snapshots( // entry that no install can fetch. Keeping the URL is always // recoverable, so an unknown alias forces it to be written. let (registry, include_tarball_url) = match key.suffix.registry_qualified() { - Some((registry_name, _)) => match sources.named_registries.get(registry_name) { + Some((registry_name, _)) => match sources.registries_by_prefix.get(registry_name) { Some(named_registry) => { (named_registry.as_str(), sources.lockfile_include_tarball_url) } @@ -722,7 +727,15 @@ fn build_packages_and_snapshots( }, None => (sources.registry, sources.lockfile_include_tarball_url), }; - let mut metadata = build_package_metadata(node, key, registry, include_tarball_url); + let mut metadata = build_package_metadata( + node, + key, + LockfileFormOptions { + registry, + server_type: registry_server_type(sources.registry_options_by_url, registry), + include_tarball_url, + }, + ); // `deprecated` is the only registry-mutable field of a // published version; an unchanged resolution must not lose // a recorded deprecation to a registry serving it @@ -751,8 +764,7 @@ fn build_packages_and_snapshots( fn build_package_metadata( node: &DependenciesGraphNode, metadata_key: &PackageKey, - registry: &str, - lockfile_include_tarball_url: bool, + lockfile_form: LockfileFormOptions<'_>, ) -> PackageMetadata { let manifest = node.resolve_result.manifest.as_deref(); @@ -807,8 +819,7 @@ fn build_package_metadata( let resolution = node.resolve_result.resolution.to_lockfile_form( &metadata_key.name.to_string(), &resolution_version, - registry, - lockfile_include_tarball_url, + lockfile_form, ); // Record `version` only for non-registry packages (depPath carries diff --git a/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs b/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs index 993e2dcfc1..e8b3f1d084 100644 --- a/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs +++ b/pnpm/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs @@ -20,6 +20,9 @@ use pnpm_resolving_deps_resolver::{ use pnpm_resolving_resolver_base::{PkgResolutionId, ResolveResult}; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +static EMPTY_REGISTRY_OPTIONS: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + static EMPTY_NAMED_REGISTRIES: std::sync::LazyLock> = std::sync::LazyLock::new(std::collections::HashMap::new); use serde_json::json; @@ -66,7 +69,8 @@ fn single_importer_opts<'a>( ImporterLockfileInput { manifest, direct_dependencies_by_alias: direct }, ); GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph, auto_install_peers, @@ -462,7 +466,8 @@ fn dedupe_peers_round_trips_through_lockfile_settings() { ImporterLockfileInput { manifest: &manifest, direct_dependencies_by_alias: direct.clone() }, ); let on = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -495,7 +500,8 @@ fn dedupe_peers_round_trips_through_lockfile_settings() { ImporterLockfileInput { manifest: &manifest, direct_dependencies_by_alias: direct }, ); let off = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -582,7 +588,8 @@ fn patched_dependencies_flow_into_lockfile_and_empty_is_omitted() { }, ); dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -742,7 +749,8 @@ fn aliased_catalog_dependency_records_catalog_snapshot() { ImporterLockfileInput { manifest: &manifest, direct_dependencies_by_alias: direct }, ); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -1802,7 +1810,8 @@ fn snapshot_link_uses_lockfile_root_while_importer_link_uses_project_root() { )]); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -1895,7 +1904,8 @@ fn multi_importer_workspace_writes_per_project_lockfile_entries() { ); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -2006,7 +2016,8 @@ fn multi_importer_pruner_marks_shared_dep_non_optional_when_any_importer_reaches ); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -2143,7 +2154,8 @@ fn workspace_sibling_link_renders_per_importer_with_link_ref() { ); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, importers, graph: &graph, auto_install_peers: false, @@ -2452,7 +2464,8 @@ fn injected_workspace_dep_keeps_prior_link_on_untargeted_install() { let previous = previous_importers_with_link("n", "workspace:*", "../n"); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: Some(&previous), update_reuse_scope: UpdateReuseScope::All, ..single_importer_opts(&manifest, &graph, direct, false, false, None, None) @@ -2477,7 +2490,8 @@ fn injected_workspace_dep_renders_file_without_prior_link() { let (_tmp, manifest, graph, direct) = injected_link_fixture(); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: None, previous_packages: None, update_reuse_scope: UpdateReuseScope::All, @@ -2506,7 +2520,8 @@ fn injected_workspace_dep_flips_to_file_when_update_targets_it() { let previous = previous_importers_with_link("n", "workspace:*", "../n"); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: Some(&previous), update_reuse_scope: UpdateReuseScope::Except(HashSet::from_iter(["n".to_string()])), ..single_importer_opts(&manifest, &graph, direct, false, false, None, None) @@ -2535,7 +2550,8 @@ fn injected_workspace_dep_flips_to_file_when_specifier_changed() { let previous = previous_importers_with_link("n", "workspace:^1.0.0", "../n"); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: Some(&previous), update_reuse_scope: UpdateReuseScope::All, ..single_importer_opts(&manifest, &graph, direct, false, false, None, None) @@ -2570,7 +2586,8 @@ fn injected_workspace_dep_flips_to_file_when_recursive_update_targets_it_per_imp )]); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: Some(&previous), update_reuse_scope: UpdateReuseScope::All, update_reuse_scopes_by_importer: scopes_by_importer, @@ -2607,7 +2624,8 @@ fn injected_workspace_dep_keeps_link_when_recursive_update_targets_other_pkg() { )]); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: Some(&previous), update_reuse_scope: UpdateReuseScope::All, update_reuse_scopes_by_importer: scopes_by_importer, @@ -2635,7 +2653,8 @@ fn injected_workspace_dep_flips_to_file_on_scope_wide_update() { let previous = previous_importers_with_link("n", "workspace:*", "../n"); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { - named_registries: &EMPTY_NAMED_REGISTRIES, + registries_by_prefix: &EMPTY_NAMED_REGISTRIES, + registry_options_by_url: &EMPTY_REGISTRY_OPTIONS, previous_importers: Some(&previous), update_reuse_scope: UpdateReuseScope::None, ..single_importer_opts(&manifest, &graph, direct, false, false, None, None) @@ -2746,9 +2765,9 @@ fn named_registry_package_keeps_the_format_and_drops_a_canonical_tarball() { let mut direct = BTreeMap::new(); direct.insert("foo".to_string(), DepPath::from("foo@work:1.0.0".to_string())); - let named_registries = named_registries_with("work", "https://npm.enterprise.example.com/"); + let registries_by_prefix = named_registries_with("work", "https://npm.enterprise.example.com/"); let mut opts = single_importer_opts(&manifest, &graph, direct, true, false, None, None); - opts.named_registries = &named_registries; + opts.registries_by_prefix = ®istries_by_prefix; let lockfile = dependencies_graph_to_lockfile(opts); diff --git a/pnpm/crates/package-manager/src/fast_update_catalog_versions/tests.rs b/pnpm/crates/package-manager/src/fast_update_catalog_versions/tests.rs index 39d6141f21..984a9739d4 100644 --- a/pnpm/crates/package-manager/src/fast_update_catalog_versions/tests.rs +++ b/pnpm/crates/package-manager/src/fast_update_catalog_versions/tests.rs @@ -106,6 +106,7 @@ async fn try_update(lockfile: &Lockfile, catalogs: &Catalogs, manifest: Value) - resolve_options: &resolve_options, manifest_hook: None, registries: ®istries, + registry_options_by_url: &std::collections::BTreeMap::new(), lockfile_include_tarball_url: false, }, catalogs, diff --git a/pnpm/crates/package-manager/src/fast_update_overrides.rs b/pnpm/crates/package-manager/src/fast_update_overrides.rs index 8278541773..bd77311ab0 100644 --- a/pnpm/crates/package-manager/src/fast_update_overrides.rs +++ b/pnpm/crates/package-manager/src/fast_update_overrides.rs @@ -3,15 +3,16 @@ use indexmap::IndexMap; use node_semver::{Range, Version}; use pnpm_config_parse_overrides::{PackageSelector, VersionOverride}; use pnpm_lockfile::{ - BundledDependencies, ImporterDepVersion, Lockfile, LockfileResolution, PackageKey, - PackageMetadata, PkgName, PkgNameVerPeer, PkgVerPeer, Prefix, ResolvedDependencyMap, - SnapshotDepRef, SnapshotEntry, StringOrList, pick_registry_for_package, + BundledDependencies, ImporterDepVersion, Lockfile, LockfileFormOptions, LockfileResolution, + PackageKey, PackageMetadata, PkgName, PkgNameVerPeer, PkgVerPeer, Prefix, RegistryOptions, + ResolvedDependencyMap, SnapshotDepRef, SnapshotEntry, StringOrList, pick_registry_for_package, + registry_server_type, }; use pnpm_resolving_deps_resolver::ManifestHook; use pnpm_resolving_resolver_base::{ResolveOptions, ResolveResult, Resolver, WantedDependency}; use serde_json::Value; use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, sync::Arc, }; @@ -48,6 +49,7 @@ pub(crate) struct RewriteContext<'a> { pub resolve_options: &'a ResolveOptions, pub manifest_hook: Option<&'a ManifestHook>, pub registries: &'a HashMap, + pub registry_options_by_url: &'a BTreeMap, pub lockfile_include_tarball_url: bool, } @@ -408,13 +410,18 @@ fn rewrite_lockfile( } snapshots.insert(new_key.clone(), snapshot); let metadata_key = new_key.without_peer(); + let registry = + pick_registry_for_package(context.registries, &old_key.name.to_string(), None); let metadata = package_metadata( &replacement.manifest, replacement.resolution.to_lockfile_form( &old_key.name.to_string(), &new_key.suffix.version().to_string(), - &pick_registry_for_package(context.registries, &old_key.name.to_string(), None), - context.lockfile_include_tarball_url, + LockfileFormOptions { + registry: ®istry, + server_type: registry_server_type(context.registry_options_by_url, ®istry), + include_tarball_url: context.lockfile_include_tarball_url, + }, ), ); if let Some(existing) = packages.get(&metadata_key) diff --git a/pnpm/crates/package-manager/src/fast_update_overrides/tests.rs b/pnpm/crates/package-manager/src/fast_update_overrides/tests.rs index 91d0160cd8..372cd9bf02 100644 --- a/pnpm/crates/package-manager/src/fast_update_overrides/tests.rs +++ b/pnpm/crates/package-manager/src/fast_update_overrides/tests.rs @@ -136,6 +136,7 @@ async fn try_update( resolve_options: &resolve_options, manifest_hook: None, registries: ®istries, + registry_options_by_url: &std::collections::BTreeMap::new(), lockfile_include_tarball_url: false, }, parsed_overrides, diff --git a/pnpm/crates/package-manager/src/install/modules_state.rs b/pnpm/crates/package-manager/src/install/modules_state.rs index 68253bec21..d49cdbf8d2 100644 --- a/pnpm/crates/package-manager/src/install/modules_state.rs +++ b/pnpm/crates/package-manager/src/install/modules_state.rs @@ -454,7 +454,6 @@ pub(super) fn build_modules_manifest( // this is a fresh timestamp (a prune ran or first install) or the // preserved prior value. pruned_at, - registries: Some(config.resolved_registries()), // `iter_installability` excludes fetch-failure entries so they // don't get persisted across installs — optional fetch failures // are silently swallowed. diff --git a/pnpm/crates/package-manager/src/install/tests.rs b/pnpm/crates/package-manager/src/install/tests.rs index 231659eb77..3df2f4d220 100644 --- a/pnpm/crates/package-manager/src/install/tests.rs +++ b/pnpm/crates/package-manager/src/install/tests.rs @@ -913,7 +913,7 @@ async fn lockfile_only_routes_scoped_packages_to_configured_scoped_registry() { config.modules_dir = modules_dir; config.virtual_store_dir = virtual_store_dir; config.registry = format!("{}/", default_registry.url()); - config.registries.insert("@private".to_string(), scoped_registry_url); + config.registries_by_scope.insert("@private".to_string(), scoped_registry_url); let config = config.leak(); Install { @@ -1480,7 +1480,7 @@ async fn install_emits_pnpm_event_sequence() { config.modules_dir = modules_dir.clone(); config.virtual_store_dir = virtual_store_dir.clone(); config - .registries + .registries_by_scope .insert("@private".to_string(), "https://private.example.com/npm/".to_string()); let config = config.leak(); @@ -1642,7 +1642,7 @@ async fn install_writes_modules_yaml() { config.modules_dir = modules_dir.clone(); config.virtual_store_dir = virtual_store_dir.clone(); config - .registries + .registries_by_scope .insert("@private".to_string(), "https://private.example.com/npm/".to_string()); let config = config.leak(); @@ -1707,7 +1707,6 @@ async fn install_writes_modules_yaml() { store_dir: emitted_store_dir, virtual_store_dir: emitted_virtual_store_dir, virtual_store_dir_max_length, - registries, package_manager, .. } = modules_dir @@ -1726,14 +1725,6 @@ async fn install_writes_modules_yaml() { // to the absolute install-time path. assert_eq!(emitted_virtual_store_dir, virtual_store_dir.to_string_lossy()); assert_eq!(virtual_store_dir_max_length, pnpm_config::default_virtual_store_dir_max_length()); - assert_eq!( - registries.as_ref().and_then(|r| r.get("default")).map(String::as_str), - Some(config.registry.as_str()), - ); - assert_eq!( - registries.as_ref().and_then(|r| r.get("@private")).map(String::as_str), - Some("https://private.example.com/npm/"), - ); assert_eq!(package_manager, format!("pnpm@{}", pnpm_config::PNPM_VERSION)); drop(dir); diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs index 2091afd68f..9641978551 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -790,7 +790,7 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { init_store_dir_best_effort(store_dir).await; } - let resolver_setup::Registries { by_scope: registries, named: merged_named_registries } = + let resolver_setup::Registries { by_scope: registries, named: merged_registries_by_prefix } = resolver_setup::resolve_registries(config)?; // `resolutionMode` / `minimumReleaseAge` derivations. `time_based` @@ -847,7 +847,7 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { requester, supported_architectures, registries: ®istries, - named_registries: &merged_named_registries, + registries_by_prefix: &merged_registries_by_prefix, full_metadata, wanted_lockfile, store_index: store_index_ref, @@ -1065,7 +1065,7 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { update_reuse_scopes_by_importer, update_depth: update_seed_policy.max_depth(), registries, - named_registries: merged_named_registries.clone(), + registries_by_prefix: merged_registries_by_prefix.clone(), }) .await?; crate::minimum_release_age::handle_minimum_release_age_violations::( @@ -2145,15 +2145,18 @@ fn build_fresh_lockfile( } // Same merge the resolver chain performs; the config was already // validated at resolver construction, so skip re-validation here. - let named_registries: HashMap = - pnpm_resolving_npm_resolver::BUILTIN_NAMED_REGISTRIES + let registries_by_prefix: HashMap = + pnpm_resolving_npm_resolver::BUILTIN_REGISTRIES_BY_PREFIX .iter() .map(|(name, url)| ((*name).to_string(), (*url).to_string())) - .chain(config.named_registries.iter().map(|(name, url)| (name.clone(), url.clone()))) + .chain( + config.registries_by_prefix.iter().map(|(name, url)| (name.clone(), url.clone())), + ) .collect(); let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions { importers, graph, + registry_options_by_url: &config.registry_options_by_url, auto_install_peers: config.auto_install_peers, dedupe_peers: config.dedupe_peers, exclude_links_from_lockfile: config.exclude_links_from_lockfile, @@ -2168,7 +2171,7 @@ fn build_fresh_lockfile( pnpmfile_checksum: pnpmfile_checksum.map(str::to_string), catalogs, registry: &config.registry, - named_registries: &named_registries, + registries_by_prefix: ®istries_by_prefix, lockfile_include_tarball_url: config.lockfile_include_tarball_url, previous_importers, previous_packages: wanted_lockfile.and_then(|lockfile| lockfile.packages.as_ref()), diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs index 74ab700afa..81477894ff 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs @@ -315,6 +315,7 @@ pub(super) async fn lockfile_reuse_seed(inputs: ReuseSeedInputs<'_>) -> Option) -> Option { BTreeMap, pub update_depth: pnpm_resolving_deps_resolver::UpdateDepth, pub registries: HashMap, - pub named_registries: HashMap, + pub registries_by_prefix: HashMap, } /// Walk every importer's dependencies through the resolver chain. @@ -456,7 +458,7 @@ pub(super) async fn run_resolve_pass( update_reuse_scopes_by_importer, update_depth, registries, - named_registries, + registries_by_prefix, } = inputs; let workspace_importers: Vec> = @@ -475,6 +477,11 @@ pub(super) async fn run_resolve_pass( .map_or_else(|| std::ffi::OsString::from("node_modules"), std::ffi::OsStr::to_os_string); let workspace_opts = pnpm_resolving_deps_resolver::WorkspaceResolveOptions { + registry_context: pnpm_lockfile::RegistryContext { + registries, + registries_by_prefix, + registry_options_by_url: config.registry_options_by_url.clone(), + }, dedupe_peers: config.dedupe_peers, dedupe_injected_deps: config.dedupe_injected_deps, dedupe_peer_dependents: config.dedupe_peer_dependents, @@ -494,8 +501,6 @@ pub(super) async fn run_resolve_pass( update_reuse_scopes_by_importer, update_depth, auto_install_peers: config.auto_install_peers, - registries, - named_registries, allowed_deprecated_versions: config.allowed_deprecated_versions.clone(), deprecation_log: Some(super::deprecation_log_fn::()), }; diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolver_setup.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolver_setup.rs index 317e5f7965..625842156d 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolver_setup.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolver_setup.rs @@ -75,11 +75,11 @@ pub(super) struct Registries { pub(super) fn resolve_registries( config: &Config, ) -> Result { - let user_named_registries: HashMap = - config.named_registries.iter().map(|(name, url)| (name.clone(), url.clone())).collect(); + let user_registries_by_prefix: HashMap = + config.registries_by_prefix.iter().map(|(name, url)| (name.clone(), url.clone())).collect(); Ok(Registries { by_scope: config.resolved_registries().into_iter().collect(), - named: merge_named_registries(&user_named_registries) + named: merge_named_registries(&user_registries_by_prefix) .map_err(InstallWithFreshLockfileError::InvalidNamedRegistry)?, }) } @@ -141,7 +141,7 @@ pub(super) struct ResolverChainInputs<'a> { pub requester: &'a str, pub supported_architectures: Option<&'a pnpm_package_is_installable::SupportedArchitectures>, pub registries: &'a HashMap, - pub named_registries: &'a HashMap, + pub registries_by_prefix: &'a HashMap, /// See `NpmResolver::full_metadata` — forced on when `time-based` /// resolution or the `no-downgrade` trust policy needs the /// per-version `time` field. @@ -203,7 +203,7 @@ pub(super) async fn build_resolver_chain = Arc::new(NpmResolver { registries: registries.clone(), - named_registries: named_registries.clone(), + registries_by_prefix: registries_by_prefix.clone(), http_client: Arc::clone(http_client_arc), auth_headers: Arc::clone(auth_headers), meta_cache: Arc::clone(meta_cache), @@ -304,8 +304,8 @@ pub(super) async fn build_resolver_chain( let policy = PickPolicy::from_config_with_extra_excludes(ctx.config, extra_excludes.as_deref()) .map_err(UpdateError::MinimumReleaseAgeExclude)?; - let named_registries = - merge_named_registries(&ctx.config.named_registries.clone().into_iter().collect()) + let registries_by_prefix = + merge_named_registries(&ctx.config.registries_by_prefix.clone().into_iter().collect()) .map_err(UpdateError::InvalidNamedRegistry)?; let npm_resolver: Arc = Arc::new(NpmResolver { registries: ctx.config.resolved_registries().into_iter().collect(), - named_registries, + registries_by_prefix, http_client: Arc::clone(ctx.http_client_arc), auth_headers: Arc::clone(&ctx.config.auth_headers), meta_cache: Arc::::default(), diff --git a/pnpm/crates/pnpr-client/src/lib.rs b/pnpm/crates/pnpr-client/src/lib.rs index 53ade1a0b4..1654dcbe36 100644 --- a/pnpm/crates/pnpr-client/src/lib.rs +++ b/pnpm/crates/pnpr-client/src/lib.rs @@ -22,10 +22,13 @@ use std::collections::{BTreeMap, HashSet}; use derive_more::{Display, Error, From}; use futures_util::StreamExt as _; use pnpm_catalogs_types::Catalogs; -use pnpm_config::TrustPolicy; +use pnpm_config::{RegistryDeclaration, TrustPolicy}; use pnpm_lockfile::Lockfile; use pnpm_lockfile_verification::{RenderedViolation, VerifyError}; use reqwest::Client; + +/// The `registries` a request declares, keyed by registry URL. +pub type RegistryDeclarations = BTreeMap; use serde::{Deserialize, Serialize}; /// Dependency map (`name` -> `version range`). @@ -45,10 +48,14 @@ pub struct ResolveOptions { pub dev_dependencies: DepMap, pub optional_dependencies: DepMap, /// The client's default registry. The server resolves against this - /// (and `named_registries`) rather than its own configuration. + /// (and the registries declared alongside it) rather than its own + /// configuration. pub registry: String, /// The client's named-registry aliases. - pub named_registries: DepMap, + /// The registries the client declares, keyed by URL, in the shape + /// of the `registries` setting. The default registry is not among + /// them: it travels as `registry`. + pub registries: RegistryDeclarations, /// `Authorization` for the pnpr server's own URL (`None` if it needs /// none): identifies the caller to pnpr. The client never forwards its /// own registry credentials — pnpr selects upstream credentials from @@ -110,7 +117,10 @@ pub struct ResolveProject { pub struct ResolveProjectsOptions { pub projects: Vec, pub registry: String, - pub named_registries: DepMap, + /// The registries the client declares, keyed by URL, in the shape + /// of the `registries` setting. The default registry is not among + /// them: it travels as `registry`. + pub registries: RegistryDeclarations, pub authorization: Option, pub overrides: Option, pub catalogs: Option, @@ -139,7 +149,7 @@ impl From for ResolveProjectsOptions { optional_dependencies: opts.optional_dependencies, }], registry: opts.registry, - named_registries: opts.named_registries, + registries: opts.registries, authorization: opts.authorization, overrides: opts.overrides, catalogs: opts.catalogs, @@ -163,7 +173,10 @@ impl From for ResolveProjectsOptions { #[derive(Clone)] pub struct VerifyLockfileOptions { pub registry: String, - pub named_registries: DepMap, + /// The registries the client declares, keyed by URL, in the shape + /// of the `registries` setting. The default registry is not among + /// them: it travels as `registry`. + pub registries: RegistryDeclarations, pub authorization: Option, pub overrides: Option, pub lockfile: Lockfile, @@ -190,7 +203,7 @@ impl VerifyLockfileOptions { fn from_owned_resolve_projects_options(opts: ResolveProjectsOptions) -> Option { Some(Self { registry: opts.registry, - named_registries: opts.named_registries, + registries: opts.registries, authorization: opts.authorization, overrides: opts.overrides, lockfile: opts.lockfile?, @@ -342,7 +355,7 @@ impl PnprClient { ) -> Result<(), PnprClientError> { let request = serde_json::json!({ "registry": opts.registry, - "namedRegistries": opts.named_registries, + "registries": opts.registries, "overrides": opts.overrides, "lockfile": opts.lockfile, "trustLockfile": opts.trust_lockfile, @@ -435,7 +448,7 @@ impl PnprClient { let request = serde_json::json!({ "projects": opts.projects, "registry": opts.registry, - "namedRegistries": opts.named_registries, + "registries": opts.registries, "overrides": opts.overrides, "catalogs": opts.catalogs, "lockfile": opts.lockfile, diff --git a/pnpm/crates/pnpr-client/src/tests.rs b/pnpm/crates/pnpr-client/src/tests.rs index 3ceb7302be..3cf6984ab4 100644 --- a/pnpm/crates/pnpr-client/src/tests.rs +++ b/pnpm/crates/pnpr-client/src/tests.rs @@ -51,7 +51,7 @@ fn resolve_projects_options() -> ResolveProjectsOptions { optional_dependencies: BTreeMap::new(), }], registry: "https://registry.test/".to_string(), - named_registries: BTreeMap::new(), + registries: BTreeMap::new(), authorization: None, overrides: None, catalogs: Some(BTreeMap::from([( diff --git a/pnpm/crates/pnpr-client/tests/integration.rs b/pnpm/crates/pnpr-client/tests/integration.rs index de2a08f1cb..748fb8976a 100644 --- a/pnpm/crates/pnpr-client/tests/integration.rs +++ b/pnpm/crates/pnpr-client/tests/integration.rs @@ -19,6 +19,7 @@ use std::{ time::Duration, }; +use pnpm_config::RegistryDeclaration; use pnpm_pnpr_client::{ PnprClient, PnprClientError, ResolveOptions, ResolveProject, ResolveProjectsOptions, VerifyLockfileOptions, @@ -206,7 +207,7 @@ fn options( dev_dependencies: BTreeMap::new(), optional_dependencies: BTreeMap::new(), registry: registry.to_string(), - named_registries: BTreeMap::new(), + registries: BTreeMap::new(), authorization: Some(authorization.to_string()), overrides: None, catalogs: None, @@ -722,3 +723,83 @@ async fn handshake_rejects_a_non_pnpr_server() { assert!(err.to_string().contains("not a pnpr server"), "got: {err}"); mock.assert_async().await; } + +/// The client describes its registries to the server the way its own +/// `registries` setting does, so a scope the client routes elsewhere resolves +/// from that registry and not from the request's default one. +/// +/// This is also the contract test for the two ends of the protocol: the +/// server reads the declarations under the key the client writes them. +#[tokio::test] +async fn resolves_a_scope_from_the_registry_declared_for_it() { + let registry = TestRegistry::start(); + // A default registry that is allowlisted but serves nothing: reaching it + // for the scoped package is the failure this test is looking for. + let dead_default = "http://127.0.0.1:9/"; + let (pnpr_url, pnpr_auth, _storage) = + start_pnpr_inner(None, Vec::new(), vec![registry.url(), dead_default.to_string()]).await; + + let mut opts = options(dead_default, &pnpr_auth, deps([("@foo/no-deps", "1.0.0")])); + opts.registries = BTreeMap::from([( + registry.url(), + RegistryDeclaration { + scopes: Some(vec!["@foo".to_string()]), + ..RegistryDeclaration::default() + }, + )]); + + let outcome = PnprClient::new(pnpr_url).resolve(opts).await.expect("install should succeed"); + + let packages = outcome.lockfile.packages.as_ref().expect("lockfile has packages"); + assert!( + packages.keys().any(|key| key.to_string().starts_with("@foo/no-deps@1.0.0")), + "the declared registry should have served the scope, got: {:?}", + packages.keys().map(ToString::to_string).collect::>(), + ); +} + +/// A client describes its whole configuration, including scopes a given +/// resolve never reaches. Declaring a registry this pnpr does not serve is +/// therefore not an error by itself — only fetching from one is. +#[tokio::test] +async fn a_declared_registry_the_resolve_never_reaches_is_not_rejected() { + let registry = TestRegistry::start(); + let (pnpr_url, pnpr_auth, _storage) = start_pnpr(®istry.url()).await; + + let mut opts = options(®istry.url(), &pnpr_auth, deps([("@foo/no-deps", "1.0.0")])); + opts.registries = BTreeMap::from([( + "http://169.254.169.254/".to_string(), + RegistryDeclaration { + scopes: Some(vec!["@never-resolved".to_string()]), + ..RegistryDeclaration::default() + }, + )]); + + let outcome = PnprClient::new(pnpr_url).resolve(opts).await.expect("install should succeed"); + let packages = outcome.lockfile.packages.as_ref().expect("lockfile has packages"); + assert!(packages.keys().any(|key| key.to_string().starts_with("@foo/no-deps@1.0.0"))); +} + +/// The SSRF boundary still holds where it matters: a scope the resolve *does* +/// reach is refused before the request leaves the server. +#[tokio::test] +async fn a_declared_registry_the_resolve_reaches_is_refused() { + let registry = TestRegistry::start(); + let (pnpr_url, pnpr_auth, _storage) = start_pnpr(®istry.url()).await; + + let mut opts = options(®istry.url(), &pnpr_auth, deps([("@foo/no-deps", "1.0.0")])); + opts.registries = BTreeMap::from([( + "http://169.254.169.254/".to_string(), + RegistryDeclaration { + scopes: Some(vec!["@foo".to_string()]), + ..RegistryDeclaration::default() + }, + )]); + + let Err(error) = PnprClient::new(pnpr_url).resolve(opts).await else { + panic!("an off-allowlist registry the resolve reaches must be refused") + }; + let error = error.to_string(); + assert!(error.contains("is not allowed by this pnpr server"), "{error}"); + assert!(error.contains("169.254.169.254"), "the refused origin is named: {error}"); +} diff --git a/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse.rs b/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse.rs index 67bc1c9b53..b314b8c7e3 100644 --- a/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse.rs +++ b/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse.rs @@ -6,8 +6,8 @@ use node_semver::{Range, Version}; use pnpm_lockfile::{ Lockfile, LockfileResolution, PkgName, PkgNameVer, PkgNameVerPeer, ProjectSnapshot, - ResolvedDependencySpec, SnapshotEntry, TarballResolution, npm_tarball_url, - pick_registry_for_package, + RegistryContext, ResolvedDependencySpec, SnapshotEntry, TarballResolution, TarballUrlOptions, + npm_tarball_url, pick_registry_for_package, registry_server_type, }; use pnpm_resolving_parse_wanted_dependency::git_specifiers_are_equivalent; use pnpm_resolving_resolver_base::{CurrentPkg, PkgResolutionId, ResolveResult}; @@ -18,8 +18,7 @@ use serde_json::{Map, Value}; pub(crate) fn current_pkg_from_lockfile( lockfile: &Lockfile, key: &PkgNameVerPeer, - registries: &std::collections::HashMap, - named_registries: &std::collections::HashMap, + registry_context: &RegistryContext, ) -> Option { let metadata_key = key.without_peer(); let metadata = lockfile.packages.as_ref()?.get(&metadata_key)?; @@ -37,11 +36,12 @@ pub(crate) fn current_pkg_from_lockfile( // unknown alias (or an unthreaded registry map) withholds // `currentPkg` so the dep re-resolves normally. let (registry, tarball_version) = match registry_qualified { - Some((registry_name, version)) => { - (named_registries.get(registry_name)?.clone(), version.to_string()) - } + Some((registry_name, version)) => ( + registry_context.registries_by_prefix.get(registry_name)?.clone(), + version.to_string(), + ), None => ( - pick_registry_for_package(registries, &name, None), + pick_registry_for_package(®istry_context.registries, &name, None), metadata_key.suffix.version().to_string(), ), }; @@ -49,7 +49,17 @@ pub(crate) fn current_pkg_from_lockfile( return None; } LockfileResolution::Tarball(TarballResolution { - tarball: npm_tarball_url(&name, &tarball_version, ®istry), + tarball: npm_tarball_url( + &name, + &tarball_version, + TarballUrlOptions { + registry: ®istry, + server_type: registry_server_type( + ®istry_context.registry_options_by_url, + ®istry, + ), + }, + ), integrity: Some(registry_resolution.integrity.clone()), git_hosted: None, path: None, diff --git a/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs b/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs index dbb95557dd..2b9fa2781c 100644 --- a/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs +++ b/pnpm/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs @@ -1,5 +1,11 @@ use std::collections::HashMap; +/// The registry facts with only the scope map populated — what these tests +/// vary; the aliases and per-registry settings stay empty. +fn registry_context(registries: HashMap) -> pnpm_lockfile::RegistryContext { + pnpm_lockfile::RegistryContext { registries, ..Default::default() } +} + use pnpm_lockfile::{ ComVer, GitResolution, ImporterDepVersion, Lockfile, LockfileResolution, LockfileVersion, PackageMetadata, PkgName, PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, RegistryResolution, @@ -242,7 +248,7 @@ fn current_pkg_materializes_a_registry_resolution_into_its_tarball_url() { Some(HashMap::from([("react@18.2.0".parse().expect("parse key"), registry_metadata())])); let current_pkg = - super::current_pkg_from_lockfile(&lockfile, &key, &default_registry(), &HashMap::new()) + super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(default_registry())) .expect("packages entry exists"); assert_eq!(current_pkg.id.to_string(), "react@18.2.0"); @@ -264,7 +270,7 @@ fn current_pkg_routes_a_scoped_package_to_its_scope_registry() { registries.insert("@scope".to_string(), "https://scoped.example.test/".to_string()); let current_pkg = - super::current_pkg_from_lockfile(&lockfile, &key, ®istries, &HashMap::new()) + super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(registries)) .expect("packages entry exists"); let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else { @@ -287,7 +293,7 @@ fn current_pkg_passes_a_recorded_tarball_resolution_through() { lockfile.packages = Some(HashMap::from([(key.clone(), metadata)])); let current_pkg = - super::current_pkg_from_lockfile(&lockfile, &key, &default_registry(), &HashMap::new()) + super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(default_registry())) .expect("packages entry exists"); let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else { @@ -301,7 +307,7 @@ fn current_pkg_is_none_without_a_packages_entry() { let key: PkgNameVerPeer = "react@18.2.0".parse().expect("parse key"); let lockfile = empty_lockfile(); assert!( - super::current_pkg_from_lockfile(&lockfile, &key, &default_registry(), &HashMap::new()) + super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(default_registry())) .is_none(), ); } @@ -312,7 +318,7 @@ fn current_pkg_is_withheld_for_a_registry_entry_without_a_registry_map() { let mut lockfile = empty_lockfile(); lockfile.packages = Some(HashMap::from([(key.clone(), registry_metadata())])); assert!( - super::current_pkg_from_lockfile(&lockfile, &key, &HashMap::new(), &HashMap::new()) + super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(HashMap::new())) .is_none(), ); } diff --git a/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/walk.rs b/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/walk.rs index 42d496336e..6a94dc1549 100644 --- a/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/walk.rs +++ b/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/walk.rs @@ -250,12 +250,7 @@ where // exists for a freshly resolving edge. let current_pkg = prior_key.as_ref().and_then(|key| { let lockfile = ctx.workspace.wanted_lockfile.as_ref()?; - current_pkg_from_lockfile( - lockfile, - key, - &ctx.workspace.registries, - &ctx.workspace.named_registries, - ) + current_pkg_from_lockfile(lockfile, key, &ctx.workspace.registry_context) }); let opts_with_current_pkg; let opts = match current_pkg { diff --git a/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/workspace_ctx.rs b/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/workspace_ctx.rs index 21814facc6..320b3d5c2f 100644 --- a/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/workspace_ctx.rs +++ b/pnpm/crates/resolving-deps-resolver/src/resolve_dependency_tree/workspace_ctx.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use pnpm_hooks::PnpmfileHooks; -use pnpm_lockfile::{PkgName, PkgNameVerPeer}; +use pnpm_lockfile::{PkgName, PkgNameVerPeer, RegistryContext}; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::{ collections::BTreeMap, @@ -330,11 +330,10 @@ pub struct WorkspaceTreeCtx { /// tarball URL for the `currentPkg` payload. Empty when the entry /// point doesn't thread registries (then `currentPkg` is withheld /// for `Registry`-shaped entries rather than sent without a URL). - pub(super) registries: std::collections::HashMap, /// Alias → URL map of named registries (built-ins merged with the /// user's setting), for materializing a prior registry-qualified /// `Registry` lockfile resolution back into its tarball URL. - pub(super) named_registries: std::collections::HashMap, + pub(super) registry_context: RegistryContext, /// `pkg id → importer id` of the importer whose occurrence owns /// that package's shared children context. Ownership is chosen by /// update-active status followed by `(depth, importer order, parent path)`: @@ -510,8 +509,7 @@ impl Default for WorkspaceTreeCtx { allowed_deprecated_versions: BTreeMap::new(), deprecation_log: None, auto_install_peers: false, - registries: std::collections::HashMap::new(), - named_registries: std::collections::HashMap::new(), + registry_context: RegistryContext::default(), first_importer_by_pkg: Mutex::new(SnapshotCell::default()), first_walk_missing_by_pkg: Mutex::new(SnapshotCell::default()), changed_direct_deps: Mutex::new(HashMap::default()), @@ -1162,23 +1160,10 @@ impl WorkspaceTreeCtx { self } - /// Attach the resolved registry map. See the `registries` field. + /// Attach the registry facts. See the `registry_context` field. #[must_use] - pub fn with_registries( - mut self, - registries: std::collections::HashMap, - ) -> Self { - self.registries = registries; - self - } - - /// Attach the named-registry alias map. See the `named_registries` field. - #[must_use] - pub fn with_named_registries( - mut self, - named_registries: std::collections::HashMap, - ) -> Self { - self.named_registries = named_registries; + pub fn with_registry_context(mut self, registry_context: RegistryContext) -> Self { + self.registry_context = registry_context; self } diff --git a/pnpm/crates/resolving-deps-resolver/src/resolve_workspace.rs b/pnpm/crates/resolving-deps-resolver/src/resolve_workspace.rs index c33d04a183..85339d7bfc 100644 --- a/pnpm/crates/resolving-deps-resolver/src/resolve_workspace.rs +++ b/pnpm/crates/resolving-deps-resolver/src/resolve_workspace.rs @@ -27,6 +27,7 @@ use crate::{ resolved_tree::ResolvedTree, }; use chrono::{DateTime, Duration, Utc}; +use pnpm_lockfile::RegistryContext; use pnpm_package_manifest::{DependencyGroup, PackageManifest}; use pnpm_resolving_resolver_base::{Resolver, WantedDependency, parse_packument_timestamp}; use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; @@ -139,14 +140,12 @@ pub struct WorkspaceResolveOptions { /// [`crate::ResolveImporterOptions::auto_install_peers`] — the /// setting is workspace-wide. pub auto_install_peers: bool, - /// Resolved registry map (`"default"` + per-scope), for - /// materializing a prior `Registry` lockfile resolution back into - /// its tarball URL when building the `currentPkg` payload custom - /// resolvers receive. - pub registries: std::collections::HashMap, - /// Alias → URL map of named registries (built-ins merged with the - /// user's setting). See [`WorkspaceResolveOptions::registries`]. - pub named_registries: std::collections::HashMap, + /// How a package's registry is decided and what it serves: the scope + /// map, the named-registry aliases (built-ins merged with the user's + /// setting), and the per-registry settings. Used to materialize a + /// prior `Registry` lockfile resolution back into its tarball URL when + /// building the `currentPkg` payload custom resolvers receive. + pub registry_context: RegistryContext, } /// Result of [`fn@resolve_workspace`]. The combined @@ -202,8 +201,7 @@ where update_reuse_scopes_by_importer, update_depth, auto_install_peers, - registries, - named_registries, + registry_context, } = opts; // Taken before the lockfile moves into the workspace ctx below, and // only for the pre-pass that reads it — a lockfile is untrusted @@ -226,8 +224,7 @@ where .with_allowed_deprecated_versions(allowed_deprecated_versions) .with_deprecation_log(deprecation_log) .with_auto_install_peers(auto_install_peers) - .with_registries(registries) - .with_named_registries(named_registries), + .with_registry_context(registry_context), ); // Build every importer's options up front so the `time-based` diff --git a/pnpm/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs b/pnpm/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs index 1b33bebcd4..a78160f964 100644 --- a/pnpm/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs +++ b/pnpm/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs @@ -9,7 +9,7 @@ use std::{ }; use chrono::{DateTime, TimeZone, Utc}; -use pnpm_lockfile::{DirectoryResolution, LockfileResolution}; +use pnpm_lockfile::{DirectoryResolution, LockfileResolution, RegistryContext}; use pnpm_package_manifest::{DependencyGroup, PackageManifest}; use pnpm_resolving_resolver_base::{ LatestQuery, NoMatchingVersionError, PkgResolutionId, PreferredVersions, RegistryResponseError, @@ -196,7 +196,7 @@ fn importer_opts( fn workspace_opts(pick_lowest_direct: bool, time_based: bool) -> WorkspaceResolveOptions { WorkspaceResolveOptions { - named_registries: std::collections::HashMap::new(), + registry_context: RegistryContext::default(), dedupe_peers: false, dedupe_injected_deps: false, dedupe_peer_dependents: false, @@ -218,7 +218,6 @@ fn workspace_opts(pick_lowest_direct: bool, time_based: bool) -> WorkspaceResolv update_reuse_scopes_by_importer: BTreeMap::new(), update_depth: crate::UpdateDepth::UNLIMITED, auto_install_peers: false, - registries: std::collections::HashMap::new(), } } diff --git a/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs b/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs index 73529ba22b..befb3553fe 100644 --- a/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs +++ b/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs @@ -117,8 +117,8 @@ pub struct CreateNpmResolutionVerifierOptions { pub registries: HashMap, /// User-defined named-registry aliases (e.g. `gh:` → /// `https://npm.pkg.github.com/`). Merged with - /// [`crate::BUILTIN_NAMED_REGISTRIES`]. - pub named_registries: HashMap, + /// [`crate::BUILTIN_REGISTRIES_BY_PREFIX`]. + pub registries_by_prefix: HashMap, pub http_client: Arc, pub auth_headers: Arc, /// Root of pnpm's on-disk metadata mirror. When set, the verifier @@ -184,7 +184,7 @@ pub struct NpmResolutionVerifier { /// Alias → URL map (built-ins merged with the user's setting) for /// routing registry-qualified lockfile keys, which carry no tarball /// URL for the prefix list to match. - named_registries_by_name: HashMap, + registries_by_prefix: HashMap, http_client: Arc, auth_headers: Arc, cache_dir: Option, @@ -241,12 +241,12 @@ pub fn create_npm_resolution_verifier( None }; - let named_registry_prefixes = named_registry_tarball_prefixes(&opts.named_registries); - let named_registries_by_name = opts.named_registries.clone(); + let named_registry_prefixes = named_registry_tarball_prefixes(&opts.registries_by_prefix); + let registries_by_prefix = opts.registries_by_prefix.clone(); let sorted_min_age_excludes = sorted_unique(&opts.minimum_release_age_exclude_patterns); let sorted_trust_excludes = sorted_unique(&opts.trust_policy_exclude_patterns); - let named_registries_routing = named_registries_routing_digest(&named_registries_by_name); + let named_registries_routing = named_registries_routing_digest(®istries_by_prefix); let policy_snapshot = build_policy_snapshot( opts.minimum_release_age.unwrap_or(0), @@ -269,7 +269,7 @@ pub fn create_npm_resolution_verifier( sorted_trust_excludes, registries: opts.registries, named_registry_prefixes, - named_registries_by_name, + registries_by_prefix, http_client: opts.http_client, auth_headers: opts.auth_headers, cache_dir: opts.cache_dir, @@ -414,7 +414,7 @@ impl NpmResolutionVerifier { // closed on an unknown alias: none of the metadata-backed checks // below could vouch for the entry without its registry URL. let named_registry = match ctx.registry_name { - Some(registry_name) => match self.named_registries_by_name.get(registry_name) { + Some(registry_name) => match self.registries_by_prefix.get(registry_name) { Some(url) => Some(url.clone()), None => { return ResolutionVerification::Err { @@ -1107,8 +1107,8 @@ fn sorted_unique(values: &[String]) -> Vec { deduped } -fn named_registries_routing_digest(named_registries: &HashMap) -> String { - let sorted: BTreeMap<&str, &str> = named_registries +fn named_registries_routing_digest(registries_by_prefix: &HashMap) -> String { + let sorted: BTreeMap<&str, &str> = registries_by_prefix .iter() .map(|(alias, registry)| (alias.as_str(), registry.as_str())) .collect(); diff --git a/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs b/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs index 8f8e25e1f7..d59e97a196 100644 --- a/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs +++ b/pnpm/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs @@ -62,7 +62,7 @@ fn default_opts(registry_url: &str) -> CreateNpmResolutionVerifierOptions { trust_policy_exclude_patterns: Vec::new(), trust_policy_ignore_after: None, registries: registries_with_default(registry_url), - named_registries: HashMap::new(), + registries_by_prefix: HashMap::new(), http_client: Arc::new(ThrottledClient::default()), auth_headers: Arc::new(AuthHeaders::default()), cache_dir: None, @@ -971,7 +971,7 @@ async fn verify_routes_via_named_registry_prefix() { // breaks, the request would target the bogus URL and the test // would fail with a connection error instead of finding the mock. let mut opts = default_opts("http://nonexistent.example.invalid/"); - opts.named_registries = named; + opts.registries_by_prefix = named; opts.minimum_release_age = Some(60 * 24); opts.now = Some(now_at("2025-12-01T00:00:00Z")); let verifier = create_npm_resolution_verifier(opts); @@ -1052,11 +1052,14 @@ fn can_trust_past_check_accepts_looser_min_age() { #[test] fn can_trust_past_check_rejects_changed_named_registry_mapping() { let mut opts = default_opts("https://registry.example/"); - opts.named_registries.insert("work".to_string(), "https://registry.work.example/".to_string()); + opts.registries_by_prefix + .insert("work".to_string(), "https://registry.work.example/".to_string()); let verifier = create_npm_resolution_verifier(opts); let cached = verifier.policy().clone(); let mut changed_opts = default_opts("https://registry.example/"); - changed_opts.named_registries.insert("work".to_string(), "https://other.example/".to_string()); + changed_opts + .registries_by_prefix + .insert("work".to_string(), "https://other.example/".to_string()); let changed = create_npm_resolution_verifier(changed_opts); // Pins that the rejection below comes from the URL change and not from diff --git a/pnpm/crates/resolving-npm-resolver/src/errors.rs b/pnpm/crates/resolving-npm-resolver/src/errors.rs index 39ee2d2145..bd6c3621dd 100644 --- a/pnpm/crates/resolving-npm-resolver/src/errors.rs +++ b/pnpm/crates/resolving-npm-resolver/src/errors.rs @@ -30,6 +30,19 @@ pub enum FetchMetadataError { pkg_mirror: std::path::PathBuf, }, + /// The deployment's route policy refuses this origin. Only a server + /// with an [`UpstreamRouteHook`](pnpm_network::UpstreamRouteHook) + /// raises it: the CLI fetches as the user and reaches whatever the user + /// configured. + #[display( + "{url} is not allowed by this pnpr server; the operator must declare its registry as a public route or an upstream" + )] + #[diagnostic(code(ERR_PNPM_REGISTRY_OFF_ALLOWLIST))] + OffAllowlist { + #[error(not(source))] + url: String, + }, + #[display("Failed to fetch metadata from {url}: {error}")] #[diagnostic(code(ERR_PNPM_RESOLVING_NPM_RESOLVER_NETWORK_ERROR))] Network { diff --git a/pnpm/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs b/pnpm/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs index d8302634fb..4f9d8cd9d1 100644 --- a/pnpm/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs +++ b/pnpm/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs @@ -18,7 +18,7 @@ //! them into a violation reason instead of swallowing. use chrono::DateTime; -use pnpm_network::{AuthHeaders, ThrottledClient}; +use pnpm_network::{AuthHeaders, ThrottledClient, redact_url_credentials}; use crate::FetchMetadataError; @@ -39,6 +39,9 @@ pub async fn fetch_attestation_published_at( ) -> Result, FetchMetadataError> { let registry = opts.registry.trim_end_matches('/'); let url = format!("{registry}/-/npm/v1/attestations/{pkg_name}@{version}"); + if !opts.auth_headers.allows_fetch(&url) { + return Err(FetchMetadataError::OffAllowlist { url: redact_url_credentials(&url) }); + } // Verification-only lookup: queue in the background class so it // never outranks resolution-gating fetches. let mut request = opts diff --git a/pnpm/crates/resolving-npm-resolver/src/fetch_full_metadata.rs b/pnpm/crates/resolving-npm-resolver/src/fetch_full_metadata.rs index b4092b3c26..e63e6b4f91 100644 --- a/pnpm/crates/resolving-npm-resolver/src/fetch_full_metadata.rs +++ b/pnpm/crates/resolving-npm-resolver/src/fetch_full_metadata.rs @@ -122,6 +122,12 @@ pub(crate) struct MetadataRequestOptions<'a> { pub(crate) async fn send_metadata_request<'a>( opts: &MetadataRequestOptions<'a>, ) -> Result<(ThrottledClientGuard<'a>, Response), FetchMetadataError> { + // The route policy decides whether this origin may be reached at all, + // here rather than when the request that named it was read: a registry a + // caller configures but never resolves from costs nothing. + if !opts.auth_headers.allows_fetch(opts.url) { + return Err(FetchMetadataError::OffAllowlist { url: redact_url_credentials(opts.url) }); + } let etag = if opts.bypass_cache { None } else { opts.etag.filter(|value| !value.is_empty()) }; let modified = if opts.bypass_cache { None diff --git a/pnpm/crates/resolving-npm-resolver/src/lib.rs b/pnpm/crates/resolving-npm-resolver/src/lib.rs index a0c63d5ad8..3d35cfe9b0 100644 --- a/pnpm/crates/resolving-npm-resolver/src/lib.rs +++ b/pnpm/crates/resolving-npm-resolver/src/lib.rs @@ -53,7 +53,7 @@ pub use fetch_full_metadata_cached::{FetchFullMetadataCachedOptions, fetch_full_ pub use infer_range_spec_style::infer_range_spec_style; pub use mirror::{ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR}; pub use named_registry::{ - BUILTIN_NAMED_REGISTRIES, MergeNamedRegistriesError, merge_named_registries, + BUILTIN_REGISTRIES_BY_PREFIX, MergeNamedRegistriesError, merge_named_registries, named_registry_tarball_prefixes, pick_registry_for_version, }; pub use named_registry_resolver::NamedRegistryResolver; diff --git a/pnpm/crates/resolving-npm-resolver/src/named_registry.rs b/pnpm/crates/resolving-npm-resolver/src/named_registry.rs index 4bbe821a9f..7dcfbbffaf 100644 --- a/pnpm/crates/resolving-npm-resolver/src/named_registry.rs +++ b/pnpm/crates/resolving-npm-resolver/src/named_registry.rs @@ -29,7 +29,7 @@ use reqwest::Url; /// against, so an org that proxies /// npmjs should point `npmjs` at their proxy to keep verification /// going there rather than to the public host. -pub const BUILTIN_NAMED_REGISTRIES: &[(&str, &str)] = +pub const BUILTIN_REGISTRIES_BY_PREFIX: &[(&str, &str)] = &[("gh", "https://npm.pkg.github.com/"), ("npmjs", "https://registry.npmjs.org/")]; /// Failure from [`merge_named_registries`], surfaced with the @@ -100,7 +100,7 @@ pub fn merge_named_registries( }); } } - let mut merged: HashMap = BUILTIN_NAMED_REGISTRIES + let mut merged: HashMap = BUILTIN_REGISTRIES_BY_PREFIX .iter() .map(|(name, url)| ((*name).to_string(), (*url).to_string())) .collect(); @@ -118,8 +118,10 @@ fn is_valid_http_url(url: &str) -> bool { /// Equal lengths tie-break lexicographically, since length alone leaves /// the order to `HashMap` iteration. #[must_use] -pub fn named_registry_tarball_prefixes(named_registries: &HashMap) -> Vec { - let mut prefixes: Vec = named_registries +pub fn named_registry_tarball_prefixes( + registries_by_prefix: &HashMap, +) -> Vec { + let mut prefixes: Vec = registries_by_prefix .values() .filter_map(|url| Url::parse(url).ok()) .map(|parsed| { diff --git a/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver.rs b/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver.rs index 31ee8bee17..637c938808 100644 --- a/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver.rs +++ b/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver.rs @@ -61,8 +61,8 @@ pub struct NamedRegistryResolver { /// (`gh:` → GitHub Packages) plus any user-supplied overrides /// from `pnpm-workspace.yaml#namedRegistries`. Already validated /// — every URL parses and is http(s). - pub named_registries: HashMap, - /// Precomputed key set of [`Self::named_registries`]. The parser + pub registries_by_prefix: HashMap, + /// Precomputed key set of [`Self::registries_by_prefix`]. The parser /// checks aliases against this set per call, so caching it /// avoids rebuilding the set for every resolve. pub registry_names: HashSet, @@ -136,7 +136,7 @@ impl NamedRegistryResolver { // Defensive: should never trigger because the parser checks // the alias set first, but kept as a belt-and-braces guard. - let Some(registry) = self.named_registries.get(®istry_name) else { + let Some(registry) = self.registries_by_prefix.get(®istry_name) else { return Ok(None); }; diff --git a/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver/tests.rs b/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver/tests.rs index 130873608b..4f5e31dcbb 100644 --- a/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver/tests.rs +++ b/pnpm/crates/resolving-npm-resolver/src/named_registry_resolver/tests.rs @@ -65,13 +65,13 @@ const ACME_PRIVATE_BODY: &str = r#"{ reason = "nested test helper called many times; owned arg keeps the call sites and assert ergonomics simple" )] fn build_resolver( - user_named_registries: HashMap, + user_registries_by_prefix: HashMap, ) -> (NamedRegistryResolver, TempDir) { - let merged = merge_named_registries(&user_named_registries).expect("URLs are valid"); + let merged = merge_named_registries(&user_registries_by_prefix).expect("URLs are valid"); let registry_names: HashSet = merged.keys().cloned().collect(); let cache_dir = TempDir::new().expect("tempdir"); let resolver = NamedRegistryResolver { - named_registries: merged, + registries_by_prefix: merged, registry_names, http_client: Arc::new(ThrottledClient::default()), auth_headers: Arc::new(AuthHeaders::default()), diff --git a/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs b/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs index bb5529d482..639d05fff8 100644 --- a/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs +++ b/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs @@ -86,11 +86,11 @@ pub struct NpmResolver { pub registries: HashMap, /// User-supplied named-registry aliases (e.g. `gh:` → /// `https://npm.pkg.github.com/`). Merged with - /// [`crate::BUILTIN_NAMED_REGISTRIES`] at construction. Today + /// [`crate::BUILTIN_REGISTRIES_BY_PREFIX`] at construction. Today /// only consulted by the named-registry resolver (out of scope /// for this port); kept here so the install layer can build one /// resolver instance with the full registry view. - pub named_registries: HashMap, + pub registries_by_prefix: HashMap, pub http_client: Arc, pub auth_headers: Arc, pub meta_cache: Arc, diff --git a/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs b/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs index 9d18dc658d..28afff2bab 100644 --- a/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs +++ b/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs @@ -69,7 +69,7 @@ fn build_resolver_with_registries( let cache_dir = TempDir::new().expect("tempdir"); let resolver = NpmResolver { registries, - named_registries: HashMap::new(), + registries_by_prefix: HashMap::new(), http_client: Arc::new(ThrottledClient::default()), auth_headers: Arc::new(AuthHeaders::default()), meta_cache: Arc::new(InMemoryPackageMetaCache::default()), @@ -746,7 +746,7 @@ async fn shared_manifest_cache_does_not_leak_across_registries() { let cache_dir = TempDir::new().expect("tempdir"); let resolver = NpmResolver { registries, - named_registries: HashMap::new(), + registries_by_prefix: HashMap::new(), http_client: Arc::new(ThrottledClient::default()), auth_headers: Arc::new(AuthHeaders::default()), meta_cache: Arc::new(InMemoryPackageMetaCache::default()), diff --git a/pnpm/crates/tarball/src/download.rs b/pnpm/crates/tarball/src/download.rs index fc87a4845b..81137fd334 100644 --- a/pnpm/crates/tarball/src/download.rs +++ b/pnpm/crates/tarball/src/download.rs @@ -198,6 +198,9 @@ pub(crate) fn tarball_error_to_request_retry(err: &TarballError) -> RequestRetry TarballError::FetchTarball(_) => { out.code = Some("ERR_PNPM_FETCH".to_string()); } + TarballError::OffAllowlist { .. } => { + out.code = Some("ERR_PNPM_REGISTRY_OFF_ALLOWLIST".to_string()); + } TarballError::Checksum(_) => { out.code = Some("ERR_PNPM_TARBALL_INTEGRITY".to_string()); } @@ -257,6 +260,8 @@ pub(crate) fn is_transient_error(err: &TarballError) -> bool { match err { TarballError::HttpStatus(http) => !matches!(http.status, 401 | 403 | 404), TarballError::ReadLocalTarball { .. } => false, + // A route policy does not change between attempts. + TarballError::OffAllowlist { .. } => false, _ => true, } } @@ -384,6 +389,13 @@ pub(crate) async fn fetch_and_extract_once( // URL. When the pool is saturated, the package with the most // estimated pipeline work claims the next freed slot, so the // longest download+extract jobs never start last. + // The route policy decides whether this origin may be reached at all, + // at the fetch rather than when the request that named it was read. + if !auth_headers.allows_fetch(package_url) { + return Err(TarballError::OffAllowlist { + url: pnpm_network::redact_url_credentials(package_url), + }); + } let client = http_client.acquire_for_url_with_priority(package_url, download_priority).await; let mut request = client.get(package_url); // Resolve the per-URL auth header and attach it. Tarball hosts that diff --git a/pnpm/crates/tarball/src/error.rs b/pnpm/crates/tarball/src/error.rs index 21ef77e6d0..2fa060169a 100644 --- a/pnpm/crates/tarball/src/error.rs +++ b/pnpm/crates/tarball/src/error.rs @@ -69,6 +69,20 @@ pub enum TarballError { #[diagnostic(code(ERR_PNPM_TARBALL_FETCH_TARBALL))] FetchTarball(NetworkError), + /// The deployment's route policy refuses this origin. Only a server + /// with an [`UpstreamRouteHook`](pnpm_network::UpstreamRouteHook) + /// raises it: the CLI fetches as the user and reaches whatever the user + /// configured. + #[from(ignore)] + #[display( + "{url} is not allowed by this pnpr server; the operator must declare its registry as a public route or an upstream" + )] + #[diagnostic(code(ERR_PNPM_REGISTRY_OFF_ALLOWLIST))] + OffAllowlist { + #[error(not(source))] + url: String, + }, + #[diagnostic(code(ERR_PNPM_TARBALL_HTTP_STATUS))] HttpStatus(HttpStatusError), diff --git a/pnpm/crates/tarball/src/zip_archive.rs b/pnpm/crates/tarball/src/zip_archive.rs index 2cfd5f4f76..922678dfa3 100644 --- a/pnpm/crates/tarball/src/zip_archive.rs +++ b/pnpm/crates/tarball/src/zip_archive.rs @@ -221,6 +221,13 @@ pub(crate) async fn fetch_and_extract_zip_once( let network_error = |error| TarballError::FetchTarball(NetworkError { url: package_url.to_string(), error }); + // The route policy decides whether this origin may be reached at all, + // at the fetch rather than when the request that named it was read. + if !auth_headers.allows_fetch(package_url) { + return Err(TarballError::OffAllowlist { + url: pnpm_network::redact_url_credentials(package_url), + }); + } let client = http_client.acquire_for_url(package_url).await; let mut request = client.get(package_url); diff --git a/pnpm/tasks/micro-benchmark/src/workspace_resolution.rs b/pnpm/tasks/micro-benchmark/src/workspace_resolution.rs index 706042387f..8b781ceca8 100644 --- a/pnpm/tasks/micro-benchmark/src/workspace_resolution.rs +++ b/pnpm/tasks/micro-benchmark/src/workspace_resolution.rs @@ -29,7 +29,7 @@ use std::{ time::Instant, }; -use pnpm_lockfile::{LockfileResolution, PkgName, PkgNameVer, TarballResolution}; +use pnpm_lockfile::{LockfileResolution, PkgName, PkgNameVer, RegistryContext, TarballResolution}; use pnpm_package_manifest::{DependencyGroup, PackageManifest}; use pnpm_resolving_deps_resolver::{ ResolveImporterOptions, UpdateDepth, UpdateReuseScope, WorkspaceImporter, @@ -274,7 +274,7 @@ fn importer_options(importer: &WorkspaceImporter<'_>) -> ResolveImporterOptions fn workspace_options() -> WorkspaceResolveOptions { WorkspaceResolveOptions { - named_registries: HashMap::new(), + registry_context: RegistryContext::default(), dedupe_peers: true, dedupe_injected_deps: true, dedupe_peer_dependents: true, @@ -296,7 +296,6 @@ fn workspace_options() -> WorkspaceResolveOptions { allowed_deprecated_versions: BTreeMap::new(), deprecation_log: None, auto_install_peers: true, - registries: HashMap::new(), } } diff --git a/pnpm11/building/after-install/src/extendBuildOptions.ts b/pnpm11/building/after-install/src/extendBuildOptions.ts index 1062d413d7..2ec68ed5c6 100644 --- a/pnpm11/building/after-install/src/extendBuildOptions.ts +++ b/pnpm11/building/after-install/src/extendBuildOptions.ts @@ -1,10 +1,10 @@ import path from 'node:path' -import { DEFAULT_REGISTRIES, normalizeRegistries } from '@pnpm/config.normalize-registries' +import { DEFAULT_REGISTRIES_BY_SCOPE, normalizeRegistriesByScope } from '@pnpm/config.normalize-registries' import type { Config, ConfigContext } from '@pnpm/config.reader' import type { LogBase } from '@pnpm/logger' import type { StoreController } from '@pnpm/store.controller-types' -import type { Registries, RegistryConfig, SupportedArchitectures } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig, SupportedArchitectures } from '@pnpm/types' import { loadJsonFile } from 'load-json-file' export type StrictBuildOptions = { @@ -28,7 +28,7 @@ export type StrictBuildOptions = { storeController: StoreController force: boolean useLockfile: boolean - registries: Registries + registriesByScope: RegistriesByScope dir: string pnpmHomeDir: string @@ -78,7 +78,7 @@ const defaults = async (opts: BuildOptions): Promise => { pending: false, production: true, configByUri: {}, - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, scriptsPrependNodePath: false, shamefullyHoist: false, shellEmulator: false, @@ -109,7 +109,7 @@ export async function extendBuildOptions ( ...opts, storeDir: defaultOpts.storeDir, } - extendedOpts.registries = normalizeRegistries(extendedOpts.registries) + extendedOpts.registriesByScope = normalizeRegistriesByScope(extendedOpts.registriesByScope) // Mirror extendInstallOptions: under a global virtual store, the virtual // store directory is `/links`, not the per-project // `node_modules/.pnpm`. Without this, getContext() in the build step diff --git a/pnpm11/building/after-install/src/index.ts b/pnpm11/building/after-install/src/index.ts index c2c9035ee1..265bd7817c 100644 --- a/pnpm11/building/after-install/src/index.ts +++ b/pnpm11/building/after-install/src/index.ts @@ -169,7 +169,6 @@ export async function buildSelectedPkgs ( packageManager: `${opts.packageManager.name}@${opts.packageManager.version}`, pendingBuilds: ctx.pendingBuilds, publicHoistPattern: ctx.publicHoistPattern, - registries: ctx.registries, skipped: Array.from(ctx.skipped), storeDir: ctx.modulesFile?.storeDir ?? ctx.storeDir, virtualStoreDir: ctx.modulesFile?.virtualStoreDir ?? ctx.virtualStoreDir, @@ -251,7 +250,6 @@ export async function buildProjects ( packageManager: `${opts.packageManager.name}@${opts.packageManager.version}`, pendingBuilds: ctx.pendingBuilds, publicHoistPattern: ctx.publicHoistPattern, - registries: ctx.registries, skipped: Array.from(ctx.skipped), storeDir: ctx.storeDir, virtualStoreDir: ctx.virtualStoreDir, diff --git a/pnpm11/building/commands/src/build/rebuild.ts b/pnpm11/building/commands/src/build/rebuild.ts index 259ed0875e..628046540b 100644 --- a/pnpm11/building/commands/src/build/rebuild.ts +++ b/pnpm11/building/commands/src/build/rebuild.ts @@ -80,7 +80,7 @@ export type RebuildCommandOpts = Pick & Pick { ]) const gitDepPath = modules!.pendingBuilds[1] - const modulesManifest = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), pending: false, - registries: modulesManifest!.registries!, storeDir, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, [gitDepPath]: true }, }, []) @@ -103,7 +101,7 @@ test('rebuilds dependencies', async () => { }) test('rebuilds dependencies in the global virtual store', async () => { - const project = prepare() + prepare() const cacheDir = path.resolve('cache') const storeDir = path.resolve('store') @@ -137,14 +135,12 @@ test('rebuilds dependencies in the global virtual store', async () => { // The build was deferred, so the postinstall artifact is not there yet. expect(fs.existsSync(path.join(pkgInGvs, 'generated-by-postinstall.js'))).toBeFalsy() - const modulesManifest = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), enableGlobalVirtualStore: true, pending: false, - registries: modulesManifest!.registries!, storeDir, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true }, }, []) @@ -259,13 +255,11 @@ test('skipIfHasSideEffectsCache', async () => { '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0', ]) - const modulesManifest = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), pending: true, - registries: modulesManifest!.registries!, skipIfHasSideEffectsCache: true, storeDir, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true }, @@ -281,7 +275,7 @@ test('skipIfHasSideEffectsCache', async () => { }) test('rebuild does not fail when a linked package is present', async () => { - const project = prepare() + prepare() const cacheDir = path.resolve('cache') const storeDir = path.resolve('store') f.copy('local-pkg', path.resolve('..', 'local-pkg')) @@ -298,13 +292,11 @@ test('rebuild does not fail when a linked package is present', async () => { '--config.enableGlobalVirtualStore=false', ]) - const modulesManifest = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), pending: false, - registries: modulesManifest!.registries!, storeDir, allowBuilds: { 'local-pkg': true, 'is-positive': true }, }, []) @@ -336,7 +328,6 @@ test('rebuilds specific dependencies', async () => { cacheDir, dir: process.cwd(), pending: false, - registries: modulesManifest!.registries!, storeDir, allowBuilds: { [gitDepPath]: true }, }, ['install-scripts-example-for-pnpm']) @@ -397,7 +388,6 @@ test('rebuild with pending option', async () => { cacheDir, dir: process.cwd(), pending: true, - registries: modules!.registries!, storeDir, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, [gitDepPath]: true }, }, []) @@ -451,7 +441,6 @@ test('rebuild dependencies in correct order', async () => { cacheDir, dir: process.cwd(), pending: false, - registries: modules!.registries!, storeDir, allowBuilds: { '@pnpm.e2e/with-postinstall-a': true, '@pnpm.e2e/with-postinstall-b': true }, }, []) @@ -487,13 +476,11 @@ test('rebuild links bins', async () => { expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/has-generated-bins-as-dep/node_modules/.bin/cmd1'))).toBeFalsy() expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/has-generated-bins-as-dep/node_modules/.bin/cmd2'))).toBeFalsy() - const modules = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), pending: true, - registries: modules!.registries!, storeDir, allowBuilds: { '@pnpm.e2e/has-generated-bins-as-dep': true, '@pnpm.e2e/generated-bins': true }, }, []) @@ -505,7 +492,7 @@ test('rebuild links bins', async () => { }) test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => { - const project = prepare({ + prepare({ dependencies: { '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', }, @@ -528,13 +515,11 @@ test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => { const reporter = jest.fn() - const modules = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), pending: true, - registries: modules!.registries!, reporter, storeDir, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, '@pnpm.e2e/not-compatible-with-any-os': true }, @@ -542,7 +527,7 @@ test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => { }) test('rebuilds in the global virtual store when the approval was granted after the install', async () => { - const project = prepare() + prepare() const cacheDir = path.resolve('cache') const storeDir = path.resolve('store') @@ -571,14 +556,12 @@ test('rebuilds in the global virtual store when the approval was granted after t const pkgInGvs = path.join(pkgVersionDir, hash, 'node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example') expect(fs.existsSync(path.join(pkgInGvs, 'generated-by-postinstall.js'))).toBeFalsy() - const modulesManifest = project.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), enableGlobalVirtualStore: true, pending: false, - registries: modulesManifest!.registries!, storeDir, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true }, }, []) @@ -589,7 +572,7 @@ test('rebuilds in the global virtual store when the approval was granted after t // GHSA-c59q-g84q-2gj5: a traversal depPath key must not point the build // at a directory outside the virtual store. test('rebuild refuses a lockfile depPath name that escapes the virtual store', async () => { - const project = prepare() + prepare() const cacheDir = path.resolve('cache') const storeDir = path.resolve('store') @@ -624,13 +607,11 @@ test('rebuild refuses a lockfile depPath name that escapes the virtual store', a fs.writeFileSync(path.join(escapedPkgDir, 'write-marker.cjs'), `require('fs').writeFileSync(${JSON.stringify(marker)}, 'pwned')`) - const modulesManifest = project.readModulesManifest() await expect(rebuild.handler({ ...DEFAULT_OPTS, cacheDir, dir: process.cwd(), pending: false, - registries: modulesManifest!.registries!, storeDir, // Approve the build, so that what stops the script is the contained // join and not the build policy. The key has to carry the version: diff --git a/pnpm11/building/commands/test/build/recursive.ts b/pnpm11/building/commands/test/build/recursive.ts index f74de85236..024f82fcc6 100644 --- a/pnpm11/building/commands/test/build/recursive.ts +++ b/pnpm11/building/commands/test/build/recursive.ts @@ -52,13 +52,11 @@ test('pnpm recursive rebuild', async () => { projects['project-2'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js') projects['project-2'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js') - const modulesManifest = projects['project-1'].readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, allProjects, dir: process.cwd(), recursive: true, - registries: modulesManifest!.registries!, selectedProjectsGraph, workspaceDir: process.cwd(), allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true }, @@ -129,14 +127,12 @@ test('pnpm recursive rebuild with hoisted node linker', async () => { projects['project-4'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js') projects['project-4'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js') - const modulesManifest = rootProject.readModulesManifest() await rebuild.handler({ ...DEFAULT_OPTS, allProjects, dir: process.cwd(), nodeLinker: 'hoisted', recursive: true, - registries: modulesManifest!.registries!, selectedProjectsGraph, lockfileDir: process.cwd(), workspaceDir: process.cwd(), @@ -287,7 +283,6 @@ test('only build allowBuilds (not others)', async () => { '@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js' ) - const modulesManifest = projects['project-1'].readModulesManifest() await rebuild.handler( { ...DEFAULT_OPTS, @@ -295,7 +290,6 @@ test('only build allowBuilds (not others)', async () => { allProjects, dir: process.cwd(), recursive: true, - registries: modulesManifest!.registries!, selectedProjectsGraph, workspaceDir: process.cwd(), }, @@ -382,7 +376,6 @@ test('only build allowBuilds', async () => { '@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js' ) - const modulesManifest = projects['project-1'].readModulesManifest() await rebuild.handler( { ...DEFAULT_OPTS, @@ -390,7 +383,6 @@ test('only build allowBuilds', async () => { allProjects, dir: process.cwd(), recursive: true, - registries: modulesManifest!.registries!, selectedProjectsGraph, workspaceDir: process.cwd(), }, diff --git a/pnpm11/config/commands/src/configGet.ts b/pnpm11/config/commands/src/configGet.ts index 4522324518..5c8b164595 100644 --- a/pnpm11/config/commands/src/configGet.ts +++ b/pnpm11/config/commands/src/configGet.ts @@ -28,7 +28,7 @@ function lookupConfig (opts: ConfigCommandOptions, key: string, isScopedKey: boo // `pnpm publish` and the resolvers actually use. if (key.endsWith(':registry')) { const scope = key.slice(0, key.length - ':registry'.length) - const merged = opts._config.registries?.[scope] + const merged = opts._config.registriesByScope?.[scope] if (merged !== undefined) { return { value: merged } } diff --git a/pnpm11/config/commands/test/configGet.test.ts b/pnpm11/config/commands/test/configGet.test.ts index 89bc535105..8ca25509b8 100644 --- a/pnpm11/config/commands/test/configGet.test.ts +++ b/pnpm11/config/commands/test/configGet.test.ts @@ -248,7 +248,7 @@ test('config get with scoped registry returns the merged value from pnpm-workspa authConfig: { '@scope:registry': 'https://from-npmrc.example.com/', }, - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', '@scope': 'https://from-workspace-yaml.example.com/', }, diff --git a/pnpm11/config/normalize-registries/package.json b/pnpm11/config/normalize-registries/package.json index aa3d91d3f0..f70f10c49f 100644 --- a/pnpm11/config/normalize-registries/package.json +++ b/pnpm11/config/normalize-registries/package.json @@ -24,10 +24,11 @@ "!*.map" ], "scripts": { - "test": "pn compile", - "lint": "eslint \"src/**/*.ts\"", + "test": "pn compile && pn .test", + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", "prepublishOnly": "tsgo --build", - "compile": "tsgo --build && pn lint --fix" + "compile": "tsgo --build && pn lint --fix", + ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest" }, "dependencies": { "@pnpm/constants": "workspace:*", @@ -36,6 +37,7 @@ "ramda": "catalog:" }, "devDependencies": { + "@jest/globals": "catalog:", "@pnpm/config.normalize-registries": "workspace:*", "@types/ramda": "catalog:" }, diff --git a/pnpm11/config/normalize-registries/src/index.ts b/pnpm11/config/normalize-registries/src/index.ts index 1dfbebe4f0..1b239dbccf 100644 --- a/pnpm11/config/normalize-registries/src/index.ts +++ b/pnpm11/config/normalize-registries/src/index.ts @@ -1,40 +1,103 @@ -import { BUILTIN_NAMED_REGISTRIES } from '@pnpm/constants' -import type { NamedRegistries, Registries } from '@pnpm/types' +import { BUILTIN_REGISTRIES_BY_PREFIX } from '@pnpm/constants' +import type { RegistriesByPrefix, RegistriesByScope, RegistryContext, RegistryDeclaration, RegistryServerType } from '@pnpm/types' import normalizeRegistryUrl from 'normalize-registry-url' import { map as mapValues } from 'ramda' -export const DEFAULT_REGISTRIES: Registries = { +export const DEFAULT_REGISTRIES_BY_SCOPE: RegistriesByScope = { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', } -export function normalizeRegistries (registries?: Record): Registries { - if (registries == null) return DEFAULT_REGISTRIES - const normalizeRegistries = mapValues(normalizeRegistryUrl, registries) +export function normalizeRegistriesByScope (registries?: Record): RegistriesByScope { + if (registries == null) return DEFAULT_REGISTRIES_BY_SCOPE + const normalizeRegistriesByScope = mapValues(normalizeRegistryUrl, registries) return { - ...DEFAULT_REGISTRIES, - ...normalizeRegistries, + ...DEFAULT_REGISTRIES_BY_SCOPE, + ...normalizeRegistriesByScope, } } /** * User entries win on collision, so a GHES user can point `gh` at their * enterprise host. URLs are deliberately not normalized the way - * `normalizeRegistries` normalizes `registries`: the name and URL are recorded + * `normalizeRegistriesByScope` normalizes `registries`: the name and URL are recorded * in lockfile dep paths, so rewriting one changes what a lockfile resolves to. * * Null-prototype because names come out of those dep paths: a crafted * `foo@constructor:1.0.0` must not resolve to `Object.prototype.constructor` * and slip past the `if (!registry)` guards that fail closed on unknown names. */ -export function normalizeNamedRegistries (namedRegistries?: Record): NamedRegistries { - if (namedRegistries == null) return DEFAULT_NAMED_REGISTRIES +export function normalizeRegistriesByPrefix (registriesByPrefix?: Record): RegistriesByPrefix { + if (registriesByPrefix == null) return DEFAULT_REGISTRIES_BY_PREFIX return Object.assign( - Object.create(null) as NamedRegistries, - BUILTIN_NAMED_REGISTRIES, - namedRegistries + Object.create(null) as RegistriesByPrefix, + BUILTIN_REGISTRIES_BY_PREFIX, + registriesByPrefix ) } -/** Shared, like `DEFAULT_REGISTRIES`, so per-package callers can cache on its identity. */ -const DEFAULT_NAMED_REGISTRIES = normalizeNamedRegistries({}) +/** Shared, like `DEFAULT_REGISTRIES_BY_SCOPE`, so per-package callers can cache on its identity. */ +const DEFAULT_REGISTRIES_BY_PREFIX = normalizeRegistriesByPrefix({}) + +/** + * Narrow a config-shaped object down to the registry facts alone, so the + * install and lockfile layers are not handed the whole config, and so a + * forwarding call site cannot drop one. The single place a new + * {@link RegistryContext} field has to be listed. + */ +export function pickRegistryContext (source: RegistryContext): RegistryContext { + return { + registriesByScope: source.registriesByScope, + registriesByPrefix: source.registriesByPrefix, + registryOptionsByUrl: source.registryOptionsByUrl, + } +} + +/** + * Rebuilds the declarations from the lookups they were split into, for a + * client that has to describe its registries to a pnpr server. + * + * The inverse of what the config reader does to the `registries` setting, + * minus the default registry: that one travels as the request's own + * `registry` field. + * + * Entries are keyed by the URL each lookup holds rather than by a normalized + * one, so a registry a prefix addresses without a trailing slash stays the URL + * the client resolves against. + * + * A built-in route the user has not pointed elsewhere is not declared: it is + * not part of the client's configuration, and declaring `@jsr` would put + * npm.jsr.io in front of a pnpr server's allowlist on every request, including + * the requests that never resolve a JSR package. + */ +export function toRegistryDeclarations (context: Partial): Record { + const declarations: Record = {} + const declarationFor = (registry: string): RegistryDeclaration => (declarations[registry] ??= {}) + for (const [scope, registry] of Object.entries(context.registriesByScope ?? {})) { + if (scope === 'default' || DEFAULT_REGISTRIES_BY_SCOPE[scope] === registry) continue + const declaration = declarationFor(registry) + declaration.scopes = [...declaration.scopes ?? [], scope] + } + for (const [prefix, registry] of Object.entries(context.registriesByPrefix ?? {})) { + declarationFor(registry).prefix = prefix + } + for (const [registry, options] of Object.entries(context.registryOptionsByUrl ?? {})) { + if (options.serverType == null) continue + declarationFor(registry).serverType = options.serverType + } + return declarations +} + +/** + * The layout the user declared for `registry`, or `undefined` for none. + * + * Built-in layouts are deliberately not applied here — they belong with + * `isCanonicalRegistryTarballUrl`, which acts on them, so that a code path + * that never threads `registryOptionsByUrl` still gets them. + */ +export function getRegistryServerType ( + registryContext: Pick, + registry: string +): RegistryServerType | undefined { + return registryContext.registryOptionsByUrl?.[normalizeRegistryUrl(registry)]?.serverType +} diff --git a/pnpm11/config/normalize-registries/test/toRegistryDeclarations.test.ts b/pnpm11/config/normalize-registries/test/toRegistryDeclarations.test.ts new file mode 100644 index 0000000000..c13cd579d3 --- /dev/null +++ b/pnpm11/config/normalize-registries/test/toRegistryDeclarations.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@jest/globals' +import { toRegistryDeclarations } from '@pnpm/config.normalize-registries' + +test('toRegistryDeclarations() gathers every route to a registry into its entry', () => { + expect(toRegistryDeclarations({ + registriesByScope: { + default: 'https://registry.npmjs.org/', + '@acme': 'https://npm.corp.example/', + '@acme-internal': 'https://npm.corp.example/', + '@other': 'https://npm.other.example/', + }, + registriesByPrefix: { work: 'https://npm.corp.example/' }, + registryOptionsByUrl: { 'https://npm.corp.example/': { serverType: 'artifactory' } }, + })).toStrictEqual({ + 'https://npm.corp.example/': { + scopes: ['@acme', '@acme-internal'], + prefix: 'work', + serverType: 'artifactory', + }, + 'https://npm.other.example/': { scopes: ['@other'] }, + }) +}) + +test('toRegistryDeclarations() leaves the default registry to the request that carries it', () => { + expect(toRegistryDeclarations({ + registriesByScope: { default: 'https://npm.corp.example/' }, + })).toStrictEqual({}) +}) + +test('toRegistryDeclarations() declares a registry that only has a layout', () => { + // The default registry's layout reaches the server this way: its URL is a + // key here even though its route travels as the request's `registry`. + expect(toRegistryDeclarations({ + registriesByScope: { default: 'https://npm.corp.example/' }, + registryOptionsByUrl: { 'https://npm.corp.example/': { serverType: 'npm' } }, + })).toStrictEqual({ + 'https://npm.corp.example/': { serverType: 'npm' }, + }) +}) + +test('toRegistryDeclarations() keys a prefix by the URL as written', () => { + // A named registry's URL is what a lockfile's recorded tarball URLs are + // matched against, so it must not pick up a normalized trailing slash. + expect(toRegistryDeclarations({ + registriesByPrefix: { work: 'https://npm.corp.example' }, + })).toStrictEqual({ + 'https://npm.corp.example': { prefix: 'work' }, + }) +}) + +test('toRegistryDeclarations() does not declare a built-in route the user did not point elsewhere', () => { + // Declaring `@jsr` would put npm.jsr.io in front of a pnpr server's + // allowlist on every request, including those that resolve no JSR package. + expect(toRegistryDeclarations({ + registriesByScope: { + default: 'https://registry.npmjs.org/', + '@jsr': 'https://npm.jsr.io/', + }, + })).toStrictEqual({}) + + expect(toRegistryDeclarations({ + registriesByScope: { + default: 'https://registry.npmjs.org/', + '@jsr': 'https://jsr.corp.example/', + }, + })).toStrictEqual({ + 'https://jsr.corp.example/': { scopes: ['@jsr'] }, + }) +}) diff --git a/pnpm11/config/normalize-registries/test/tsconfig.json b/pnpm11/config/normalize-registries/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/pnpm11/config/normalize-registries/test/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "../node_modules/.test.lib", + "rootDir": "..", + "isolatedModules": true + }, + "include": [ + "**/*.ts", + "../../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": ".." + } + ] +} diff --git a/pnpm11/config/pick-registry-for-package/src/index.ts b/pnpm11/config/pick-registry-for-package/src/index.ts index 811ef3ce82..584f60ed90 100644 --- a/pnpm11/config/pick-registry-for-package/src/index.ts +++ b/pnpm11/config/pick-registry-for-package/src/index.ts @@ -1,8 +1,8 @@ -import type { NamedRegistries, Registries } from '@pnpm/types' +import type { RegistriesByPrefix, RegistriesByScope } from '@pnpm/types' -export function pickRegistryForPackage (registries: Registries, packageName: string, bareSpecifier?: string): string { +export function pickRegistryForPackage (registriesByScope: RegistriesByScope, packageName: string, bareSpecifier?: string): string { const scope = getScope(packageName, bareSpecifier) - return (scope && registries[scope]) ?? registries.default + return (scope && registriesByScope[scope]) ?? registriesByScope.default } function getScope (pkgName: string, bareSpecifier?: string): string | null { @@ -31,11 +31,11 @@ function getScope (pkgName: string, bareSpecifier?: string): string | null { * where verification traffic goes for entries that name no registry. Memoized * on the map because the caller sits behind a per-package walk. */ -export function namedRegistryTarballPrefixes (namedRegistries: NamedRegistries): readonly string[] { - let prefixes = tarballPrefixesCache.get(namedRegistries) +export function namedRegistryTarballPrefixes (registriesByPrefix: RegistriesByPrefix): readonly string[] { + let prefixes = tarballPrefixesCache.get(registriesByPrefix) if (prefixes) return prefixes - prefixes = Object.values(namedRegistries) + prefixes = Object.values(registriesByPrefix) .map((url) => { let parsed: URL try { @@ -50,8 +50,8 @@ export function namedRegistryTarballPrefixes (namedRegistries: NamedRegistries): .filter((prefix): prefix is string => prefix != null) // Tie-break equal lengths so the order does not depend on key order. .sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0)) - tarballPrefixesCache.set(namedRegistries, prefixes) + tarballPrefixesCache.set(registriesByPrefix, prefixes) return prefixes } -const tarballPrefixesCache = new WeakMap() +const tarballPrefixesCache = new WeakMap() diff --git a/pnpm11/config/pick-registry-for-package/test/knownRegistries.test.ts b/pnpm11/config/pick-registry-for-package/test/knownRegistries.test.ts index ecf2d5adfd..0829623111 100644 --- a/pnpm11/config/pick-registry-for-package/test/knownRegistries.test.ts +++ b/pnpm11/config/pick-registry-for-package/test/knownRegistries.test.ts @@ -1,31 +1,31 @@ import { expect, test } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { namedRegistryTarballPrefixes } from '@pnpm/config.pick-registry-for-package' -import { BUILTIN_NAMED_REGISTRIES } from '@pnpm/constants' +import { BUILTIN_REGISTRIES_BY_PREFIX } from '@pnpm/constants' // A new built-in redirects verification traffic; this makes that explicit. test('the default reverse-routing prefixes are exactly the built-in registries', () => { - expect(namedRegistryTarballPrefixes(normalizeNamedRegistries())).toStrictEqual([ + expect(namedRegistryTarballPrefixes(normalizeRegistriesByPrefix())).toStrictEqual([ 'https://npm.pkg.github.com/', 'https://registry.npmjs.org/', ]) }) test('every built-in alias contributes a prefix', () => { - const tarballPrefixes = namedRegistryTarballPrefixes(normalizeNamedRegistries()) + const tarballPrefixes = namedRegistryTarballPrefixes(normalizeRegistriesByPrefix()) - expect(tarballPrefixes).toHaveLength(Object.keys(BUILTIN_NAMED_REGISTRIES).length) + expect(tarballPrefixes).toHaveLength(Object.keys(BUILTIN_REGISTRIES_BY_PREFIX).length) }) test('a user mapping replaces the built-in prefix it overrides', () => { - const tarballPrefixes = namedRegistryTarballPrefixes(normalizeNamedRegistries({ npmjs: 'https://npm.internal.example/' })) + const tarballPrefixes = namedRegistryTarballPrefixes(normalizeRegistriesByPrefix({ npmjs: 'https://npm.internal.example/' })) expect(tarballPrefixes).toContain('https://npm.internal.example/') expect(tarballPrefixes).not.toContain('https://registry.npmjs.org/') }) test('prefixes are ordered longest first so the deepest match wins', () => { - const tarballPrefixes = namedRegistryTarballPrefixes(normalizeNamedRegistries({ + const tarballPrefixes = namedRegistryTarballPrefixes(normalizeRegistriesByPrefix({ team: 'https://npm.example/team', teamSub: 'https://npm.example/team/sub', })) @@ -37,7 +37,7 @@ test('prefixes are ordered longest first so the deepest match wins', () => { }) test('every prefix ends in a slash', () => { - const tarballPrefixes = namedRegistryTarballPrefixes(normalizeNamedRegistries({ noSlash: 'https://npm.example/team' })) + const tarballPrefixes = namedRegistryTarballPrefixes(normalizeRegistriesByPrefix({ noSlash: 'https://npm.example/team' })) for (const prefix of tarballPrefixes) { expect(prefix.endsWith('/')).toBe(true) @@ -45,20 +45,20 @@ test('every prefix ends in a slash', () => { }) test('a malformed user URL is dropped rather than poisoning the prefix list', () => { - const tarballPrefixes = namedRegistryTarballPrefixes(normalizeNamedRegistries({ broken: 'not a url' })) + const tarballPrefixes = namedRegistryTarballPrefixes(normalizeRegistriesByPrefix({ broken: 'not a url' })) - expect(tarballPrefixes).toStrictEqual([...namedRegistryTarballPrefixes(normalizeNamedRegistries())]) + expect(tarballPrefixes).toStrictEqual([...namedRegistryTarballPrefixes(normalizeRegistriesByPrefix())]) }) test('the normalized map is prototype-free so a crafted name cannot resolve', () => { - const namedRegistries = normalizeNamedRegistries() + const registriesByPrefix = normalizeRegistriesByPrefix() - expect(namedRegistries.constructor).toBeUndefined() - expect(namedRegistries.toString).toBeUndefined() + expect(registriesByPrefix.constructor).toBeUndefined() + expect(registriesByPrefix.toString).toBeUndefined() }) test('equal-length prefixes are ordered lexicographically, not by hash order', () => { - const tarballPrefixes = namedRegistryTarballPrefixes(normalizeNamedRegistries({ + const tarballPrefixes = namedRegistryTarballPrefixes(normalizeRegistriesByPrefix({ b: 'https://npm.example/bbb/', a: 'https://npm.example/aaa/', })) @@ -68,10 +68,10 @@ test('equal-length prefixes are ordered lexicographically, not by hash order', ( }) test('one instance is shared per alias map, so per-package callers do not rebuild it', () => { - const namedRegistries = normalizeNamedRegistries({ work: 'https://npm.enterprise.example/' }) + const registriesByPrefix = normalizeRegistriesByPrefix({ work: 'https://npm.enterprise.example/' }) - expect(namedRegistryTarballPrefixes(namedRegistries)).toBe(namedRegistryTarballPrefixes(namedRegistries)) + expect(namedRegistryTarballPrefixes(registriesByPrefix)).toBe(namedRegistryTarballPrefixes(registriesByPrefix)) // The no-config case only hits the cache because the default is shared. - expect(namedRegistryTarballPrefixes(normalizeNamedRegistries())) - .toBe(namedRegistryTarballPrefixes(normalizeNamedRegistries())) + expect(namedRegistryTarballPrefixes(normalizeRegistriesByPrefix())) + .toBe(namedRegistryTarballPrefixes(normalizeRegistriesByPrefix())) }) diff --git a/pnpm11/config/reader/src/Config.ts b/pnpm11/config/reader/src/Config.ts index 19d1a6441d..067197a834 100644 --- a/pnpm11/config/reader/src/Config.ts +++ b/pnpm11/config/reader/src/Config.ts @@ -7,8 +7,9 @@ import type { ProjectManifest, ProjectRootDir, ProjectsGraph, - Registries, + RegistriesByScope, RegistryConfig, + RegistryOptions, TrustPolicy, VersioningSettings, } from '@pnpm/types' @@ -250,16 +251,23 @@ export interface Config extends OptionsFromRootManifest { pnprServer?: string - registries: Registries - packageManagerRegistries?: Registries + registriesByScope: RegistriesByScope + packageManagerRegistries?: RegistriesByScope packageManagerNetworkConfig?: PackageManagerNetworkConfig /** * As the user wrote it. Built-ins are filled in by - * `normalizeNamedRegistries` where a lookup happens, not here — this value + * `normalizeRegistriesByPrefix` where a lookup happens, not here — this value * is also forwarded to a pnpr server, which must only be asked about * registries the project actually declares. */ - namedRegistries?: Record + registriesByPrefix?: Record + /** + * Non-secret per-registry settings from `pnpm-workspace.yaml`, keyed by + * normalized registry URL. Deliberately not folded into `configByUri`: that + * one carries credentials, and the install and lockfile layers that need a + * registry's tarball layout must not be handed its secrets. + */ + registryOptionsByUrl?: Record configByUri: Record ignoreWorkspaceRootCheck: boolean workspaceRoot: boolean diff --git a/pnpm11/config/reader/src/getOptionsFromRootManifest.ts b/pnpm11/config/reader/src/getOptionsFromRootManifest.ts index 3fa95ddbc8..e2f0414a2e 100644 --- a/pnpm11/config/reader/src/getOptionsFromRootManifest.ts +++ b/pnpm11/config/reader/src/getOptionsFromRootManifest.ts @@ -1,18 +1,24 @@ import path from 'node:path' import { envReplace } from '@pnpm/config.env-replace' -import { PnpmError } from '@pnpm/error' +import { PnpmError, redactAndSanitize } from '@pnpm/error' import { globalWarn } from '@pnpm/logger' -import type { - AllowedDeprecatedVersions, - PackageExtension, - PeerDependencyRules, - PnpmSettings, - ProjectManifest, - SupportedArchitectures, +import { + type AllowedDeprecatedVersions, + DEFAULT_REGISTRY_SCOPE, + type PackageExtension, + type PeerDependencyRules, + type PnpmSettings, + type ProjectManifest, + type RegistryDeclaration, + type RegistryOptions, + type SupportedArchitectures, } from '@pnpm/types' +import normalizeRegistryUrl from 'normalize-registry-url' import { map as mapValues } from 'ramda' +import { quoteAndJoin } from './quoteAndJoin.js' + export type OptionsFromRootManifest = { allowedDeprecatedVersions?: AllowedDeprecatedVersions allowUnusedPatches?: boolean @@ -24,6 +30,10 @@ export type OptionsFromRootManifest = { supportedArchitectures?: SupportedArchitectures allowBuilds?: Record requiredScripts?: string[] + /** The three lookups the `registries` setting is split into. */ + registriesByScope?: Record + registriesByPrefix?: Record + registryOptionsByUrl?: Record } & Pick interface GetOptionsFromPnpmSettingsOptions { @@ -69,12 +79,194 @@ export function getOptionsFromPnpmSettings ( settings.patchedDependencies[dep] = path.join(manifestDir, patchFile) } } + translateRegistrySettings(settings) translateUpdateSettings(pnpmSettings, settings) translateAuditSettings(pnpmSettings, settings) return settings } +const REGISTRY_SERVER_TYPES = new Set(['npm', 'artifactory']) + +/** + * Credentials and TLS material stay in `.npmrc`, which is not committed. + * `registries` lives in `pnpm-workspace.yaml`, which is, so accepting these + * here would invite secrets into version control. Rejecting is better than + * ignoring: a silently dropped `_authToken` reads as configured. + */ +const SECRET_REGISTRY_KEYS = new Set([ + '_auth', '_authToken', '_password', 'username', 'tokenHelper', + 'ca', 'cafile', 'cert', 'certfile', 'key', 'keyfile', +]) + +/** The fields a `registries` entry may carry. Anything else is a typo. */ +const REGISTRY_DECLARATION_FIELDS = new Set(['serverType', 'scopes', 'prefix']) + +/** + * Turns the settings that name registries into the three lookups the rest of + * pnpm reads: the scope-routed URLs, the `:`-addressed URLs, and the + * per-registry options. The declaration map itself does not travel. + */ +function translateRegistrySettings (settings: OptionsFromRootManifest): void { + // Both settings are read under the names a user writes and removed, because + // the keys the rest of pnpm reads are the lookups they feed. + const written = settings as Pick + const deprecatedPrefixes = written.namedRegistries + delete written.namedRegistries + const declarations = written.registries + delete written.registries + + const lookups = readRegistryDeclarations(declarations) + if (deprecatedPrefixes != null && Object.keys(lookups.registriesByPrefix).length > 0) { + globalWarn('Both the "registries" and "namedRegistries" settings declare registry prefixes. The deprecated "namedRegistries" setting is only read for prefixes "registries" does not declare.') + } + // A prefix a `registries` entry declares wins: the two spell the same thing. + const registriesByPrefix = { ...deprecatedPrefixes, ...lookups.registriesByPrefix } + if (Object.keys(registriesByPrefix).length > 0) settings.registriesByPrefix = registriesByPrefix + if (Object.keys(lookups.registriesByScope).length > 0) settings.registriesByScope = lookups.registriesByScope + if (Object.keys(lookups.registryOptionsByUrl).length > 0) settings.registryOptionsByUrl = lookups.registryOptionsByUrl +} + +/** + * Reads the `registries` setting and inverts the routes each entry declares, + * or returns `undefined` when there is nothing to invert. + * + * A registry is declared once, keyed by its URL, because every fact in an + * entry is a fact about that server: how it lays out tarball URLs, and which + * routes reach it. The lookups are the inverse of the routes, because a scope + * resolves to exactly one registry while a registry serves many. + * + * The older shape — a map whose values are all strings — is the scope lookup + * already, and is left in place as it stands. + */ +function readRegistryDeclarations (declarations: PnpmSettings['registries']): RegistryLookups { + const empty: RegistryLookups = { registriesByScope: {}, registriesByPrefix: {}, registryOptionsByUrl: {} } + if (declarations == null) return empty + assertObjectSetting(declarations, 'registries') + const entries = Object.entries(declarations) + const scopeRoutes = entries.filter(([, declaration]) => typeof declaration === 'string') + if (scopeRoutes.length === entries.length) { + assertNoUrlKeyedScopeRoutes(scopeRoutes.map(([scope]) => scope)) + return { ...empty, registriesByScope: Object.fromEntries(scopeRoutes as Array<[string, string]>) } + } + if (scopeRoutes.length > 0) { + throw new PnpmError('INVALID_SETTING', + `The "registries" setting mixes registry declarations with ": " entries (${quoteAndJoin(scopeRoutes.map(([scope]) => scope))}).`, + { hint: 'Key every entry by registry URL and list the scopes routed to it under "scopes".' }) + } + return splitRegistryDeclarations(entries as Array<[string, RegistryDeclaration]>) +} + +/** + * A scope routes to a registry, so a URL in that position routes nothing and + * would sit there inert. It is the declaration shape, half-written. + */ +function assertNoUrlKeyedScopeRoutes (scopes: string[]): void { + for (const scope of scopes) { + if (!looksLikeRegistryUrl(scope)) continue + throw new PnpmError('INVALID_SETTING', + `The "registries['${redactRegistryUrl(scope)}']" entry is a string.`, + { hint: 'A registry URL keys a declaration, e.g. { serverType: "artifactory" }. A string value routes a scope, and a URL is not a scope.' }) + } +} + +interface RegistryLookups { + /** Scope-routed URLs, normalized, with `@` under its `default` key. */ + registriesByScope: Record + /** + * Prefix-addressed URLs, deliberately kept as written: a named registry's + * URL is what a lockfile's recorded tarball URLs are matched against, so + * normalizing it would change what an existing lockfile verifies against. + */ + registriesByPrefix: Record + registryOptionsByUrl: Record +} + +/** Validates the declarations and inverts their routes into the lookups. */ +function splitRegistryDeclarations (entries: Array<[string, RegistryDeclaration]>): RegistryLookups { + const lookups: RegistryLookups = { registriesByScope: {}, registriesByPrefix: {}, registryOptionsByUrl: {} } + for (const [registry, declaration] of entries) { + // The URL is user config that may carry `user:pass@` credentials, and it + // is about to be interpolated into an error a terminal or CI log will show. + const settingPath = `registries['${redactRegistryUrl(registry)}']` + assertObjectSetting(declaration, settingPath) + assertKnownRegistryFields(declaration, registry, settingPath) + // The map lives in the committed pnpm-workspace.yaml, and it already + // refuses credential fields for that reason; a credential in the key is + // the same secret in the same file. A registry whose URL really carries + // credentials should move them to .npmrc, which also makes the URL here + // match the one pnpm resolves from. + if (registryUrlHasUserinfo(registry)) { + throw new PnpmError('INVALID_SETTING', + `The "${settingPath}" key embeds credentials.`, + { hint: 'Put them in an .npmrc file instead, so they are not committed.' }) + } + // Normalized for the lookups keyed by URL, so that a registry a package + // resolved from matches its declaration however either one spelled the + // trailing slash. + const normalizedRegistry = normalizeRegistryUrl(registry) + const { serverType, scopes, prefix } = declaration + if (serverType != null) { + if (!REGISTRY_SERVER_TYPES.has(serverType)) { + throw new PnpmError('INVALID_SETTING', + `The "${settingPath}.serverType" setting should be one of ${quoteAndJoin([...REGISTRY_SERVER_TYPES])}, but got ${JSON.stringify(serverType)}`) + } + lookups.registryOptionsByUrl[normalizedRegistry] = { serverType } + } + if (scopes != null) { + assertStringArray(scopes, `${settingPath}.scopes`) + for (const scope of scopes) { + if (!scope.startsWith(DEFAULT_REGISTRY_SCOPE)) { + throw new PnpmError('INVALID_SETTING', + `The "${settingPath}.scopes" setting should list "@"-prefixed scopes, but got ${JSON.stringify(scope)}`, + { hint: `A bare "${DEFAULT_REGISTRY_SCOPE}" is the scope-less default registry.` }) + } + const scopeKey = scope === DEFAULT_REGISTRY_SCOPE ? 'default' : scope + const routed = lookups.registriesByScope[scopeKey] + if (routed != null && routed !== normalizedRegistry) { + throw new PnpmError('INVALID_SETTING', + `The scope ${JSON.stringify(scope)} is routed to two registries: ${quoteAndJoin([routed, normalizedRegistry].map(redactAndSanitize))}.`) + } + lookups.registriesByScope[scopeKey] = normalizedRegistry + } + } + if (prefix != null) { + assertString(prefix, `${settingPath}.prefix`) + const declaredBy = lookups.registriesByPrefix[prefix] + if (declaredBy != null) { + throw new PnpmError('INVALID_SETTING', + `The prefix ${JSON.stringify(prefix)} is declared by two registries: ${quoteAndJoin([declaredBy, registry].map(redactAndSanitize))}.`) + } + lookups.registriesByPrefix[prefix] = registry + } + } + return lookups +} + +/** + * A misspelled field would otherwise sit there doing nothing, and a credential + * field would sit in a committed file. Both are refused here rather than by + * the yaml parser, which renders the offending source line verbatim under its + * errors — printing the very credential being refused. + */ +function assertKnownRegistryFields (declaration: RegistryDeclaration, registry: string, settingPath: string): void { + for (const field of Object.keys(declaration)) { + if (REGISTRY_DECLARATION_FIELDS.has(field)) continue + if (SECRET_REGISTRY_KEYS.has(field)) { + throw new PnpmError('INVALID_SETTING', + `The "${settingPath}.${field}" setting is not allowed in pnpm-workspace.yaml.`, + { hint: `Set "//${redactRegistryUrl(registry).replace(/^(?:https?:)?\/\//, '')}:${field}" in an .npmrc file instead, so it is not committed.` }) + } + throw new PnpmError('INVALID_SETTING', + `The "${settingPath}.${field}" setting is not a known registry setting.`, + { hint: `A registry declares ${quoteAndJoin([...REGISTRY_DECLARATION_FIELDS])}.` }) + } +} + +function looksLikeRegistryUrl (key: string): boolean { + return key.includes('://') || key.startsWith('//') +} + /** * Translates the user-facing `update` settings section into the internal * `updateConfig` shape that the rest of pnpm reads, and removes the raw @@ -252,10 +444,16 @@ function replaceEnvInSettings ( if (REQUEST_DESTINATION_SCALAR_KEYS.has(newKey) && !opts.expandRequestDestinationEnv && hasEnvPlaceholder(value)) continue // @ts-expect-error newSettings[newKey as keyof PnpmSettings] = envReplace(value, process.env) - } else if (newKey === 'registries' || newKey === 'namedRegistries') { + } else if (newKey === 'namedRegistries' || (newKey === 'registries' && isScopeRouteMap(value))) { newSettings[newKey as keyof PnpmSettings] = (opts.expandRequestDestinationEnv ? replaceEnvInStringValues(value) : copyStringValuesWithoutEnvPlaceholders(value)) as never + } else if (newKey === 'registries') { + // A declaration map is keyed by registry URL rather than valued by one, + // so the request destination is the key and the gate has to apply there. + newSettings[newKey as keyof PnpmSettings] = (opts.expandRequestDestinationEnv + ? replaceEnvInKeys(value) + : copyEntriesWithoutEnvPlaceholderKeys(value)) as never } else { newSettings[newKey as keyof PnpmSettings] = value } @@ -263,6 +461,21 @@ function replaceEnvInSettings ( return newSettings } +/** + * Whether a `registries` value is the older `: ` shape rather than + * a map of declarations. The registry URL is the value in the one and the key + * in the other, so which one carries the request destination — and so which + * one the env-placeholder gate applies to — follows from this. + * + * A map that mixes the two is a declaration map here; {@link + * translateRegistrySettings} rejects it with a message about the mixing. + */ +function isScopeRouteMap (value: unknown): boolean { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return false + const values = Object.values(value as Record) + return values.length > 0 && values.every((entry) => typeof entry === 'string') +} + function replaceEnvInStringValues (value: unknown): unknown { if (value == null || typeof value !== 'object' || Array.isArray(value)) return value const out: Record = {} @@ -282,6 +495,83 @@ function copyStringValuesWithoutEnvPlaceholders (value: unknown): unknown { return out } +function replaceEnvInKeys (value: unknown): unknown { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return value + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[envReplace(k, process.env)] = v + } + return out +} + +function copyEntriesWithoutEnvPlaceholderKeys (value: unknown): unknown { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return value + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (hasEnvPlaceholder(k)) continue + out[k] = v + } + return out +} + +/** + * Whether the authority of `url` carries a `user:pass@` prefix. The authority + * ends at the first `/`, `?`, or `#`, so a later `@` in the path is not one. + * + * Both the full form and the scheme-less `//host/` form count. The latter is + * the shape `.npmrc` scopes settings with, so it is the one a user is most + * likely to reach for here. + */ +function registryUrlHasUserinfo (url: string): boolean { + return userinfoEnd(url) !== undefined +} + +/** + * The offset just past the `user:pass@` of `url`, or `undefined` when its + * authority carries none. Splitting it out keeps the detection and the + * redaction below agreeing on what the authority is. + */ +function userinfoEnd (url: string): number | undefined { + const authorityStart = authorityStartOf(url) + if (authorityStart === undefined) return undefined + const authority = url.slice(authorityStart) + const authorityEnd = authority.search(/[/?#]/) + const at = (authorityEnd === -1 ? authority : authority.slice(0, authorityEnd)).lastIndexOf('@') + return at === -1 ? undefined : authorityStart + at + 1 +} + +/** + * Where the authority of `url` begins, or `undefined` if it has none. + * + * The scheme is anchored at the start rather than found by searching for the + * first `://`: a `://` inside the path (`//host/a://b`) would otherwise be + * taken for the separator, and the real authority — credentials and all — + * would go unexamined. + */ +function authorityStartOf (url: string): number | undefined { + const schemeEnd = url.indexOf('://') + if (schemeEnd !== -1 && SCHEME.test(url.slice(0, schemeEnd))) return schemeEnd + '://'.length + if (url.startsWith('//')) return '//'.length + return undefined +} + +const SCHEME = /^[a-z][a-z0-9+.-]*$/i + +/** + * `url` with any `user:pass@` removed, safe to put in a message. + * + * {@link redactAndSanitize} only recognizes an authority after a `://`, and + * deliberately so: it runs over arbitrary prose, where a bare `//` is more + * often a comment or a path than a URL. Here the string is known to be a + * registry URL, so the scheme-less `//host/` form can be handled too. + */ +function redactRegistryUrl (url: string): string { + const authorityStart = authorityStartOf(url) + const end = userinfoEnd(url) + if (authorityStart === undefined || end === undefined) return redactAndSanitize(url) + return redactAndSanitize(`${url.slice(0, authorityStart)}${url.slice(end)}`) +} + function hasEnvPlaceholder (value: string): boolean { return /\$\{[^}]+\}/.test(value) } diff --git a/pnpm11/config/reader/src/index.ts b/pnpm11/config/reader/src/index.ts index bd268c2be9..b113faff62 100644 --- a/pnpm11/config/reader/src/index.ts +++ b/pnpm11/config/reader/src/index.ts @@ -5,8 +5,8 @@ import { stripVTControlCharacters } from 'node:util' import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config' import { createMatcher } from '@pnpm/config.matcher' -import { GLOBAL_CONFIG_YAML_FILENAME, GLOBAL_LAYOUT_VERSION } from '@pnpm/constants' -import { PnpmError } from '@pnpm/error' +import { BUILTIN_REGISTRIES_BY_PREFIX, GLOBAL_CONFIG_YAML_FILENAME, GLOBAL_LAYOUT_VERSION } from '@pnpm/constants' +import { PnpmError, redactAndSanitize } from '@pnpm/error' import { addEsmNodePathLoaderOption } from '@pnpm/exec.esm-node-path-loader' import { getCurrentBranch } from '@pnpm/network.git-utils' import { applyRuntimeOnFailOverride } from '@pnpm/pkg-manifest.utils' @@ -50,6 +50,7 @@ import { type CliOptions as SupportedArchitecturesCliOptions, overrideSupportedArchitecturesWithCLI, } from './overrideSupportedArchitecturesWithCLI.js' +import { quoteAndJoin } from './quoteAndJoin.js' import { transformPathKeys } from './transformPath.js' import { types } from './types.js' export { types } @@ -355,7 +356,7 @@ export async function getConfig (opts: { workspaceDir: undefined, workspaceManifest: globalYamlConfig, }) - globalYamlRegistries = pnpmConfig.registries as Record | undefined + globalYamlRegistries = pnpmConfig.registriesByScope as Record | undefined } const networkConfigs = getNetworkConfigs(pnpmConfig.authConfig) const registriesFromNpmrc = { @@ -370,9 +371,9 @@ export async function getConfig (opts: { cliScopedRegistries[key.slice(0, -':registry'.length)] = normalizeRegistryUrl(value) } } - pnpmConfig.registries = { ...registriesFromNpmrc } + pnpmConfig.registriesByScope = { ...registriesFromNpmrc } if (explicitlySetKeys.has('registry') && typeof pnpmConfig.registry === 'string') { - pnpmConfig.registries.default = normalizeRegistryUrl(pnpmConfig.registry) + pnpmConfig.registriesByScope.default = normalizeRegistryUrl(pnpmConfig.registry) } pnpmConfig.packageManagerRegistries = { default: normalizeRegistryUrl(trustedAuthConfig.registry as string), @@ -507,7 +508,7 @@ export async function getConfig (opts: { workspaceManifest, }) if (workspaceManifest.registries != null) { - workspaceManifestRegistries = pnpmConfig.registries as Record | undefined + workspaceManifestRegistries = pnpmConfig.registriesByScope as Record | undefined } } } else if (cliOptions['global']) { @@ -521,7 +522,7 @@ export async function getConfig (opts: { workspaceManifest, }) if (workspaceManifest.registries != null) { - workspaceManifestRegistries = pnpmConfig.registries as Record | undefined + workspaceManifestRegistries = pnpmConfig.registriesByScope as Record | undefined } } } @@ -532,7 +533,7 @@ export async function getConfig (opts: { // via `authConfig`, so they're re-applied last here to avoid being buried // by yaml. `cliScopedRegistries` iterates raw `cliOptions` because // `explicitlySetKeys` is camelCased, which mangles `@org-a:registry`. - pnpmConfig.registries = { + pnpmConfig.registriesByScope = { ...registriesFromNpmrc, ...globalYamlRegistries, ...workspaceManifestRegistries, @@ -546,14 +547,14 @@ export async function getConfig (opts: { // as `cliScopedRegistries` — it entered `registriesFromNpmrc` via // `authConfig.registry` and would otherwise be buried by env JSON. if (explicitlySetKeys.has('registry') && typeof pnpmConfig.registry === 'string') { - pnpmConfig.registries.default = normalizeRegistryUrl(pnpmConfig.registry) + pnpmConfig.registriesByScope.default = normalizeRegistryUrl(pnpmConfig.registry) } - if (!pnpmConfig.registries.default) { - pnpmConfig.registries.default = registriesFromNpmrc.default + if (!pnpmConfig.registriesByScope.default) { + pnpmConfig.registriesByScope.default = registriesFromNpmrc.default } - for (const [scope, url] of Object.entries(pnpmConfig.registries)) { + for (const [scope, url] of Object.entries(pnpmConfig.registriesByScope)) { if (typeof url === 'string') { - pnpmConfig.registries[scope] = normalizeRegistryUrl(url) + pnpmConfig.registriesByScope[scope] = normalizeRegistryUrl(url) } } @@ -562,8 +563,8 @@ export async function getConfig (opts: { // registry configured in pnpm-workspace.yaml. Only sync when the workspace // manifest actually contributed a different default than what .npmrc provided, // and when registry was not explicitly set via CLI. - if (!explicitlySetKeys.has('registry') && pnpmConfig.registries.default !== registriesFromNpmrc.default) { - pnpmConfig.registry = pnpmConfig.registries.default + if (!explicitlySetKeys.has('registry') && pnpmConfig.registriesByScope.default !== registriesFromNpmrc.default) { + pnpmConfig.registry = pnpmConfig.registriesByScope.default } // omit some schema that the custom parser can't yet handle @@ -588,11 +589,16 @@ export async function getConfig (opts: { if (typeof value !== 'string') { throw new TypeError(`Unexpected type of registry, expecting a string but received ${JSON.stringify(value)}`) } - pnpmConfig.registries.default = normalizeRegistryUrl(value) + pnpmConfig.registriesByScope.default = normalizeRegistryUrl(value) pnpmConfig.packageManagerRegistries.default = normalizeRegistryUrl(value) } } + // After the env loop: PNPM_CONFIG_REGISTRY can still change + // `registries.default` above, and an entry matching it must not be reported + // as unused. + warnAboutUnmatchedRegistryOptions(pnpmConfig, warnings) + // When the user explicitly sets `minimumReleaseAge`, treat it as strict by // default. Without this, a user-set value would silently fall back to // installing an immature version when no mature version satisfies the @@ -1197,7 +1203,10 @@ type ProjectManifestSkippedKey = | typeof CREDENTIAL_KEYS[number] /** Every key a caller of {@link addSettingsFromWorkspaceManifestToConfig} may skip. */ -type SkippableKey = ProjectManifestSkippedKey | typeof SELF_UPDATE_SKIPPED_SETTINGS[number] +type SkippableKey = + | ProjectManifestSkippedKey + | typeof SELF_UPDATE_SKIPPED_SETTINGS[number] + | typeof GLOBAL_CONFIG_ONLY_SKIPPED_KEYS[number] const PROJECT_MANIFEST_SKIPPED_KEYS: ReadonlySet = new Set([ ...MACHINE_LOCATION_KEYS, @@ -1213,9 +1222,20 @@ const PROJECT_MANIFEST_SKIPPED_KEYS: ReadonlySet = ne * `--config.config-dir` would land back on a key the reader resolves for * itself, and only for the users who happen to have a `config.yaml`. */ -const GLOBAL_CONFIG_SKIPPED_KEYS: ReadonlySet = new Set( - [...PROJECT_MANIFEST_SKIPPED_KEYS].filter((key) => !isConfigFileKey(kebabCase(key))) -) +/** + * Only the layout half of a `registries` entry is workspace-only: it decides + * which tarball URLs are omitted from the lockfile, so a machine-local setting + * would make one developer write a lockfile their collaborators read back with + * a different layout. The routes to the registry are a legitimate global + * preference, which is why the whole `registries` key is not refused here. + */ +const GLOBAL_CONFIG_ONLY_SKIPPED_KEYS = ['registryOptionsByUrl'] as const satisfies ReadonlyArray + +const GLOBAL_CONFIG_SKIPPED_KEYS: ReadonlySet = new Set([ + ...[...PROJECT_MANIFEST_SKIPPED_KEYS].filter((key) => !isConfigFileKey(kebabCase(key))), + ...GLOBAL_CONFIG_ONLY_SKIPPED_KEYS, +]) + /** * Whether a project's `pnpm-workspace.yaml` would ignore this camelCase key. @@ -1347,7 +1367,29 @@ function addSettingsFromWorkspaceManifestToConfig (pnpmConfig: Config & ConfigCo pnpmConfig.catalogs = getCatalogsFromWorkspaceManifest(workspaceManifest) } -/** Renders keys for a warning that lists the settings it ignored. */ -function quoteAndJoin (keys: string[]): string { - return keys.map(key => `"${key}"`).join(', ') +/** + * A `registries` entry that routes nothing to itself — no `scopes`, no + * `prefix` — describes a registry configured elsewhere, so it only takes + * effect when its key is one pnpm actually resolves from. A key that matches + * none is inert: the wrong URL, a stale entry, a scope that moved. Warn rather + * than throw, because a shared config dependency can legitimately describe + * registries a given project does not use. + */ +function warnAboutUnmatchedRegistryOptions (config: Config, warnings: string[]): void { + const registryOptionsByUrl = config.registryOptionsByUrl + if (registryOptionsByUrl == null) return + const configuredRegistries = new Set([ + ...Object.values(config.registriesByScope), + ...Object.values(config.registriesByPrefix ?? {}), + ...Object.values(BUILTIN_REGISTRIES_BY_PREFIX), + ].map(normalizeRegistryUrl)) + const unmatched = Object.keys(registryOptionsByUrl).filter((registry) => !configuredRegistries.has(registry)) + if (unmatched.length === 0) return + // A registry URL can carry `user:pass@` credentials, and the global config's + // keys have had `${VAR}` expanded by this point, so neither list may be + // echoed raw into a terminal or a CI log. + warnings.push( + `The following "registries" entries do not match any configured registry and were ignored: ${quoteAndJoin(unmatched.map(redactAndSanitize))}. ` + + `The configured registries are: ${quoteAndJoin([...configuredRegistries].sort().map(redactAndSanitize))}.` + ) } diff --git a/pnpm11/config/reader/src/localConfig.ts b/pnpm11/config/reader/src/localConfig.ts index c2c2aafaef..d5530d7d19 100644 --- a/pnpm11/config/reader/src/localConfig.ts +++ b/pnpm11/config/reader/src/localConfig.ts @@ -42,7 +42,7 @@ const AUTH_CFG_KEYS = [ 'configByUri', 'key', 'registry', - 'registries', + 'registriesByScope', ] satisfies Array /** diff --git a/pnpm11/config/reader/src/packageManagerRegistries.ts b/pnpm11/config/reader/src/packageManagerRegistries.ts index 8041f535a3..4875ae57a0 100644 --- a/pnpm11/config/reader/src/packageManagerRegistries.ts +++ b/pnpm11/config/reader/src/packageManagerRegistries.ts @@ -1,4 +1,4 @@ -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import type { Config } from './Config.js' @@ -15,11 +15,11 @@ export interface PackageManagerBootstrapConfig { key?: string localAddress?: string noProxy?: string | boolean - registries: Registries + registriesByScope: RegistriesByScope strictSsl?: boolean } -export function getPackageManagerRegistries (config: PackageManagerConfig): Registries { +export function getPackageManagerRegistries (config: PackageManagerConfig): RegistriesByScope { return { default: DEFAULT_PACKAGE_MANAGER_REGISTRY, ...config.packageManagerRegistries, @@ -36,7 +36,7 @@ export function getPackageManagerBootstrapConfig (config: PackageManagerConfig): key: config.packageManagerNetworkConfig?.key, localAddress: config.packageManagerNetworkConfig?.localAddress, noProxy: config.packageManagerNetworkConfig?.noProxy, - registries: getPackageManagerRegistries(config), + registriesByScope: getPackageManagerRegistries(config), strictSsl: config.packageManagerNetworkConfig?.strictSsl, } } diff --git a/pnpm11/config/reader/src/quoteAndJoin.ts b/pnpm11/config/reader/src/quoteAndJoin.ts new file mode 100644 index 0000000000..65a5ee8f0a --- /dev/null +++ b/pnpm11/config/reader/src/quoteAndJoin.ts @@ -0,0 +1,4 @@ +/** Renders values for a message that lists the settings it objected to. */ +export function quoteAndJoin (values: string[]): string { + return values.map((value) => `"${value}"`).join(', ') +} diff --git a/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts b/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts index 7c47b24462..e0bbed2aee 100644 --- a/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts +++ b/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts @@ -1,3 +1,5 @@ +import util from 'node:util' + import { afterEach, expect, test } from '@jest/globals' import type { PackageExtension } from '@pnpm/types' @@ -26,7 +28,7 @@ test('getOptionsFromPnpmSettings() ignores env variables inside registries value '@scope': 'https://registry.example.com/${PNPM_TEST_TOKEN}/', }, }) as any // eslint-disable-line - expect(options.registries).toStrictEqual({ + expect(options.registriesByScope).toStrictEqual({ default: 'https://registry.npmjs.org/', }) }) @@ -38,7 +40,7 @@ test('getOptionsFromPnpmSettings() ignores env variables inside namedRegistries work: 'https://${PNPM_TEST_HOST}/npm/', }, } as any) as any // eslint-disable-line - expect(options.namedRegistries).toStrictEqual({}) + expect(options.registriesByPrefix).toBeUndefined() }) test('getOptionsFromPnpmSettings() ignores env variables inside registry setting', () => { @@ -71,10 +73,10 @@ test('getOptionsFromPnpmSettings() may expand env variables inside trusted reque } as any, { expandRequestDestinationEnv: true }) as any // eslint-disable-line expect(options.pnprServer).toBe('https://registry.example.com/pnpr/') expect(options.registry).toBe('https://registry.example.com/npm/') - expect(options.registries).toStrictEqual({ + expect(options.registriesByScope).toStrictEqual({ '@scope': 'https://registry.example.com/scope/', }) - expect(options.namedRegistries).toStrictEqual({ + expect(options.registriesByPrefix).toStrictEqual({ work: 'https://registry.example.com/work/', }) }) @@ -297,3 +299,236 @@ test('getOptionsFromPnpmSettings() accepts valid packageExtensions', () => { }, }) }) + +test('getOptionsFromPnpmSettings() keys the registry options by normalized registry URL', () => { + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://artifactory.example/artifactory/api/npm/npm-virtual': { serverType: 'artifactory' }, + }, + }) + expect(options.registryOptionsByUrl).toStrictEqual({ + 'https://artifactory.example/artifactory/api/npm/npm-virtual/': { serverType: 'artifactory' }, + }) +}) + +test('getOptionsFromPnpmSettings() rejects an unknown registry server type', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.example.com/': { serverType: 'nexus' as never }, + }, + })).toThrow(/should be one of "npm", "artifactory", but got "nexus"/) +}) + +test('getOptionsFromPnpmSettings() rejects credentials in a registry declaration', () => { + // pnpm-workspace.yaml is committed; credentials belong in .npmrc. + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.example.com/': { _authToken: 'secret' } as never, + }, + })).toThrow(/is not allowed in pnpm-workspace\.yaml/) +}) + +test('getOptionsFromPnpmSettings() rejects a URL-keyed registries entry written as a string', () => { + // A map of strings is the `: ` shape, and a URL is not a scope, + // so this one would otherwise be read as a route that can never match. + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.example.com/': 'artifactory' as never, + }, + })).toThrow(/is a string/) +}) + +test('getOptionsFromPnpmSettings() rejects a registries map that mixes both shapes', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + '@acme': 'https://npm.example.com/', + 'https://artifactory.example/': { serverType: 'artifactory' }, + } as never, + })).toThrow(/mixes registry declarations with ": " entries \("@acme"\)/) +}) + +test('getOptionsFromPnpmSettings() drops a registries entry whose URL has an unexpanded env placeholder', () => { + // The key is a request destination, so it gets the same gate the `registries` + // values get: no silent expansion outside a trusted context. + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://${PNPM_TEST_HOST}/': { serverType: 'artifactory' }, + }, + }) + expect(options.registryOptionsByUrl).toBeUndefined() +}) + +test('getOptionsFromPnpmSettings() expands the registries key when request destinations may be expanded', () => { + process.env.PNPM_TEST_HOST = 'artifactory.example' + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://${PNPM_TEST_HOST}/': { serverType: 'artifactory' }, + }, + }, { expandRequestDestinationEnv: true }) + expect(options.registryOptionsByUrl).toStrictEqual({ + 'https://artifactory.example/': { serverType: 'artifactory' }, + }) +}) + +test('getOptionsFromPnpmSettings() rejects credentials embedded in a registries key', () => { + // pnpm-workspace.yaml is committed; credentials belong in .npmrc. + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://ci-user-6e42:hunter2@npm.example.com/': { serverType: 'artifactory' }, + }, + })).toThrow(/key embeds credentials/) +}) + +test('getOptionsFromPnpmSettings() does not mistake an @ later in the path for credentials', () => { + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.example.com/scope@1/': { serverType: 'artifactory' }, + }, + }) + expect(options.registryOptionsByUrl).toStrictEqual({ + 'https://npm.example.com/scope@1/': { serverType: 'artifactory' }, + }) +}) + +test('getOptionsFromPnpmSettings() rejects credentials in a scheme-less registries key', () => { + // `.npmrc` scopes settings with a scheme-less `//host/`, and this setting's + // own error points users at that syntax, so it is the form they are most + // likely to write. + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + '//ci-user-6e42:hunter2@npm.example.com/': { serverType: 'artifactory' }, + }, + })).toThrow(/key embeds credentials/) + + // The message names the key, so it must not carry the credentials with it. + try { + getOptionsFromPnpmSettings(process.cwd(), { + registries: { + '//ci-user-6e42:hunter2@npm.example.com/': { serverType: 'artifactory' }, + }, + }) + } catch (err) { + const message = util.types.isNativeError(err) ? err.message : String(err) + expect(message).not.toContain('hunter2') + expect(message).not.toContain('ci-user-6e42') + expect(message).toContain('npm.example.com') + } +}) + +test('getOptionsFromPnpmSettings() accepts a scheme-less registries key without credentials', () => { + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + '//npm.example.com/': { serverType: 'artifactory' }, + }, + }) + expect(options.registryOptionsByUrl).toStrictEqual({ + '//npm.example.com/': { serverType: 'artifactory' }, + }) +}) + +test('getOptionsFromPnpmSettings() is not fooled by a :// later in a scheme-less registries key', () => { + // Searching for the first `://` would find the one in the path and parse the + // authority from there, leaving the real credentials unexamined. + const key = '//ci-user-6e42:hunter2@npm.example.com/a://b' + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { [key]: { serverType: 'artifactory' } }, + })).toThrow(/key embeds credentials/) + + try { + getOptionsFromPnpmSettings(process.cwd(), { + registries: { [key]: { serverType: 'artifactory' } }, + }) + } catch (err) { + const message = util.types.isNativeError(err) ? err.message : String(err) + expect(message).not.toContain('hunter2') + expect(message).not.toContain('ci-user-6e42') + } +}) + +test('getOptionsFromPnpmSettings() routes the declared scopes to the registry', () => { + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example': { scopes: ['@', '@foo', '@bar'], serverType: 'npm' }, + }, + }) as any // eslint-disable-line + expect(options.registriesByScope).toStrictEqual({ + default: 'https://npm.corp.example/', + '@foo': 'https://npm.corp.example/', + '@bar': 'https://npm.corp.example/', + }) +}) + +test('getOptionsFromPnpmSettings() rejects a scope declared without its @', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example/': { scopes: ['foo'] }, + }, + })).toThrow(/should list "@"-prefixed scopes, but got "foo"/) +}) + +test('getOptionsFromPnpmSettings() rejects one scope routed to two registries', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example/': { scopes: ['@foo'] }, + 'https://artifactory.example/': { scopes: ['@foo'] }, + }, + })).toThrow(/The scope "@foo" is routed to two registries/) +}) + +test('getOptionsFromPnpmSettings() reads a declared prefix as a named registry', () => { + // Keyed by the URL as written: a named registry's URL is what a lockfile's + // recorded tarball URLs are matched against, so normalizing it here would + // change what an existing lockfile verifies against. + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example': { prefix: 'work', serverType: 'artifactory' }, + }, + }) + expect(options.registriesByPrefix).toStrictEqual({ work: 'https://npm.corp.example' }) + expect(options.registryOptionsByUrl).toStrictEqual({ + 'https://npm.corp.example/': { serverType: 'artifactory' }, + }) +}) + +test('getOptionsFromPnpmSettings() rejects one prefix declared by two registries', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example/': { prefix: 'work' }, + 'https://artifactory.example/': { prefix: 'work' }, + }, + })).toThrow(/The prefix "work" is declared by two registries/) +}) + +test('getOptionsFromPnpmSettings() lets a declared prefix win over the deprecated namedRegistries', () => { + const options = getOptionsFromPnpmSettings(process.cwd(), { + namedRegistries: { + work: 'https://stale.example/', + other: 'https://other.example/', + }, + registries: { + 'https://npm.corp.example/': { prefix: 'work' }, + }, + }) + expect(options.registriesByPrefix).toStrictEqual({ + work: 'https://npm.corp.example/', + other: 'https://other.example/', + }) +}) + +test('getOptionsFromPnpmSettings() records no options for a registry that only declares routes', () => { + const options = getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example/': { scopes: ['@foo'] }, + }, + }) + expect(options.registryOptionsByUrl).toBeUndefined() +}) + +test('getOptionsFromPnpmSettings() rejects an unknown field in a registry declaration', () => { + // A misspelled field would otherwise sit there doing nothing. + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + registries: { + 'https://npm.corp.example/': { scope: '@acme' } as never, + }, + })).toThrow(/is not a known registry setting/) +}) diff --git a/pnpm11/config/reader/test/index.ts b/pnpm11/config/reader/test/index.ts index 9848fc8e92..456439686a 100644 --- a/pnpm11/config/reader/test/index.ts +++ b/pnpm11/config/reader/test/index.ts @@ -1118,7 +1118,7 @@ test('registries of scoped packages are read and normalized', async () => { }, }) - expect(config.registries).toStrictEqual({ + expect(config.registriesByScope).toStrictEqual({ default: 'https://default.com/', '@jsr': 'https://npm.jsr.io/', '@foo': 'https://foo.com/', @@ -1142,7 +1142,7 @@ test('registries in current directory\'s .npmrc have bigger priority then global }, }) - expect(config.registries).toStrictEqual({ + expect(config.registriesByScope).toStrictEqual({ default: 'https://pnpm.io/', '@jsr': 'https://npm.jsr.io/', '@foo': 'https://foo.com/', @@ -1166,8 +1166,8 @@ test('project .npmrc does not expand env variables in registry URLs', async () = }, }) - expect(config.registries.default).not.toBe('https://registry.example.com/secret/') - expect(JSON.stringify(config.registries)).not.toContain('secret') + expect(config.registriesByScope.default).not.toBe('https://registry.example.com/secret/') + expect(JSON.stringify(config.registriesByScope)).not.toContain('secret') expect(warnings).toEqual(expect.arrayContaining([ expect.stringContaining('Ignored project-level request destination "registry"'), ])) @@ -1196,7 +1196,7 @@ test('project .npmrc does not expand env variables in scoped registry URLs or UR }, }) - expect(config.registries['@scope']).toBeUndefined() + expect(config.registriesByScope['@scope']).toBeUndefined() expect(Object.keys(config.authConfig).join('\n')).not.toContain('secret') expect(warnings).toEqual(expect.arrayContaining([ expect.stringContaining('Ignored project-level request destination "@scope:registry"'), @@ -1331,7 +1331,7 @@ test('user .npmrc may expand env variables in registry URLs', async () => { }, }) - expect(config.registries.default).toBe('https://registry.example.com/secret/') + expect(config.registriesByScope.default).toBe('https://registry.example.com/secret/') }) test('pnpm-workspace.yaml registries override the same scope from .npmrc (#11492)', async () => { @@ -1350,7 +1350,7 @@ test('pnpm-workspace.yaml registries override the same scope from .npmrc (#11492 workspaceDir: process.cwd(), }) - expect(config.registries['@my-org']).toBe('https://from-workspace-yaml.example.com/') + expect(config.registriesByScope['@my-org']).toBe('https://from-workspace-yaml.example.com/') }) test('pnpm-workspace.yaml registries.default is reflected in config.registry (#10099)', async () => { @@ -1369,7 +1369,7 @@ test('pnpm-workspace.yaml registries.default is reflected in config.registry (#1 }) expect(config.registry).toBe('https://private.example.com/') - expect(config.registries.default).toBe('https://private.example.com/') + expect(config.registriesByScope.default).toBe('https://private.example.com/') }) test('pnpm-workspace.yaml request destinations do not expand env variables', async () => { @@ -1398,9 +1398,9 @@ test('pnpm-workspace.yaml request destinations do not expand env variables', asy workspaceDir: process.cwd(), }) - expect(config.registries.default).not.toBe('https://private.example.com/secret/') - expect(config.registries['@scope']).toBeUndefined() - expect(config.namedRegistries).toStrictEqual({}) + expect(config.registriesByScope.default).not.toBe('https://private.example.com/secret/') + expect(config.registriesByScope['@scope']).toBeUndefined() + expect(config.registriesByPrefix).toBeUndefined() expect(config.pnprServer).toBeUndefined() expect(config.httpsProxy).toBeUndefined() expect(config.httpProxy).toBeUndefined() @@ -1446,7 +1446,7 @@ test('package manager bootstrap registries ignore project workspace registries', workspaceDir: process.cwd(), }) - expect(config.registries).toMatchObject({ + expect(config.registriesByScope).toMatchObject({ '@pnpm': 'https://workspace-pnpm.example.com/', default: 'https://workspace.example.com/', }) @@ -1753,7 +1753,7 @@ test('pnpm_config__auth normalizes registry URL keys before keying auth', async }) expect(config.authConfig['//json-test.example/:_authToken']).toBe('json-token') - expect(config.registries.default).toBe('https://json-test.example/') + expect(config.registriesByScope.default).toBe('https://json-test.example/') }) test('host-keyed pnpm_config__auth supports scoped tokens on a shared host', async () => { @@ -1892,7 +1892,7 @@ test('global config.yaml registries cannot redirect pnpm_config__auth routes', a // `_auth` routes sit above global config.yaml in the registries merge, so a // user's global registry alias cannot rebind an `_auth` token's host. - expect(config.registries['@victim-scope']).toBe('https://registry.npmjs.org/') + expect(config.registriesByScope['@victim-scope']).toBe('https://registry.npmjs.org/') expect(config.authConfig['//attacker.example/:_authToken']).toBeUndefined() expect(config.authConfig['//registry.npmjs.org/:@victim-scope:_authToken']).toBe('secret-token') }) @@ -2105,7 +2105,7 @@ test('pnpm_config__auth "@" scope routes the default registry to its host', asyn packageManager: { name: 'pnpm', version: '1.0.0' }, }) - expect(config.registries.default).toBe('https://my-npm-proxy.example/') + expect(config.registriesByScope.default).toBe('https://my-npm-proxy.example/') expect(config.registry).toBe('https://my-npm-proxy.example/') expect(config.authConfig['//my-npm-proxy.example/:_authToken']).toBe('proxy-token') }) @@ -2128,7 +2128,7 @@ test('pnpm_config__auth keeps the last duplicate route in source order', async ( packageManager: { name: 'pnpm', version: '1.0.0' }, }) - expect(config.registries.default).toBe('https://aaa.example/') + expect(config.registriesByScope.default).toBe('https://aaa.example/') }) test('pnpm_config__auth scoped entry routes that scope to its host', async () => { @@ -2147,7 +2147,7 @@ test('pnpm_config__auth scoped entry routes that scope to its host', async () => packageManager: { name: 'pnpm', version: '1.0.0' }, }) - expect(config.registries['@org']).toBe('https://npm.pkg.github.com/') + expect(config.registriesByScope['@org']).toBe('https://npm.pkg.github.com/') }) test('pnpm_config__auth env default registry wins over pnpm-workspace.yaml default', async () => { @@ -2173,7 +2173,7 @@ test('pnpm_config__auth env default registry wins over pnpm-workspace.yaml defau workspaceDir: process.cwd(), }) - expect(config.registries.default).toBe('https://my-npm-proxy.example/') + expect(config.registriesByScope.default).toBe('https://my-npm-proxy.example/') expect(config.registry).toBe('https://my-npm-proxy.example/') }) @@ -2200,7 +2200,7 @@ test('pnpm_config__auth env scoped registry wins over pnpm-workspace.yaml scoped workspaceDir: process.cwd(), }) - expect(config.registries['@victim-scope']).toBe('https://registry.npmjs.org/') + expect(config.registriesByScope['@victim-scope']).toBe('https://registry.npmjs.org/') expect(config.authConfig['//attacker.example/:_authToken']).toBeUndefined() expect(config.authConfig['//attacker.example/:@victim-scope:_authToken']).toBeUndefined() expect(config.authConfig['//registry.npmjs.org/:@victim-scope:_authToken']).toBe('secret-token') @@ -2227,7 +2227,7 @@ test('pnpm_config__auth scoped registry wins over user .npmrc scoped registry', packageManager: { name: 'pnpm', version: '1.0.0' }, }) - expect(config.registries['@org']).toBe('https://env.example/') + expect(config.registriesByScope['@org']).toBe('https://env.example/') expect(config.packageManagerRegistries?.['@org']).toBe('https://env.example/') }) @@ -2249,7 +2249,7 @@ test('CLI --registry overrides pnpm_config__auth default registry routing', asyn packageManager: { name: 'pnpm', version: '1.0.0' }, }) - expect(config.registries.default).toBe('https://cli-registry.example/') + expect(config.registriesByScope.default).toBe('https://cli-registry.example/') expect(config.registry).toBe('https://cli-registry.example/') expect(config.packageManagerRegistries?.default).toBe('https://cli-registry.example/') // The token is still pinned to the env-declared host. @@ -2300,7 +2300,7 @@ test('CLI scoped registry overrides pnpm_config__auth in packageManagerRegistrie packageManager: { name: 'pnpm', version: '1.0.0' }, }) - expect(config.registries['@org']).toBe('https://cli-registry.example/') + expect(config.registriesByScope['@org']).toBe('https://cli-registry.example/') expect(config.packageManagerRegistries?.['@org']).toBe('https://cli-registry.example/') // The token stays pinned to the env-declared host, not the CLI override host. expect(config.authConfig['//my-npm-proxy.example/:@org:_authToken']).toBe('org-token') @@ -2372,8 +2372,8 @@ test('_auth from the global config yaml configures registry auth and routing', a expect(config.authConfig['//json-test.example/:_authToken']).toBe('yaml-token') expect(config.authConfig['//json-test.example/:@org:_authToken']).toBe('org-yaml-token') - expect(config.registries.default).toBe('https://json-test.example/') - expect(config.registries['@org']).toBe('https://json-test.example/') + expect(config.registriesByScope.default).toBe('https://json-test.example/') + expect(config.registriesByScope['@org']).toBe('https://json-test.example/') }) test('pnpm_config__auth env wins over global yaml _auth on the same key', async () => { @@ -2430,7 +2430,7 @@ test('_auth in a project pnpm-workspace.yaml is ignored (not honored as registry }) expect(config.authConfig['//attacker.example/:_authToken']).toBeUndefined() - expect(config.registries.default).not.toBe('https://attacker.example/') + expect(config.registriesByScope.default).not.toBe('https://attacker.example/') }) async function getConfigWithGlobalYaml ( @@ -3919,7 +3919,7 @@ test('loads setting from environment variable pnpm_config_*', async () => { expect(config.hoistPattern).toStrictEqual(['react', 'react-dom']) expect(config.trustPolicyExclude).toStrictEqual(['foo', 'bar']) expect(config.registry).toBe('https://registry.example.com/') - expect(config.registries.default).toBe('https://registry.example.com/') + expect(config.registriesByScope.default).toBe('https://registry.example.com/') }) test('environment variable pnpm_config_* should override pnpm-workspace.yaml', async () => { @@ -4134,7 +4134,7 @@ describe('global config.yaml', () => { expect(config.pnprServer).toBe('https://trusted.example.com/pnpr/') expect(config.registry).toBe('https://trusted.example.com/npm/') - expect(config.registries.default).toBe('https://trusted.example.com/npm/') + expect(config.registriesByScope.default).toBe('https://trusted.example.com/npm/') }) test('reads registries and named registries from global config.yaml', async () => { @@ -4163,9 +4163,9 @@ describe('global config.yaml', () => { workspaceDir: process.cwd(), }) - expect(config.registries.default).toBe('https://trusted.example.com/npm/') - expect(config.registries['@scope']).toBe('https://trusted.example.com/scope/') - expect(config.namedRegistries).toStrictEqual({ + expect(config.registriesByScope.default).toBe('https://trusted.example.com/npm/') + expect(config.registriesByScope['@scope']).toBe('https://trusted.example.com/scope/') + expect(config.registriesByPrefix).toStrictEqual({ work: 'https://trusted.example.com/work/', }) expect(warnings.find((w) => w.includes('global config file'))).toBeUndefined() @@ -4198,8 +4198,8 @@ describe('global config.yaml', () => { workspaceDir: process.cwd(), }) - expect(config.registries.default).toBe('https://workspace.example.com/npm/') - expect(config.registries['@scope']).toBe('https://global.example.com/scope/') + expect(config.registriesByScope.default).toBe('https://workspace.example.com/npm/') + expect(config.registriesByScope['@scope']).toBe('https://global.example.com/scope/') }) test('reads user-level preference settings from global config.yaml', async () => { @@ -4906,3 +4906,177 @@ test('catalogPrune overrides its former name', async () => { expect(config.catalogPrune).toBe(false) }) + +test('getConfig() routes the scopes and the prefix a registry declares', async () => { + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + registries: { + 'https://npm.corp.example/': { scopes: ['@foo', '@bar'], serverType: 'artifactory' }, + 'https://npm.other.example/': { prefix: 'other' }, + }, + }) + + const { config } = await getConfig({ + cliOptions: {}, + packageManager: { name: 'pnpm', version: '1.0.0' }, + workspaceDir: process.cwd(), + }) + + expect(config.registriesByScope['@foo']).toBe('https://npm.corp.example/') + expect(config.registriesByScope['@bar']).toBe('https://npm.corp.example/') + // Declaring a scope leaves the default registry alone. + expect(config.registriesByScope.default).toBe('https://registry.npmjs.org/') + expect(config.registriesByPrefix).toStrictEqual({ other: 'https://npm.other.example/' }) + expect(config.registryOptionsByUrl).toStrictEqual({ + 'https://npm.corp.example/': { serverType: 'artifactory' }, + }) +}) + +test('getConfig() reads a bare @ in scopes as the default registry', async () => { + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + registries: { + 'https://npm.corp.example/': { scopes: ['@'] }, + }, + }) + + const { config } = await getConfig({ + cliOptions: {}, + packageManager: { name: 'pnpm', version: '1.0.0' }, + workspaceDir: process.cwd(), + }) + + expect(config.registriesByScope.default).toBe('https://npm.corp.example/') + expect(config.registry).toBe('https://npm.corp.example/') +}) + +test('getConfig() still reads the older scope-keyed registries map', async () => { + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + registries: { + '@foo': 'https://npm.corp.example/', + }, + }) + + const { config } = await getConfig({ + cliOptions: {}, + packageManager: { name: 'pnpm', version: '1.0.0' }, + workspaceDir: process.cwd(), + }) + + expect(config.registriesByScope['@foo']).toBe('https://npm.corp.example/') + expect(config.registriesByScope.default).toBe('https://registry.npmjs.org/') +}) + +test('getConfig() warns about registries entries that match no configured registry', async () => { + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + registry: 'https://npm.example.com/', + registries: { + 'https://npm.example.com/': { serverType: 'artifactory' }, + 'https://typo.example.com/': { serverType: 'artifactory' }, + }, + }) + + const { warnings } = await getConfig({ + cliOptions: {}, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + }) + + const registryOptionsWarnings = warnings.filter((warning) => warning.includes('"registries" entries')) + expect(registryOptionsWarnings).toHaveLength(1) + expect(registryOptionsWarnings[0]).toContain('were ignored: "https://typo.example.com/".') + // The declared registry matched, so it must not be listed as ignored. + expect(registryOptionsWarnings[0]).not.toContain('were ignored: "https://npm.example.com/"') + expect(registryOptionsWarnings[0]).toContain('The configured registries are: ') +}) + +test('getConfig() does not warn about a registries entry matched by PNPM_CONFIG_REGISTRY', async () => { + // The env loop sets `registries.default` after the yaml is merged, so the + // check has to run downstream of it or it reports a registry that is used. + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + registries: { + 'https://from-env.example.com/': { serverType: 'artifactory' }, + }, + }) + + const { warnings } = await getConfig({ + cliOptions: {}, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + env: { ...process.env, PNPM_CONFIG_REGISTRY: 'https://from-env.example.com/' }, + }) + + expect(warnings.filter((warning) => warning.includes('"registries" entries'))).toStrictEqual([]) +}) + +test('getConfig() redacts credentials in the unmatched registries warning', async () => { + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + registry: 'https://ci-user-6e42:hunter2@npm.example.com/', + registries: { + 'https://typo.example.com/': { serverType: 'artifactory' }, + }, + }) + + const { warnings } = await getConfig({ + cliOptions: {}, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + }) + + const registryOptionsWarnings = warnings.filter((warning) => warning.includes('"registries" entries')) + expect(registryOptionsWarnings).toHaveLength(1) + expect(registryOptionsWarnings[0]).not.toContain('hunter2') + // A URL username is credential-bearing too. + expect(registryOptionsWarnings[0]).not.toContain('ci-user-6e42') + expect(registryOptionsWarnings[0]).toContain('npm.example.com') +}) + +test('getConfig() never expands an env placeholder in a registries key from a workspace manifest', async () => { + // pnpm-workspace.yaml is repo-controlled. Expanding `${VAR}` in a registry + // URL there is how a token would reach an attacker-chosen host, so the entry + // is dropped rather than expanded — and nothing echoes the secret either. + prepareEmpty() + process.env.PNPM_TEST_REGISTRY_TOKEN = 'super-secret-token' + + writeYamlFileSync('pnpm-workspace.yaml', { + registries: { + 'https://evil.example.com/${PNPM_TEST_REGISTRY_TOKEN}/': { serverType: 'artifactory' }, + }, + }) + + try { + const { config, warnings } = await getConfig({ + cliOptions: {}, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + }) + + expect(config.registryOptionsByUrl).toBeUndefined() + expect(JSON.stringify(config)).not.toContain('super-secret-token') + expect(warnings.join('\n')).not.toContain('super-secret-token') + } finally { + delete process.env.PNPM_TEST_REGISTRY_TOKEN + } +}) diff --git a/pnpm11/core/constants/src/index.ts b/pnpm11/core/constants/src/index.ts index da3384e328..f7582967f5 100644 --- a/pnpm11/core/constants/src/index.ts +++ b/pnpm11/core/constants/src/index.ts @@ -14,7 +14,7 @@ export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml' /** * Named-registry aliases that work without any configuration. User entries in - * the `namedRegistries` setting are merged on top and may override these + * the `registriesByPrefix` setting are merged on top and may override these * (e.g. GHES users can point `gh` at their own enterprise host). * * `npmjs` is here so a dependency can be pinned to the public registry even @@ -26,7 +26,7 @@ export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml' * against, an org that proxies npmjs should point `npmjs` at their proxy so * verification keeps going there rather than to the public host. */ -export const BUILTIN_NAMED_REGISTRIES: Readonly> = Object.freeze({ +export const BUILTIN_REGISTRIES_BY_PREFIX: Readonly> = Object.freeze({ gh: 'https://npm.pkg.github.com/', npmjs: 'https://registry.npmjs.org/', }) diff --git a/pnpm11/core/types/src/misc.ts b/pnpm11/core/types/src/misc.ts index 886f0306ea..b58122a68f 100644 --- a/pnpm11/core/types/src/misc.ts +++ b/pnpm11/core/types/src/misc.ts @@ -14,23 +14,96 @@ export const DEPENDENCIES_OR_PEER_FIELDS: DependenciesOrPeersField[] = [ 'peerDependencies', ] -export interface Registries { +export interface RegistriesByScope { default: string [scope: string]: string } /** - * The required keys are the enforcement: a raw `namedRegistries` from user + * The required keys are the enforcement: a raw `registriesByPrefix` from user * config does not satisfy this type, so it cannot reach a consumer without - * passing through `normalizeNamedRegistries`. Widening it is how adding a + * passing through `normalizeRegistriesByPrefix`. Widening it is how adding a * built-in gets acknowledged. */ -export interface NamedRegistries { +export interface RegistriesByPrefix { gh: string npmjs: string [name: string]: string } +/** + * The software serving a registry, declared through the `registries` setting. + * Three states, because "behaves like the npm registry" is a claim only the + * operator can make: + * + * - undeclared — strict. Only the exact canonical URL is reconstructible. + * This is how every registry but registry.npmjs.org is read by default. + * - `npm` — behaves like registry.npmjs.org, which serves a scoped package + * from the percent-encoded path as well as the unencoded one. A faithful + * mirror or caching proxy of the public registry is this. + * - `artifactory` — repeats the scope in a scoped package's tarball filename. + * + * Only layouts pnpm can rebuild a URL for belong here. A registry that serves + * tarballs from a content-derived path (GitHub Packages + * `/download////`) has no value: the digest is a + * fact about the bytes rather than about the package's identity, so its URLs + * are kept in the lockfile instead. + */ +export type RegistryServerType = 'npm' | 'artifactory' + +/** + * Non-secret, per-registry settings from the `registries` setting. Held apart + * from {@link RegistryConfig} so the install and lockfile layers can be handed + * a registry's layout without also being handed its credentials. + */ +export interface RegistryOptions { + serverType?: RegistryServerType +} + +/** + * One entry of the `registries` setting: everything a project declares about + * a registry, keyed by its URL so each fact is stated once. + * + * `scopes` and `prefix` are the routes *to* the registry, and are inverted + * into {@link RegistriesByScope} and {@link RegistriesByPrefix} when the config is read + * — a scope resolves to one registry, but a registry serves many, so the + * declaration reads the way it is written and each lookup keeps the shape it + * is queried in. + */ +export interface RegistryDeclaration extends RegistryOptions { + /** + * The scopes routed here, `@`-prefixed. {@link DEFAULT_REGISTRY_SCOPE} + * (a bare `@`) is the registry packages resolve from when no scope matches, + * the same registry the `registry` setting names. + */ + scopes?: string[] + /** + * The bare-specifier prefix this registry answers to, as in + * `"foo": "work:^1.0.0"`. Singular because the prefix is the registry's + * identity in a lockfile dep path (`foo@work:1.0.0`): a second spelling + * would key the same package from the same registry twice. + */ + prefix?: string +} + +/** + * Everything needed to decide which registry a package came from and what that + * registry does: the scope-routed URLs, the `:`-addressed aliases, and + * the declared per-registry settings. + * + * Mixed into an options type (`RegistryContext & { … }`) rather than spelled + * out field by field, so that a new per-registry setting reaches every + * consumer by being added here. Forward it with `pickRegistryContext` for the + * same reason: dropping a field is silent — the tarball URL is simply rebuilt + * in the wrong layout — so neither end should be written out by hand. + */ +export interface RegistryContext { + registriesByScope: RegistriesByScope + /** As the user wrote it; built-in aliases are merged in at lookup. */ + registriesByPrefix?: Record + registryOptionsByUrl?: Record +} + /** Parsed value of `_auth` of each registry in the rc file. */ export interface BasicAuth { username: string diff --git a/pnpm11/core/types/src/package.ts b/pnpm11/core/types/src/package.ts index 00b758f465..5fd54b7d4a 100644 --- a/pnpm11/core/types/src/package.ts +++ b/pnpm11/core/types/src/package.ts @@ -1,4 +1,4 @@ -import type { Registries } from './misc.js' +import type { RegistriesByScope, RegistryDeclaration } from './misc.js' import type { VersioningSettings } from './versioning.js' export type Dependencies = Record @@ -231,7 +231,20 @@ export interface UpdateSettings { export interface PnpmSettings { npmrcAuthFile?: string - registries?: Registries + /** + * The registries the project declares, keyed by registry URL, so that a + * registry's layout, the scopes routed to it, and the bare-specifier prefix + * it answers to are all stated once, in one place. + * + * A map whose values are plain strings is the older `scope: url` shape and + * is read as one. + */ + registries?: Record | RegistriesByScope + /** + * @deprecated Give the registry a `prefix` in + * {@link PnpmSettings.registries} instead. Kept working until the next major + * version. + */ namedRegistries?: Record configDependencies?: ConfigDependencies allowBuilds?: Record diff --git a/pnpm11/deps/compliance/commands/src/audit/audit.ts b/pnpm11/deps/compliance/commands/src/audit/audit.ts index 031d559cb8..6ce880ed33 100644 --- a/pnpm11/deps/compliance/commands/src/audit/audit.ts +++ b/pnpm11/deps/compliance/commands/src/audit/audit.ts @@ -6,7 +6,7 @@ import { PnpmError } from '@pnpm/error' import { type InstallCommandOptions, update } from '@pnpm/installing.commands' import { globalInfo } from '@pnpm/logger' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { table } from '@zkochan/table' import chalk, { type ChalkInstance } from 'chalk' import { pick, pickBy } from 'ramda' @@ -163,7 +163,7 @@ export type AuditOptions = Pick & { interactive?: boolean json?: boolean lockfileDir?: string - registries: Registries + registriesByScope: RegistriesByScope ignore?: string[] ignoreUnfixable?: boolean } & Pick | undefined> { try { - const registry = pickRegistryForPackage(opts.registries, pkgName) + const registry = pickRegistryForPackage(opts.registriesByScope, pkgName) const packageUrl = new URL(npa(pkgName).escapedName, registry.endsWith('/') ? registry : `${registry}/`).href // Full metadata: the abbreviated packument has no `time` field. const res = await fetchFromRegistry(packageUrl, { diff --git a/pnpm11/deps/compliance/commands/src/audit/signatures.ts b/pnpm11/deps/compliance/commands/src/audit/signatures.ts index 7e14b63e58..f36eecdc7d 100644 --- a/pnpm11/deps/compliance/commands/src/audit/signatures.ts +++ b/pnpm11/deps/compliance/commands/src/audit/signatures.ts @@ -14,7 +14,7 @@ export async function auditSignatures (opts: AuditOptions): Promise<{ exitCode: const { envLockfile, include, lockfile } = await loadAuditContext(opts) const auditRequest = lockfileToAuditRequest(lockfile, { envLockfile, include }) const packages: SignaturePackage[] = Object.entries(auditRequest.request).flatMap(([name, versions]) => ( - versions.map((version) => ({ name, registry: pickRegistryForPackage(opts.registries, name), version })) + versions.map((version) => ({ name, registry: pickRegistryForPackage(opts.registriesByScope, name), version })) )) if (packages.length === 0) { throw new PnpmError('AUDIT_NO_PACKAGES', 'No installed packages found to audit') diff --git a/pnpm11/deps/compliance/commands/src/licenses/licensesList.ts b/pnpm11/deps/compliance/commands/src/licenses/licensesList.ts index 4759b86184..645303e6e7 100644 --- a/pnpm11/deps/compliance/commands/src/licenses/licensesList.ts +++ b/pnpm11/deps/compliance/commands/src/licenses/licensesList.ts @@ -21,8 +21,8 @@ export type LicensesCommandOptions = { | 'dev' | 'dir' | 'lockfileDir' -| 'registries' -| 'namedRegistries' +| 'registriesByScope' +| 'registriesByPrefix' | 'optional' | 'production' | 'storeDir' @@ -75,8 +75,8 @@ export async function licensesList (opts: LicensesCommandOptions): Promise { const { output, exitCode } = await audit.handler({ ...AUDIT_REGISTRY_OPTS, dir: hasSignaturesDir, - registries: { ...AUDIT_REGISTRY_OPTS.registries, '@scope': SCOPED_AUDIT_REGISTRY }, + registriesByScope: { ...AUDIT_REGISTRY_OPTS.registriesByScope, '@scope': SCOPED_AUDIT_REGISTRY }, rootProjectManifestDir: hasSignaturesDir, }, ['signatures']) diff --git a/pnpm11/deps/compliance/commands/test/audit/preserveReferenceOverrides.ts b/pnpm11/deps/compliance/commands/test/audit/preserveReferenceOverrides.ts index 3f45d2c3fe..851ec29b98 100644 --- a/pnpm11/deps/compliance/commands/test/audit/preserveReferenceOverrides.ts +++ b/pnpm11/deps/compliance/commands/test/audit/preserveReferenceOverrides.ts @@ -12,7 +12,7 @@ import * as responses from './utils/responses/index.js' const f = fixtures(import.meta.dirname) -const registries = DEFAULT_OPTS.registries +const registriesByScope = DEFAULT_OPTS.registriesByScope beforeEach(async () => { await setupMockAgent() @@ -25,7 +25,7 @@ afterEach(async () => { test('overrides with references (via $) are preserved during audit --fix', async () => { const tmp = f.prepare('preserve-reference-overrides') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' }) .reply(200, responses.ALL_VULN_RESP) diff --git a/pnpm11/deps/compliance/commands/test/audit/utils/options.ts b/pnpm11/deps/compliance/commands/test/audit/utils/options.ts index dcc8772de2..81cbd0e656 100644 --- a/pnpm11/deps/compliance/commands/test/audit/utils/options.ts +++ b/pnpm11/deps/compliance/commands/test/audit/utils/options.ts @@ -2,7 +2,7 @@ import { DEFAULT_OPTS as BASE_OPTS, REGISTRY_URL } from '@pnpm/testing.command-d export const DEFAULT_OPTS = { ...BASE_OPTS, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, registry: 'https://registry.npmjs.org/', strictSsl: true, peersSuffixMaxLength: 1000, @@ -12,7 +12,7 @@ export const AUDIT_REGISTRY = 'http://audit.registry/' export const AUDIT_REGISTRY_OPTS = { ...DEFAULT_OPTS, registry: AUDIT_REGISTRY, - registries: { default: AUDIT_REGISTRY }, + registriesByScope: { default: AUDIT_REGISTRY }, configByUri: {}, } @@ -20,6 +20,6 @@ export const MOCK_REGISTRY = REGISTRY_URL export const MOCK_REGISTRY_OPTS = { ...DEFAULT_OPTS, registry: MOCK_REGISTRY, - registries: { default: MOCK_REGISTRY }, + registriesByScope: { default: MOCK_REGISTRY }, configByUri: {}, } diff --git a/pnpm11/deps/compliance/commands/test/licenses/utils/index.ts b/pnpm11/deps/compliance/commands/test/licenses/utils/index.ts index 21609dfe32..c096cb1e81 100644 --- a/pnpm11/deps/compliance/commands/test/licenses/utils/index.ts +++ b/pnpm11/deps/compliance/commands/test/licenses/utils/index.ts @@ -2,6 +2,6 @@ import { DEFAULT_OPTS as BASE_OPTS } from '@pnpm/testing.command-defaults' export const DEFAULT_OPTS = { ...BASE_OPTS, - registries: { default: 'https://registry.npmjs.org' }, + registriesByScope: { default: 'https://registry.npmjs.org' }, virtualStoreDir: 'node_modules/.pnpm', } diff --git a/pnpm11/deps/compliance/commands/test/sbom/index.ts b/pnpm11/deps/compliance/commands/test/sbom/index.ts index 2f95cdf8e6..fd0d06867f 100644 --- a/pnpm11/deps/compliance/commands/test/sbom/index.ts +++ b/pnpm11/deps/compliance/commands/test/sbom/index.ts @@ -1003,7 +1003,7 @@ test('pnpm sbom excludes platform-incompatible optional packages instead of emit const storeDir = path.join(workspaceDir, 'store') await install.handler({ ...DEFAULT_OPTS, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, dir: workspaceDir, pnpmHomeDir: '', storeDir, @@ -1012,7 +1012,7 @@ test('pnpm sbom excludes platform-incompatible optional packages instead of emit const { output, exitCode } = await sbom.handler({ ...DEFAULT_OPTS, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, dir: workspaceDir, lockfileDir: workspaceDir, pnpmHomeDir: '', diff --git a/pnpm11/deps/compliance/commands/test/sbom/utils/index.ts b/pnpm11/deps/compliance/commands/test/sbom/utils/index.ts index 21609dfe32..c096cb1e81 100644 --- a/pnpm11/deps/compliance/commands/test/sbom/utils/index.ts +++ b/pnpm11/deps/compliance/commands/test/sbom/utils/index.ts @@ -2,6 +2,6 @@ import { DEFAULT_OPTS as BASE_OPTS } from '@pnpm/testing.command-defaults' export const DEFAULT_OPTS = { ...BASE_OPTS, - registries: { default: 'https://registry.npmjs.org' }, + registriesByScope: { default: 'https://registry.npmjs.org' }, virtualStoreDir: 'node_modules/.pnpm', } diff --git a/pnpm11/deps/compliance/license-scanner/src/getPkgInfo.ts b/pnpm11/deps/compliance/license-scanner/src/getPkgInfo.ts index 163e90c430..bb38082e35 100644 --- a/pnpm11/deps/compliance/license-scanner/src/getPkgInfo.ts +++ b/pnpm11/deps/compliance/license-scanner/src/getPkgInfo.ts @@ -7,7 +7,7 @@ import { type PackageSnapshot, pkgSnapshotToResolution } from '@pnpm/lockfile.ut import { readPackageJson } from '@pnpm/pkg-manifest.reader' import type { StoreIndex } from '@pnpm/store.index' import { readPackageFileMap } from '@pnpm/store.pkg-finder' -import type { PackageManifest, Registries } from '@pnpm/types' +import type { PackageManifest, RegistriesByScope } from '@pnpm/types' import pLimit from 'p-limit' import { pathAbsolute } from 'path-absolute' @@ -25,8 +25,8 @@ export interface PackageInfo { version?: string depPath: string snapshot: PackageSnapshot - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record } export interface GetPackageInfoOptions { @@ -56,7 +56,7 @@ export async function getPkgInfo ( const packageResolution = pkgSnapshotToResolution( pkg.depPath, pkg.snapshot, - { registries: pkg.registries, namedRegistries: pkg.namedRegistries } + { registriesByScope: pkg.registriesByScope, registriesByPrefix: pkg.registriesByPrefix } ) let files: Map diff --git a/pnpm11/deps/compliance/license-scanner/src/licenses.ts b/pnpm11/deps/compliance/license-scanner/src/licenses.ts index 3a14f9f86d..2c108a096e 100644 --- a/pnpm11/deps/compliance/license-scanner/src/licenses.ts +++ b/pnpm11/deps/compliance/license-scanner/src/licenses.ts @@ -6,7 +6,7 @@ import type { IncludedDependencies, ProjectId, ProjectManifest, - Registries, + RegistriesByScope, SupportedArchitectures, } from '@pnpm/types' import semver from 'semver' @@ -81,8 +81,8 @@ export async function findDependencyLicenses (opts: { virtualStoreDir: string virtualStoreDirMaxLength: number modulesDir?: string - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record wantedLockfile: LockfileObject | null includedImporterIds?: ProjectId[] supportedArchitectures?: SupportedArchitectures @@ -102,8 +102,8 @@ export async function findDependencyLicenses (opts: { virtualStoreDir: opts.virtualStoreDir, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, include: opts.include, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + registriesByScope: opts.registriesByScope, + registriesByPrefix: opts.registriesByPrefix, includedImporterIds: opts.includedImporterIds, supportedArchitectures: opts.supportedArchitectures, depTypes, diff --git a/pnpm11/deps/compliance/license-scanner/src/lockfileToLicenseNodeTree.ts b/pnpm11/deps/compliance/license-scanner/src/lockfileToLicenseNodeTree.ts index 22505428dd..6ad460aacd 100644 --- a/pnpm11/deps/compliance/license-scanner/src/lockfileToLicenseNodeTree.ts +++ b/pnpm11/deps/compliance/license-scanner/src/lockfileToLicenseNodeTree.ts @@ -7,7 +7,7 @@ import { type LockfileWalkerStep, } from '@pnpm/lockfile.walker' import { StoreIndex } from '@pnpm/store.index' -import type { DependenciesField, ProjectId, Registries, SupportedArchitectures } from '@pnpm/types' +import type { DependenciesField, ProjectId, RegistriesByScope, SupportedArchitectures } from '@pnpm/types' import { getPkgInfo } from './getPkgInfo.js' @@ -41,8 +41,8 @@ export interface LicenseExtractOptions { virtualStoreDirMaxLength: number modulesDir?: string dir: string - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record supportedArchitectures?: SupportedArchitectures depTypes: DepTypes } @@ -81,8 +81,8 @@ export async function lockfileToLicenseNode ( version, depPath, snapshot: pkgSnapshot, - registries: options.registries, - namedRegistries: options.namedRegistries, + registriesByScope: options.registriesByScope, + registriesByPrefix: options.registriesByPrefix, }, { storeDir: options.storeDir, @@ -155,8 +155,8 @@ export async function lockfileToLicenseNodeTree ( virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, modulesDir: opts.modulesDir, dir: opts.dir, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + registriesByScope: opts.registriesByScope, + registriesByPrefix: opts.registriesByPrefix, supportedArchitectures: opts.supportedArchitectures, depTypes, }) diff --git a/pnpm11/deps/compliance/license-scanner/test/getPkgInfo.spec.ts b/pnpm11/deps/compliance/license-scanner/test/getPkgInfo.spec.ts index f06fbffa4d..9ad26d23db 100644 --- a/pnpm11/deps/compliance/license-scanner/test/getPkgInfo.spec.ts +++ b/pnpm11/deps/compliance/license-scanner/test/getPkgInfo.spec.ts @@ -8,7 +8,7 @@ import { gitHostedStoreIndexKey, StoreIndex, storeIndexKey } from '@pnpm/store.i import { getPkgInfo } from '../lib/getPkgInfo.js' -export const DEFAULT_REGISTRIES = { +export const DEFAULT_REGISTRIES_BY_SCOPE = { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', } @@ -55,7 +55,7 @@ describe('getPkgInfo', () => { integrity: 'integrity-sha', }, }, - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, }, defaultGetOpts() ) @@ -78,7 +78,7 @@ describe('getPkgInfo', () => { commit: '2fca6157', }, }, - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, }, defaultGetOpts() ) @@ -116,7 +116,7 @@ describe('getPkgInfo', () => { snapshot: { resolution: { integrity }, }, - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, }, defaultGetOpts() ) @@ -163,7 +163,7 @@ describe('getPkgInfo', () => { commit: '2fca6157fcca165438e0f9495cf0e5a4e6f71349', }, }, - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, }, defaultGetOpts() ) diff --git a/pnpm11/deps/compliance/license-scanner/test/licenses.spec.ts b/pnpm11/deps/compliance/license-scanner/test/licenses.spec.ts index 9319ee803d..5af15d085f 100644 --- a/pnpm11/deps/compliance/license-scanner/test/licenses.spec.ts +++ b/pnpm11/deps/compliance/license-scanner/test/licenses.spec.ts @@ -3,10 +3,10 @@ import os from 'node:os' import path from 'node:path' import { afterAll, describe, expect, jest, test } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { LOCKFILE_VERSION } from '@pnpm/constants' import type { LockfileObject } from '@pnpm/lockfile.fs' -import type { DepPath, ProjectId, ProjectManifest, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, ProjectManifest, RegistriesByScope } from '@pnpm/types' import type { GetPackageInfoOptions, PackageInfo } from '../lib/getPkgInfo.js' import type { LicensePackage } from '../lib/licenses.js' @@ -81,7 +81,7 @@ describe('licences', () => { lockfileDir: '/opt/pnpm', manifest: {} as ProjectManifest, virtualStoreDir: '/.pnpm', - registries: {} as Registries, + registriesByScope: {} as RegistriesByScope, wantedLockfile: lockfile, storeDir: tmpStoreDir, virtualStoreDirMaxLength: 120, @@ -167,7 +167,7 @@ describe('licences', () => { lockfileDir: '/opt/pnpm', manifest: {} as ProjectManifest, virtualStoreDir: '/.pnpm', - registries: {} as Registries, + registriesByScope: {} as RegistriesByScope, wantedLockfile: lockfile, storeDir: tmpStoreDir, includedImporterIds: ['packages/a'] as ProjectId[], @@ -244,7 +244,7 @@ describe('licences', () => { lockfileDir: '/opt/pnpm', manifest: {} as ProjectManifest, virtualStoreDir: '/.pnpm', - registries: {} as Registries, + registriesByScope: {} as RegistriesByScope, wantedLockfile: lockfile, storeDir: tmpStoreDir, virtualStoreDirMaxLength: 120, @@ -347,7 +347,7 @@ describe('licences', () => { lockfileDir: '/opt/pnpm', manifest: {} as ProjectManifest, virtualStoreDir: '/.pnpm', - registries: {} as Registries, + registriesByScope: {} as RegistriesByScope, wantedLockfile: lockfile, storeDir: tmpStoreDir, virtualStoreDirMaxLength: 120, @@ -398,8 +398,8 @@ describe('licences', () => { lockfileDir: '/opt/pnpm', manifest: {} as ProjectManifest, virtualStoreDir: '/.pnpm', - registries: {} as Registries, - namedRegistries: normalizeNamedRegistries({ work: 'https://npm.enterprise.example.com/' }), + registriesByScope: {} as RegistriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: 'https://npm.enterprise.example.com/' }), wantedLockfile: lockfile, storeDir: tmpStoreDir, virtualStoreDirMaxLength: 120, diff --git a/pnpm11/deps/compliance/sbom/src/collectComponents.ts b/pnpm11/deps/compliance/sbom/src/collectComponents.ts index a4265d1c7c..710944b708 100644 --- a/pnpm11/deps/compliance/sbom/src/collectComponents.ts +++ b/pnpm11/deps/compliance/sbom/src/collectComponents.ts @@ -1,6 +1,6 @@ import path from 'node:path' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { checkPackageInstallability } from '@pnpm/config.package-is-installable' import { PnpmError } from '@pnpm/error' import { DepType, type DepTypes, detectDepTypes } from '@pnpm/lockfile.detect-dep-types' @@ -12,7 +12,7 @@ import { } from '@pnpm/lockfile.walker' import type { Resolution } from '@pnpm/resolving.resolver-base' import { StoreIndex } from '@pnpm/store.index' -import type { DependenciesField, ProjectId, Registries, SupportedArchitectures } from '@pnpm/types' +import type { DependenciesField, ProjectId, RegistriesByScope, SupportedArchitectures } from '@pnpm/types' import pLimit from 'p-limit' import { getPkgMetadata, type GetPkgMetadataOptions } from './getPkgMetadata.js' @@ -39,8 +39,8 @@ export interface CollectSbomComponentsOptions { rootBugsUrl?: string sbomType?: SbomComponentType include?: { [dependenciesField in DependenciesField]: boolean } - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record lockfileDir: string includedImporterIds?: ProjectId[] supportedArchitectures?: SupportedArchitectures @@ -235,11 +235,11 @@ async function walkStep ( // artifact from a compliance document. const registryUrl = registryName == null ? undefined - : normalizeNamedRegistries(opts.namedRegistries)[registryName] + : normalizeRegistriesByPrefix(opts.registriesByPrefix)[registryName] if (registryName != null && registryUrl == null) { throw new PnpmError('MISSING_NAMED_REGISTRY', - `Cannot describe package "${depPath}": it was resolved from the named registry '${registryName}:', which is not present in the namedRegistries setting.`, - { hint: `Add '${registryName}' to the namedRegistries setting in pnpm-workspace.yaml.` }) + `Cannot describe package "${depPath}": it was resolved from the named registry '${registryName}:', which is not present in the registriesByPrefix setting.`, + { hint: `Add '${registryName}' to the registriesByPrefix setting in pnpm-workspace.yaml.` }) } const purl = buildPurl({ @@ -254,12 +254,12 @@ async function walkStep ( if (componentsMap.has(purl)) return const integrity = verifiedIntegrity(pkgSnapshot.resolution) - const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, { registries: opts.registries, namedRegistries: opts.namedRegistries }) + const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, { registriesByScope: opts.registriesByScope, registriesByPrefix: opts.registriesByPrefix }) const tarballUrl = (resolution as TarballResolution).tarball ?? gitDownloadUrl(resolution) let metadata: { license?: string, description?: string, author?: string, homepage?: string, repository?: string, bugsUrl?: string } = {} if (metadataOpts) { - metadata = await getPkgMetadata(depPath, pkgSnapshot, { registries: opts.registries, namedRegistries: opts.namedRegistries }, metadataOpts) + metadata = await getPkgMetadata(depPath, pkgSnapshot, { registriesByScope: opts.registriesByScope, registriesByPrefix: opts.registriesByPrefix }, metadataOpts) } const component: SbomComponent = { diff --git a/pnpm11/deps/compliance/sbom/src/purl.ts b/pnpm11/deps/compliance/sbom/src/purl.ts index 6b7053d89a..d51d7bee83 100644 --- a/pnpm11/deps/compliance/sbom/src/purl.ts +++ b/pnpm11/deps/compliance/sbom/src/purl.ts @@ -41,7 +41,7 @@ export function buildPurl (opts: { * Reduce a registry URL to the parts that identify the registry, dropping * anything that could carry a secret. * - * An SBOM is meant to be published, and a `namedRegistries` entry may + * An SBOM is meant to be published, and a `registriesByPrefix` entry may * legitimately embed credentials — as userinfo * (`https://user:token@npm.example.com/`) or in a query string * (`?api_key=…`). Origin and path are kept because two registries can @@ -50,7 +50,7 @@ export function buildPurl (opts: { * qualifier exists to prevent. * * A URL that doesn't parse is returned unchanged: it has no userinfo to - * strip, and `namedRegistries` values are validated as http(s) URLs when + * strip, and `registriesByPrefix` values are validated as http(s) URLs when * the resolver is constructed, so this is unreachable in practice. */ function sanitizeRegistryUrl (url: string): string { diff --git a/pnpm11/deps/compliance/sbom/test/collectComponents.test.ts b/pnpm11/deps/compliance/sbom/test/collectComponents.test.ts index 8ea91a8a2b..5908137a7e 100644 --- a/pnpm11/deps/compliance/sbom/test/collectComponents.test.ts +++ b/pnpm11/deps/compliance/sbom/test/collectComponents.test.ts @@ -3,14 +3,14 @@ import os from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { collectSbomComponents } from '@pnpm/deps.compliance.sbom' import type { LockfileObject } from '@pnpm/lockfile.types' import type { PackageFilesIndex } from '@pnpm/store.cafs' import { StoreIndex, storeIndexKey } from '@pnpm/store.index' -import type { DepPath, ProjectId, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, RegistriesByScope } from '@pnpm/types' -const registries: Registries = { default: 'https://registry.npmjs.org/' } +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/' } /** * A project depending on `foo@1.0.0` from the default registry and on the @@ -38,13 +38,13 @@ function lockfileWithBothRegistries (alias: string): LockfileObject { } as unknown as LockfileObject } -function collect (alias: string, namedRegistries?: Record) { +function collect (alias: string, registriesByPrefix?: Record) { return collectSbomComponents({ lockfile: lockfileWithBothRegistries(alias), rootName: 'root', rootVersion: '1.0.0', - registries, - namedRegistries: normalizeNamedRegistries(namedRegistries), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix(registriesByPrefix), lockfileDir: '/test', lockfileOnly: true, }) @@ -104,8 +104,8 @@ describe('collectSbomComponents integrity', () => { lockfile, rootName: 'root', rootVersion: '1.0.0', - registries, - namedRegistries: normalizeNamedRegistries(undefined), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix(undefined), lockfileDir: '/test', lockfileOnly: true, }) @@ -146,8 +146,8 @@ describe('collectSbomComponents integrity', () => { lockfile, rootName: 'root', rootVersion: '1.0.0', - registries, - namedRegistries: normalizeNamedRegistries(undefined), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix(undefined), lockfileDir: '/test', lockfileOnly: true, }) @@ -185,8 +185,8 @@ describe('verifiedIntegrity robustness', () => { lockfile, rootName: 'root', rootVersion: '1.0.0', - registries, - namedRegistries: normalizeNamedRegistries(undefined), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix(undefined), lockfileDir: '/test', lockfileOnly: true, }) @@ -273,7 +273,7 @@ describe('collectSbomComponents with platform-incompatible packages', () => { lockfile: lockfileWithIncompatiblePackages(), rootName: 'root', rootVersion: '1.0.0', - registries, + registriesByScope, lockfileDir: '/tmp/project', storeDir, supportedArchitectures, @@ -304,7 +304,7 @@ describe('collectSbomComponents with platform-incompatible packages', () => { lockfile: lockfileWithIncompatiblePackages(), rootName: 'root', rootVersion: '1.0.0', - registries, + registriesByScope, lockfileDir: '/tmp/project', storeDir, supportedArchitectures, diff --git a/pnpm11/deps/compliance/sbom/test/getPkgMetadata.test.ts b/pnpm11/deps/compliance/sbom/test/getPkgMetadata.test.ts index d36efd027a..6c0b6ae0e2 100644 --- a/pnpm11/deps/compliance/sbom/test/getPkgMetadata.test.ts +++ b/pnpm11/deps/compliance/sbom/test/getPkgMetadata.test.ts @@ -10,7 +10,7 @@ import type { DepPath } from '@pnpm/types' import { bugsUrlFromField, getPkgMetadata } from '../lib/getPkgMetadata.js' const DEFAULT_REGISTRY_OPTS = { - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', }, diff --git a/pnpm11/deps/graph-builder/package.json b/pnpm11/deps/graph-builder/package.json index 2bf52b7f7e..2c23dbca43 100644 --- a/pnpm11/deps/graph-builder/package.json +++ b/pnpm11/deps/graph-builder/package.json @@ -31,6 +31,7 @@ ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest" }, "dependencies": { + "@pnpm/config.normalize-registries": "workspace:*", "@pnpm/config.package-is-installable": "workspace:*", "@pnpm/constants": "workspace:*", "@pnpm/core-loggers": "workspace:*", diff --git a/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts b/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts index 0344d01037..547ef2f512 100644 --- a/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts +++ b/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' +import { pickRegistryContext } from '@pnpm/config.normalize-registries' import { packageIsInstallable } from '@pnpm/config.package-is-installable' import { WANTED_LOCKFILE } from '@pnpm/constants' import { @@ -22,14 +23,7 @@ import type { PkgRequestFetchResult, StoreController, } from '@pnpm/store.controller-types' -import type { - AllowBuild, - DepPath, - PkgIdWithPatchHash, - ProjectId, - Registries, - SupportedArchitectures, -} from '@pnpm/types' +import type { AllowBuild, DepPath, PkgIdWithPatchHash, ProjectId, RegistriesByScope, RegistryContext, SupportedArchitectures } from '@pnpm/types' import { pathExists } from 'path-exists' import { equals, isEmpty } from 'ramda' @@ -63,7 +57,7 @@ export interface DependenciesGraph { [depPath: string]: DependenciesGraphNode } -export interface LockfileToDepGraphOptions { +export interface LockfileToDepGraphOptions extends RegistryContext { allowBuild?: AllowBuild autoInstallPeers: boolean enableGlobalVirtualStore?: boolean @@ -83,8 +77,6 @@ export interface LockfileToDepGraphOptions { nodeVersion: string pnpmVersion: string patchedDependencies?: PatchGroupRecord - registries: Registries - namedRegistries?: Record /** * The dep paths a non-optional edge reaches, as classified by * `filterLockfileByImportersAndEngine`. Installability is evaluated as @@ -143,7 +135,7 @@ export async function lockfileToDepGraph ( force: opts.force, graph, lockfileDir: opts.lockfileDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, sideEffectsCacheRead: opts.sideEffectsCacheRead, skipped: opts.skipped, storeController: opts.storeController, @@ -295,7 +287,7 @@ async function buildGraphFromPackages ( } if (!fetchResponse) { - const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, { registries: opts.registries, namedRegistries: opts.namedRegistries }) + const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, pickRegistryContext(opts)) progressLogger.debug({ packageId, requester: opts.lockfileDir, status: 'resolved' }) try { @@ -340,7 +332,7 @@ async function buildGraphFromPackages ( interface GetChildrenPathsContext { graph: DependenciesGraph force: boolean - registries: Registries + registriesByScope: RegistriesByScope virtualStoreDir: string storeDir: string skipped: Set diff --git a/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts b/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts index f51f70e396..d3998931b1 100644 --- a/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts +++ b/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts @@ -49,7 +49,7 @@ function graphOpts (lockfileDir: string): LockfileToDepGraphOptions { lockfileDir, nodeVersion: process.version, pnpmVersion: '0.0.0', - registries: { default: 'http://localhost/' }, + registriesByScope: { default: 'http://localhost/' }, requiredDepPaths: new Set(), sideEffectsCacheRead: false, skipped: new Set(), diff --git a/pnpm11/deps/graph-builder/tsconfig.json b/pnpm11/deps/graph-builder/tsconfig.json index dc33b6018c..fe4d0b5c0b 100644 --- a/pnpm11/deps/graph-builder/tsconfig.json +++ b/pnpm11/deps/graph-builder/tsconfig.json @@ -9,6 +9,9 @@ "../../__typings__/**/*.d.ts" ], "references": [ + { + "path": "../../config/normalize-registries" + }, { "path": "../../config/package-is-installable" }, diff --git a/pnpm11/deps/inspection/commands/src/fetchPackageInfo.ts b/pnpm11/deps/inspection/commands/src/fetchPackageInfo.ts index ed3fa9af8f..a8c7abf37b 100644 --- a/pnpm11/deps/inspection/commands/src/fetchPackageInfo.ts +++ b/pnpm11/deps/inspection/commands/src/fetchPackageInfo.ts @@ -50,7 +50,7 @@ export async function fetchPackageInfo ( fetchSpec: subSpec?.fetchSpec ?? 'latest', type: specType, } - const registry = pickRegistryForPackage(opts.registries, packageName) + const registry = pickRegistryForPackage(opts.registriesByScope, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}) const fetchResult = await fetchMetadataFromFromRegistry( diff --git a/pnpm11/deps/inspection/commands/src/outdated/outdated.ts b/pnpm11/deps/inspection/commands/src/outdated/outdated.ts index e87aaf4a8c..52841fe7a2 100644 --- a/pnpm11/deps/inspection/commands/src/outdated/outdated.ts +++ b/pnpm11/deps/inspection/commands/src/outdated/outdated.ts @@ -173,7 +173,7 @@ export type OutdatedCommandOptions = { | 'optional' | 'production' | 'configByUri' -| 'registries' +| 'registriesByScope' | 'strictSsl' | 'tag' | 'userAgent' diff --git a/pnpm11/deps/inspection/commands/test/bugs.ts b/pnpm11/deps/inspection/commands/test/bugs.ts index 2e46d8f440..40d1c48161 100644 --- a/pnpm11/deps/inspection/commands/test/bugs.ts +++ b/pnpm11/deps/inspection/commands/test/bugs.ts @@ -16,7 +16,7 @@ const { bugs } = await import('@pnpm/deps.inspection.commands') const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}` const BASE_OPTIONS = { - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, } as unknown as Config & ConfigContext & { dir: string } test('bugs: command should be available', () => { diff --git a/pnpm11/deps/inspection/commands/test/docs.ts b/pnpm11/deps/inspection/commands/test/docs.ts index f1b22f1430..089ba2aec7 100644 --- a/pnpm11/deps/inspection/commands/test/docs.ts +++ b/pnpm11/deps/inspection/commands/test/docs.ts @@ -12,7 +12,7 @@ const { docs } = await import('@pnpm/deps.inspection.commands') const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}` const DOCS_OPTIONS = { - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, } test('docs: command should be available', () => { diff --git a/pnpm11/deps/inspection/commands/test/outdated/index.ts b/pnpm11/deps/inspection/commands/test/outdated/index.ts index 2369fcd2b0..aae22d03d8 100644 --- a/pnpm11/deps/inspection/commands/test/outdated/index.ts +++ b/pnpm11/deps/inspection/commands/test/outdated/index.ts @@ -36,7 +36,7 @@ const OUTDATED_OPTIONS = { networkConcurrency: 16, offline: false, configByUri: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, strictSsl: false, tag: 'latest', userAgent: '', @@ -85,7 +85,7 @@ test('pnpm outdated: show details (using the public registry to verify that full dir: process.cwd(), long: true, configByUri: {}, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) expect(exitCode).toBe(1) diff --git a/pnpm11/deps/inspection/commands/test/outdated/minimumReleaseAge.test.ts b/pnpm11/deps/inspection/commands/test/outdated/minimumReleaseAge.test.ts index 9e3782e2ba..f6908aba2c 100644 --- a/pnpm11/deps/inspection/commands/test/outdated/minimumReleaseAge.test.ts +++ b/pnpm11/deps/inspection/commands/test/outdated/minimumReleaseAge.test.ts @@ -24,7 +24,7 @@ const OUTDATED_OPTIONS = { networkConcurrency: 16, offline: false, configByUri: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, strictSsl: false, tag: 'latest', userAgent: '', diff --git a/pnpm11/deps/inspection/commands/test/repo.ts b/pnpm11/deps/inspection/commands/test/repo.ts index 75b3cf0ca3..cb73b67152 100644 --- a/pnpm11/deps/inspection/commands/test/repo.ts +++ b/pnpm11/deps/inspection/commands/test/repo.ts @@ -16,7 +16,7 @@ const { repo } = await import('@pnpm/deps.inspection.commands') const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}` const BASE_OPTIONS = { - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, } as unknown as Config & ConfigContext & { dir: string } test('repo: command should be available', () => { diff --git a/pnpm11/deps/inspection/commands/test/view.ts b/pnpm11/deps/inspection/commands/test/view.ts index 89a6464033..8d15b4d725 100644 --- a/pnpm11/deps/inspection/commands/test/view.ts +++ b/pnpm11/deps/inspection/commands/test/view.ts @@ -11,7 +11,7 @@ import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock' const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}` const VIEW_OPTIONS = { - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, } test('view: command should be available', () => { diff --git a/pnpm11/deps/inspection/list/src/index.ts b/pnpm11/deps/inspection/list/src/index.ts index 6ae2dce5b9..89b9dbf79b 100644 --- a/pnpm11/deps/inspection/list/src/index.ts +++ b/pnpm11/deps/inspection/list/src/index.ts @@ -2,7 +2,7 @@ import path from 'node:path' import { buildDependenciesTree, buildDependentsTree, createPackagesSearcher, type DependenciesTree, type DependencyNode, type ImporterInfo } from '@pnpm/deps.inspection.tree-builder' import { readCurrentLockfile, readWantedLockfile } from '@pnpm/lockfile.fs' -import type { DependenciesField, Finder, Registries } from '@pnpm/types' +import type { DependenciesField, Finder, RegistriesByScope } from '@pnpm/types' import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader' import { renderDependentsJson, renderDependentsParseable, renderDependentsTree } from './renderDependentsTree.js' @@ -26,7 +26,7 @@ const DEFAULTS = { alwaysPrintRootPackage: true, depth: 0, long: false, - registries: undefined, + registriesByScope: undefined, reportAs: 'tree' as const, showExtraneous: true, } @@ -75,8 +75,8 @@ export async function searchForPackages ( checkWantedLockfileOnly?: boolean include?: { [dependenciesField in DependenciesField]: boolean } onlyProjects?: boolean - registries?: Registries - namedRegistries?: Record + registriesByScope?: RegistriesByScope + registriesByPrefix?: Record modulesDir?: string virtualStoreDirMaxLength: number finders?: Finder[] @@ -92,8 +92,8 @@ export async function searchForPackages ( lockfileDir: opts.lockfileDir, checkWantedLockfileOnly: opts.checkWantedLockfileOnly, onlyProjects: opts.onlyProjects, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + registriesByScope: opts.registriesByScope, + registriesByPrefix: opts.registriesByPrefix, search, showDedupedSearchMatches: true, modulesDir: opts.modulesDir, @@ -125,8 +125,8 @@ export async function listForPackages ( include?: { [dependenciesField in DependenciesField]: boolean } onlyProjects?: boolean reportAs?: 'parseable' | 'tree' | 'json' - registries?: Registries - namedRegistries?: Record + registriesByScope?: RegistriesByScope + registriesByPrefix?: Record modulesDir?: string virtualStoreDirMaxLength: number finders?: Finder[] @@ -160,8 +160,8 @@ export async function list ( include?: { [dependenciesField in DependenciesField]: boolean } onlyProjects?: boolean reportAs?: 'parseable' | 'tree' | 'json' - registries?: Registries - namedRegistries?: Record + registriesByScope?: RegistriesByScope + registriesByPrefix?: Record showExtraneous?: boolean modulesDir?: string virtualStoreDirMaxLength: number @@ -185,8 +185,8 @@ export async function list ( lockfileDir: maybeOpts?.lockfileDir, checkWantedLockfileOnly: maybeOpts?.checkWantedLockfileOnly, onlyProjects: maybeOpts?.onlyProjects, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + registriesByScope: opts.registriesByScope, + registriesByPrefix: opts.registriesByPrefix, modulesDir: opts.modulesDir, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, }) @@ -241,8 +241,8 @@ export async function whyForPackages ( checkWantedLockfileOnly?: boolean include?: { [dependenciesField in DependenciesField]: boolean } long?: boolean - registries?: Registries - namedRegistries?: Record + registriesByScope?: RegistriesByScope + registriesByPrefix?: Record reportAs?: 'parseable' | 'tree' | 'json' modulesDir?: string finders?: Finder[] @@ -277,8 +277,8 @@ export async function whyForPackages ( lockfileDir: opts.lockfileDir, include: opts.include, modulesDir: opts.modulesDir, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + registriesByScope: opts.registriesByScope, + registriesByPrefix: opts.registriesByPrefix, finders: opts.finders, importerInfoMap, lockfile, diff --git a/pnpm11/deps/inspection/tree-builder/src/buildDependenciesTree.ts b/pnpm11/deps/inspection/tree-builder/src/buildDependenciesTree.ts index a02a45a669..c10b0c2c9a 100644 --- a/pnpm11/deps/inspection/tree-builder/src/buildDependenciesTree.ts +++ b/pnpm11/deps/inspection/tree-builder/src/buildDependenciesTree.ts @@ -1,6 +1,6 @@ import path from 'node:path' -import { normalizeRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByScope } from '@pnpm/config.normalize-registries' import { readModulesDir } from '@pnpm/fs.read-modules-dir' import { readModulesManifest } from '@pnpm/installing.modules-yaml' import { detectDepTypes } from '@pnpm/lockfile.detect-dep-types' @@ -14,7 +14,7 @@ import { } from '@pnpm/lockfile.fs' import { safeReadPackageJsonFromDir } from '@pnpm/pkg-manifest.reader' import { StoreIndex } from '@pnpm/store.index' -import { DEPENDENCIES_FIELDS, type DependenciesField, type Finder, type Registries } from '@pnpm/types' +import { DEPENDENCIES_FIELDS, type DependenciesField, type Finder, type RegistriesByScope } from '@pnpm/types' import normalizePath from 'normalize-path' import pLimit from 'p-limit' import { pathAbsolute } from 'path-absolute' @@ -41,8 +41,8 @@ export async function buildDependenciesTree ( depth: number excludePeerDependencies?: boolean include?: { [dependenciesField in DependenciesField]: boolean } - registries?: Registries - namedRegistries?: Record + registriesByScope?: RegistriesByScope + registriesByPrefix?: Record onlyProjects?: boolean search?: Finder showDedupedSearchMatches?: boolean @@ -57,10 +57,7 @@ export async function buildDependenciesTree ( } const modulesDir = await realpathMissing(pathAbsolute(maybeOpts.modulesDir ?? 'node_modules', maybeOpts.lockfileDir)) const modules = await readModulesManifest(modulesDir) - const registries = normalizeRegistries({ - ...maybeOpts?.registries, - ...modules?.registries, - }) + const registriesByScope = normalizeRegistriesByScope(maybeOpts?.registriesByScope) const internalPnpmDir = path.join(modulesDir, '.pnpm') const currentLockfile = await readCurrentLockfile(internalPnpmDir, { ignoreIncompatible: false }) const wantedLockfile = await readWantedLockfile(maybeOpts.lockfileDir, { ignoreIncompatible: false }) @@ -93,8 +90,8 @@ export async function buildDependenciesTree ( lockfileDir: maybeOpts.lockfileDir, checkWantedLockfileOnly: maybeOpts.checkWantedLockfileOnly, onlyProjects: maybeOpts.onlyProjects, - registries, - namedRegistries: maybeOpts.namedRegistries, + registriesByScope, + registriesByPrefix: maybeOpts.registriesByPrefix, search: maybeOpts.search, showDedupedSearchMatches: maybeOpts.showDedupedSearchMatches ?? (maybeOpts.search != null), skipped: new Set(modules?.skipped ?? []), diff --git a/pnpm11/deps/inspection/tree-builder/src/buildDependentsTree.ts b/pnpm11/deps/inspection/tree-builder/src/buildDependentsTree.ts index f916c7c064..e01cfdf684 100644 --- a/pnpm11/deps/inspection/tree-builder/src/buildDependentsTree.ts +++ b/pnpm11/deps/inspection/tree-builder/src/buildDependentsTree.ts @@ -1,6 +1,6 @@ import path from 'node:path' -import { normalizeRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByScope } from '@pnpm/config.normalize-registries' import { readModulesManifest } from '@pnpm/installing.modules-yaml' import { getLockfileImporterId, @@ -11,7 +11,7 @@ import { import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils' import { StoreIndex } from '@pnpm/store.index' import { lexCompare } from '@pnpm/text.ordinal-comparator' -import type { DependenciesField, DependencyManifest, Finder, Registries } from '@pnpm/types' +import type { DependenciesField, DependencyManifest, Finder, RegistriesByScope } from '@pnpm/types' import { realpathMissing } from 'realpath-missing' import semver from 'semver' @@ -79,8 +79,8 @@ export async function buildDependentsTree ( lockfileDir: string include?: { [field in DependenciesField]?: boolean } modulesDir?: string - registries?: Registries - namedRegistries?: Record + registriesByScope?: RegistriesByScope + registriesByPrefix?: Record finders?: Finder[] importerInfoMap: Map lockfile: LockfileObject @@ -89,10 +89,7 @@ export async function buildDependentsTree ( ): Promise { const modulesDir = await realpathMissing(path.join(opts.lockfileDir, opts.modulesDir ?? 'node_modules')) const modules = await readModulesManifest(modulesDir) - const registries = normalizeRegistries({ - ...opts.registries, - ...modules?.registries, - }) + const registriesByScope = normalizeRegistriesByScope(opts.registriesByScope) const storeDir = modules?.storeDir const storeIndex = storeDir ? new StoreIndex(storeDir) : undefined const virtualStoreDir = modules?.virtualStoreDir ?? path.join(modulesDir, '.pnpm') @@ -131,8 +128,8 @@ export async function buildDependentsTree ( virtualStoreDir, virtualStoreDirMaxLength, modulesDir, - registries, - namedRegistries: opts.namedRegistries, + registriesByScope, + registriesByPrefix: opts.registriesByPrefix, wantedPackages: currentPackages, storeDir, storeIndex, @@ -253,8 +250,8 @@ function resolvePackageNodes ( virtualStoreDir: string virtualStoreDirMaxLength: number modulesDir: string - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record wantedPackages: PackageSnapshots storeDir?: string storeIndex?: StoreIndex diff --git a/pnpm11/deps/inspection/tree-builder/src/getPkgInfo.ts b/pnpm11/deps/inspection/tree-builder/src/getPkgInfo.ts index b152bd0d8b..578307e1ed 100644 --- a/pnpm11/deps/inspection/tree-builder/src/getPkgInfo.ts +++ b/pnpm11/deps/inspection/tree-builder/src/getPkgInfo.ts @@ -13,7 +13,7 @@ import { } from '@pnpm/lockfile.utils' import { readPackageJsonFromDirSync } from '@pnpm/pkg-manifest.reader' import type { StoreIndex } from '@pnpm/store.index' -import type { DependencyManifest, Registries } from '@pnpm/types' +import type { DependencyManifest, RegistriesByScope } from '@pnpm/types' import normalizePath from 'normalize-path' import { readManifestFromCafs } from './readManifestFromCafs.js' @@ -24,8 +24,8 @@ export interface GetPkgInfoOpts { readonly ref: string readonly currentPackages: PackageSnapshots readonly peers?: Set - readonly registries: Registries - readonly namedRegistries?: Record + readonly registriesByScope: RegistriesByScope + readonly registriesByPrefix?: Record readonly skipped: Set readonly storeDir?: string readonly storeIndex?: StoreIndex @@ -94,9 +94,9 @@ export function getPkgInfo (opts: GetPkgInfoOpts): { pkgInfo: PackageInfo, readM } if (pkgSnapshot) { try { - resolved = (pkgSnapshotToResolution(depPath, pkgSnapshot, { registries: opts.registries, namedRegistries: opts.namedRegistries }) as TarballResolution).tarball + resolved = (pkgSnapshotToResolution(depPath, pkgSnapshot, { registriesByScope: opts.registriesByScope, registriesByPrefix: opts.registriesByPrefix }) as TarballResolution).tarball } catch (err: unknown) { - // Inspection commands may run without the workspace's namedRegistries + // Inspection commands may run without the workspace's registriesByPrefix // setting (registries come from .modules.yaml); a named-registry entry // whose alias can't be resolved to a URL just has no tarball to show. if ((err as { code?: string }).code !== 'ERR_PNPM_MISSING_NAMED_REGISTRY') throw err diff --git a/pnpm11/deps/inspection/tree-builder/src/getTree.ts b/pnpm11/deps/inspection/tree-builder/src/getTree.ts index 0dce74bfec..e19e628c6a 100644 --- a/pnpm11/deps/inspection/tree-builder/src/getTree.ts +++ b/pnpm11/deps/inspection/tree-builder/src/getTree.ts @@ -4,7 +4,7 @@ import type { DepTypes } from '@pnpm/lockfile.detect-dep-types' import type { PackageSnapshots, ProjectSnapshot } from '@pnpm/lockfile.fs' import type { StoreIndex } from '@pnpm/store.index' import { lexCompare } from '@pnpm/text.ordinal-comparator' -import type { Finder, Registries } from '@pnpm/types' +import type { Finder, RegistriesByScope } from '@pnpm/types' import type { DependencyGraph } from './buildDependencyGraph.js' import type { DependencyNode } from './DependencyNode.js' @@ -23,8 +23,8 @@ export interface BaseTreeOpts { onlyProjects?: boolean search?: Finder skipped: Set - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record depTypes: DepTypes storeDir?: string storeIndex?: StoreIndex diff --git a/pnpm11/deps/inspection/tree-builder/test/getPkgInfo.test.ts b/pnpm11/deps/inspection/tree-builder/test/getPkgInfo.test.ts index feb366ce6e..fe5751c83b 100644 --- a/pnpm11/deps/inspection/tree-builder/test/getPkgInfo.test.ts +++ b/pnpm11/deps/inspection/tree-builder/test/getPkgInfo.test.ts @@ -12,7 +12,7 @@ test('getPkgInfo handles missing pkgSnapshot without crashing', () => { wantedPackages: {}, depTypes: {}, skipped: new Set(), - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, virtualStoreDirMaxLength: 120, diff --git a/pnpm11/deps/inspection/tree-builder/test/getTree.test.ts b/pnpm11/deps/inspection/tree-builder/test/getTree.test.ts index 2c64a9c349..e02897d144 100644 --- a/pnpm11/deps/inspection/tree-builder/test/getTree.test.ts +++ b/pnpm11/deps/inspection/tree-builder/test/getTree.test.ts @@ -104,7 +104,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, currentPackages, @@ -180,7 +180,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, } @@ -318,7 +318,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, virtualStoreDirMaxLength: 120, @@ -571,7 +571,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, virtualStoreDirMaxLength: 120, @@ -670,7 +670,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir, skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, virtualStoreDirMaxLength: 120, @@ -765,7 +765,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, virtualStoreDirMaxLength: 120, @@ -1027,7 +1027,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, virtualStoreDirMaxLength: 120, @@ -1242,7 +1242,7 @@ describe('getTree', () => { include: { optionalDependencies: false }, lockfileDir: '', skipped: new Set(), - registries: { + registriesByScope: { default: 'mock-registry-for-testing.example', }, currentPackages, diff --git a/pnpm11/deps/path/src/index.ts b/pnpm11/deps/path/src/index.ts index 9cc481505b..f6a4fe2d37 100644 --- a/pnpm11/deps/path/src/index.ts +++ b/pnpm11/deps/path/src/index.ts @@ -1,5 +1,5 @@ import { createShortHash } from '@pnpm/crypto.hash' -import type { DepPath, PkgId, PkgIdWithPatchHash, PkgResolutionId, Registries } from '@pnpm/types' +import type { DepPath, PkgId, PkgIdWithPatchHash, PkgResolutionId, RegistriesByScope } from '@pnpm/types' import semver from 'semver' export function isAbsolute (dependencyPath: string): boolean { @@ -147,10 +147,10 @@ export function tryGetPackageId (relDepPath: DepPath): PkgId { return pkgId as PkgId } -export function getRegistryByPackageName (registries: Registries, packageName: string): string { - if (packageName[0] !== '@') return registries.default +export function getRegistryByPackageName (registriesByScope: RegistriesByScope, packageName: string): string { + if (packageName[0] !== '@') return registriesByScope.default const scope = packageName.substring(0, packageName.indexOf('/')) - return registries[scope] || registries.default + return registriesByScope[scope] || registriesByScope.default } export function refToRelative ( diff --git a/pnpm11/deps/security/signatures/src/verifySignatures.ts b/pnpm11/deps/security/signatures/src/verifySignatures.ts index 877e297056..c35740bc3e 100644 --- a/pnpm11/deps/security/signatures/src/verifySignatures.ts +++ b/pnpm11/deps/security/signatures/src/verifySignatures.ts @@ -79,7 +79,7 @@ export async function verifySignatures ( missing: [], verified: 0, } - // Registries without signing keys are not counted as audited: there is no + // RegistriesByScope without signing keys are not counted as audited: there is no // registry trust root to verify against. const packumentCache = new Map>() const limit = pLimit(opts.networkConcurrency ?? 16) diff --git a/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts b/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts index 4a8ced805c..acd0c6cfa4 100644 --- a/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts +++ b/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts @@ -24,7 +24,7 @@ import { import { headlessInstall } from '@pnpm/installing.deps-restorer' import type { EnvLockfile, LockfileObject, PackageSnapshot } from '@pnpm/lockfile.types' import { registerProject, type StoreController } from '@pnpm/store.controller' -import type { DepPath, ProjectId, ProjectRootDir, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, ProjectRootDir, RegistriesByScope } from '@pnpm/types' import spawn from 'cross-spawn' import { familySync } from 'detect-libc' import semver from 'semver' @@ -120,7 +120,7 @@ export async function installPnpmToStore ( envLockfile: EnvLockfile storeController: StoreController storeDir: string - registries: Registries + registriesByScope: RegistriesByScope virtualStoreDirMaxLength: number packageManager?: { name: string, version: string } } & VerifyPnpmEngineIdentityOptions @@ -157,7 +157,7 @@ export async function installPnpmToStore ( allowBuilds: PNPM_ALLOW_BUILDS, storeController: opts.storeController, storeDir: opts.storeDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, packageManager: opts.packageManager, }) @@ -239,7 +239,7 @@ async function installPnpmToGlobalDir ( allowBuilds: PNPM_ALLOW_BUILDS, storeController: opts.storeController, storeDir: opts.storeDir, - registries: opts.registries as Registries, + registriesByScope: opts.registriesByScope as RegistriesByScope, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, packageManager: opts.packageManager, }) @@ -262,7 +262,7 @@ async function installPnpmToGlobalDir ( // Create hash symlink for the global packages system const pkgJson = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf8')) const aliases = Object.keys(pkgJson.dependencies ?? {}) - const cacheHash = createGlobalCacheKey({ aliases, registries: opts.registries }) + const cacheHash = createGlobalCacheKey({ aliases, registriesByScope: opts.registriesByScope }) const hashLink = getHashLink(globalDir, cacheHash) await symlinkDir(installDir, hashLink, { overwrite: true }) @@ -340,7 +340,7 @@ async function installFromLockfile ( allowBuilds?: Record storeController: StoreController storeDir: string - registries: Registries + registriesByScope: RegistriesByScope virtualStoreDirMaxLength: number packageManager?: { name: string, version: string } } @@ -354,7 +354,7 @@ async function installFromLockfile ( lockfileDir: installDir, storeController: opts.storeController, storeDir: opts.storeDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, enableGlobalVirtualStore: true, globalVirtualStoreDir: path.join(opts.storeDir, 'links'), allowBuilds: opts.allowBuilds, diff --git a/pnpm11/engine/pm/commands/src/self-updater/selfUpdate.ts b/pnpm11/engine/pm/commands/src/self-updater/selfUpdate.ts index 5bff339766..8b2211e03a 100644 --- a/pnpm11/engine/pm/commands/src/self-updater/selfUpdate.ts +++ b/pnpm11/engine/pm/commands/src/self-updater/selfUpdate.ts @@ -206,7 +206,7 @@ export async function handler ( if (shouldPersistLockfile({ ...opts.wantedPackageManager, fromDevEngines: true })) { const store = await createStoreController({ ...opts, ...bootstrapConfig }) await resolvePackageManagerIntegrities(resolution.manifest.version, { - registries: bootstrapConfig.registries, + registriesByScope: bootstrapConfig.registriesByScope, rootDir: opts.rootProjectManifestDir, storeController: store.ctrl, storeDir: store.dir, @@ -240,7 +240,7 @@ export async function handler ( // Resolve integrities and write env lockfile to pnpm-lock.yaml const envLockfile = await resolvePackageManagerIntegrities(resolution.manifest.version, { - registries: bootstrapConfig.registries, + registriesByScope: bootstrapConfig.registriesByScope, rootDir: opts.pnpmHomeDir, storeController: store.ctrl, storeDir: store.dir, diff --git a/pnpm11/engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts b/pnpm11/engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts index ba0622bbea..93434ff6f6 100644 --- a/pnpm11/engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts +++ b/pnpm11/engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts @@ -13,7 +13,7 @@ import { PnpmError } from '@pnpm/error' import type { EnvLockfile } from '@pnpm/lockfile.types' import { globalWarn } from '@pnpm/logger' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { familySync } from 'detect-libc' import semver from 'semver' @@ -22,7 +22,7 @@ import { exePlatformPkgDirName, exePlatformPkgDirNameNext } from './installPnpm. const CANONICAL_NPM_REGISTRY = 'https://registry.npmjs.org/' export type VerifyPnpmEngineIdentityOptions = VerifySignaturesOptions & { - registries: Registries + registriesByScope: RegistriesByScope configByUri?: Record /** * The npm signing keys to trust. Defaults to {@link getNpmSigningKeys} (npm's @@ -48,7 +48,7 @@ export type VerifyPnpmEngineIdentityOptions = VerifySignaturesOptions & { * its own key pair; the signed packument is fetched from the configured registry, * which an npm mirror proxies transparently. * - * Registries that serve no `dist.signatures` at all (private mirrors and feed + * RegistriesByScope that serve no `dist.signatures` at all (private mirrors and feed * proxies commonly strip them — https://github.com/pnpm/pnpm/issues/13147) do * not fail the check outright: the signature is then fetched from the canonical * npm registry instead, which proves exactly the same thing, since it is @@ -82,7 +82,7 @@ export async function verifyPnpmEngineIdentity ( const trustedKeys = opts.trustedKeys ?? getNpmSigningKeys() if (trustedKeys.length === 0) return // test seam: no trusted keys means skip - const toVerify = collectEnginePackagesToVerify(envLockfile, opts.registries) + const toVerify = collectEnginePackagesToVerify(envLockfile, opts.registriesByScope) if (toVerify.length === 0) { throw new PnpmError( 'PNPM_ENGINE_IDENTITY_UNVERIFIABLE', @@ -138,14 +138,14 @@ function isTolerableWithoutSignature (failure: InstalledSignatureFailure): boole !equalRegistries(failure.registry, CANONICAL_NPM_REGISTRY) } -function collectEnginePackagesToVerify (envLockfile: EnvLockfile, registries: Registries): InstalledPackageToVerify[] { +function collectEnginePackagesToVerify (envLockfile: EnvLockfile, registriesByScope: RegistriesByScope): InstalledPackageToVerify[] { const pmDeps = envLockfile.importers['.']?.packageManagerDependencies ?? {} const toVerify: InstalledPackageToVerify[] = [] for (const name of ['pnpm', '@pnpm/exe']) { const version = pmDeps[name]?.version if (version == null) continue - toVerify.push(engineComponentToVerify(envLockfile, registries, { name, version })) + toVerify.push(engineComponentToVerify(envLockfile, registriesByScope, { name, version })) } // The bytes actually executed are the host's platform binary, listed as an @@ -177,7 +177,7 @@ function collectEnginePackagesToVerify (envLockfile: EnvLockfile, registries: Re `Cannot verify the identity of the @pnpm/exe.${process.platform}-${process.arch} native binary: it is missing from pnpm-lock.yaml.` ) } - toVerify.push(engineComponentToVerify(envLockfile, registries, { name: platformName, version: optionalDeps[platformName] })) + toVerify.push(engineComponentToVerify(envLockfile, registriesByScope, { name: platformName, version: optionalDeps[platformName] })) } return toVerify @@ -201,7 +201,7 @@ function nativeEngineWrapper (pmDeps: Record): { na function engineComponentToVerify ( envLockfile: EnvLockfile, - registries: Registries, + registriesByScope: RegistriesByScope, { name, version }: { name: string, version: string } ): InstalledPackageToVerify { const integrity = registryIntegrity(envLockfile.packages[`${name}@${version}`]?.resolution) @@ -211,7 +211,7 @@ function engineComponentToVerify ( `Cannot verify the identity of ${name}@${version}: its integrity metadata is missing from pnpm-lock.yaml.` ) } - return { name, version, registry: pickRegistryForPackage(registries, name), integrity } + return { name, version, registry: pickRegistryForPackage(registriesByScope, name), integrity } } function registryIntegrity (resolution: unknown): string | undefined { diff --git a/pnpm11/engine/pm/commands/src/with/with.ts b/pnpm11/engine/pm/commands/src/with/with.ts index ca75ff4619..6388b74e93 100644 --- a/pnpm11/engine/pm/commands/src/with/with.ts +++ b/pnpm11/engine/pm/commands/src/with/with.ts @@ -69,7 +69,7 @@ export async function handler ( // registry and writes the resolved exact version to the envLockfile. const envLockfile = await resolvePackageManagerIntegrities(spec, { rootDir: opts.pnpmHomeDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, storeController: store.ctrl, storeDir: store.dir, }) @@ -81,7 +81,7 @@ export async function handler ( envLockfile, storeController: store.ctrl, storeDir: store.dir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, packageManager: { name: packageManager.name, version: packageManager.version }, // Network settings so the engine identity check can reach the canonical diff --git a/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts b/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts index fe90d40d07..0c39648405 100644 --- a/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -58,7 +58,7 @@ function prepareOptions (dir: string) { globalPkgDir: path.join(dir, 'global', 'v11'), pnpmHomeDir: dir, preferWorkspacePackages: true, - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, sort: false, @@ -162,7 +162,7 @@ console.log('${version}')`, 'utf8') test('self-update', async () => { const opts = prepare() - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler(opts, []) @@ -209,7 +209,7 @@ test('self-update refreshes legacy v10 bootstrap shim at pnpmHomeDir', async () if (process.platform === 'win32') { fs.writeFileSync(path.join(opts.pnpmHomeDir, 'pnpm.cmd'), '@echo stale\n') } - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler(opts, []) @@ -231,7 +231,7 @@ test('self-update does not write shims to pnpmHomeDir on a clean v11 layout', as // pnpmHomeDir, self-update must NOT start writing bins there. Otherwise we // would clutter pnpmHomeDir on every fresh-v11 self-update. const opts = prepare() - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler(opts, []) @@ -247,7 +247,7 @@ test('self-update resolves relative legacy shim targets from pnpmHomeDir', async const shimPath = path.join(opts.pnpmHomeDir, 'pnpm') const shimBody = `#!/bin/sh\n# cmd-shim-target=${relativeTarget}\n` fs.writeFileSync(shimPath, shimBody, { mode: 0o755 }) - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler(opts, []) @@ -270,7 +270,7 @@ test('self-update ignores a dangling legacy shim at pnpmHomeDir', async () => { if (process.platform === 'win32') { fs.writeFileSync(path.join(opts.pnpmHomeDir, 'pnpm.cmd'), '@echo off\ncall "gone\\pnpm.cmd" %*\n') } - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler(opts, []) @@ -294,12 +294,12 @@ test('self-update ignores a dangling legacy shim at pnpmHomeDir', async () => { test('self-update by exact version', async () => { const opts = prepare() - const metadata = createMetadata('9.2.0', opts.registries.default, ['9.1.0']) - const registry = opts.registries.default.replace(/\/$/, '') + const metadata = createMetadata('9.2.0', opts.registriesByScope.default, ['9.1.0']) + const registry = opts.registriesByScope.default.replace(/\/$/, '') getMockAgent().get(registry) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata).persist() - mockExeMetadata(opts.registries.default, '9.1.0') + mockExeMetadata(opts.registriesByScope.default, '9.1.0') const tgzData = fs.readFileSync(pnpmTarballPath) getMockAgent().get(registry) .intercept({ path: '/pnpm/-/pnpm-9.1.0.tgz', method: 'GET' }) @@ -329,9 +329,9 @@ test('self-update by exact version', async () => { test('self-update does nothing when pnpm is up to date', async () => { const opts = prepare() seedGlobalPnpm(opts, '9.0.0') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)) + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)) const output = await selfUpdate.handler(opts, []) @@ -341,7 +341,7 @@ test('self-update does nothing when pnpm is up to date', async () => { test('self-update installs the active pnpm version when it is missing from the global dir', async () => { mockPackageManager.version = '9.1.0' const opts = prepare() - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) const output = await selfUpdate.handler(opts, ['9.1.0']) @@ -369,11 +369,11 @@ test('self-update respects minimumReleaseAge for implicit latest resolution', as }) const pkgJsonPath = path.join(opts.dir, 'package.json') const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, ['9.0.0'], { '9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(), '9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -393,10 +393,10 @@ test('self-update respects minimumReleaseAge for implicit latest resolution', as test('self-update refuses an immature version under strict minimumReleaseAge', async () => { const opts = prepare() const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, [], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, [], { '9.1.0': new Date(now - 2 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -412,7 +412,7 @@ test('self-update refuses an immature version under strict minimumReleaseAge', a test('self-update installs an immature version when minimumReleaseAge is not strict', async () => { const opts = prepare() const now = Date.now() - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default, [], { + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default, [], { '9.1.0': new Date(now - 2 * 60 * 60 * 1000).toISOString(), })) @@ -429,11 +429,11 @@ test('self-update fetches pnpm from the trusted package-manager registry, not th const bootstrapRegistry = 'https://bootstrap-registry.test/' mockRegistryForUpdate(bootstrapRegistry, '9.1.0', createMetadata('9.1.0', bootstrapRegistry)) - // `registries` is what a project `.npmrc` / workspace manifest steers; a + // `registriesByScope` is what a project `.npmrc` / workspace manifest steers; a // request to it would 404 against the mock agent and fail the test. const output = await selfUpdate.handler({ ...opts, - registries: { default: 'https://project-registry.test/' }, + registriesByScope: { default: 'https://project-registry.test/' }, packageManagerRegistries: { default: bootstrapRegistry }, }, []) @@ -442,7 +442,7 @@ test('self-update fetches pnpm from the trusted package-manager registry, not th test('self-update rejects a trust downgrade under trustPolicy=no-downgrade', async () => { const opts = prepare() - const registry = opts.registries.default + const registry = opts.registriesByScope.default const now = Date.now() // The earlier 9.0.5 was published with strong trust evidence (trusted // publisher + provenance); the later 9.1.0 has none — a trust downgrade @@ -513,7 +513,7 @@ test('self-update does not write packageManagerDependencies when package manager '---', '', ].join('\n'), 'utf8') - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler({ ...opts, @@ -539,11 +539,11 @@ test('global self-update respects minimumReleaseAge: skips immature latest, no-o seedGlobalPnpm(opts, '9.0.0') const globalEntriesBefore = fs.readdirSync(opts.globalPkgDir).sort() const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, ['9.0.0'], { '9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(), '9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -562,11 +562,11 @@ test('self-update respects minimumReleaseAgeExclude for implicit latest resoluti }) const pkgJsonPath = path.join(opts.dir, 'package.json') const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, ['9.0.0'], { '9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(), '9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -590,11 +590,11 @@ test('self-update respects minimumReleaseAgeExclude exact version for implicit l }) const pkgJsonPath = path.join(opts.dir, 'package.json') const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, ['9.0.0'], { '9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(), '9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -618,11 +618,11 @@ test('self-update does not bypass minimumReleaseAge when minimumReleaseAgeExclud }) const pkgJsonPath = path.join(opts.dir, 'package.json') const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, ['9.0.0'], { '9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(), '9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -645,11 +645,11 @@ test('self-update throws on invalid minimumReleaseAgeExclude pattern', async () packageManager: 'pnpm@8.0.0', }) const now = Date.now() - const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], { + const metadata = createMetadata('9.1.0', opts.registriesByScope.default, ['9.0.0'], { '9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(), '9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(), }) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, metadata) @@ -668,9 +668,9 @@ test('self-update throws on invalid minimumReleaseAgeExclude pattern', async () test('self-update refuses to downgrade when latest is older than current', async () => { const opts = prepare() - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('8.15.0', opts.registries.default)) + .reply(200, createMetadata('8.15.0', opts.registriesByScope.default)) const output = await selfUpdate.handler(opts, []) @@ -687,7 +687,7 @@ test('self-update latest forces the downgrade even when latest is older', async // the no-downgrade guard. The fixture tarball is still 9.1.0, but this test // only checks that the install path was reached — not the resulting pinned // version. - mockRegistryForUpdate(opts.registries.default, '8.15.0', createMetadata('8.15.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '8.15.0', createMetadata('8.15.0', opts.registriesByScope.default)) const output = await selfUpdate.handler(opts, ['latest']) @@ -702,7 +702,7 @@ test('self-update by exact older version skips the no-downgrade guard', async () // metadata claims 8.15.0. That is fine here — this test only verifies that // an explicit version argument bypasses the implicit-latest guard, not the // resulting pinned version. - mockRegistryForUpdate(opts.registries.default, '8.15.0', createMetadata('8.15.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '8.15.0', createMetadata('8.15.0', opts.registriesByScope.default)) const output = await selfUpdate.handler(opts, ['8.15.0']) @@ -716,9 +716,9 @@ test('self-update refuses to downgrade the project pin when latest is older', as packageManager: 'pnpm@10.0.0', }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.5.0', opts.registries.default)) + .reply(200, createMetadata('9.5.0', opts.registriesByScope.default)) const output = await selfUpdate.handler({ ...opts, @@ -759,9 +759,9 @@ test('self-update refuses to downgrade the project pin when the lockfile is pinn '---', '', ].join('\n'), 'utf8') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.5.0', opts.registries.default)) + .reply(200, createMetadata('9.5.0', opts.registriesByScope.default)) const output = await selfUpdate.handler({ ...opts, @@ -780,9 +780,9 @@ test('should update packageManager field when a newer pnpm version is available' fs.writeFileSync(pkgJsonPath, JSON.stringify({ packageManager: 'pnpm@8.0.0', }), 'utf8') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)) + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)) const output = await selfUpdate.handler({ ...opts, @@ -802,9 +802,9 @@ test('should not update packageManager field when current version matches latest fs.writeFileSync(pkgJsonPath, JSON.stringify({ packageManager: 'pnpm@9.0.0', }), 'utf8') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)) + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)) const output = await selfUpdate.handler({ ...opts, @@ -825,10 +825,10 @@ test('should update devEngines.packageManager version when a newer pnpm version }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') const output = await selfUpdate.handler({ ...opts, @@ -854,10 +854,10 @@ test('should update pnpm entry in devEngines.packageManager array', async () => }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') const output = await selfUpdate.handler({ ...opts, @@ -881,10 +881,10 @@ test('should not modify devEngines.packageManager range when resolved version st }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') const output = await selfUpdate.handler({ ...opts, @@ -910,10 +910,10 @@ test('should fall back to ^version when complex range cannot accommodate the new }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') await selfUpdate.handler({ ...opts, @@ -935,10 +935,10 @@ test('should update both packageManager and devEngines.packageManager when both }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') const output = await selfUpdate.handler({ ...opts, @@ -962,10 +962,10 @@ test('should update both packageManager (with integrity hash) and devEngines.pac }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') await selfUpdate.handler({ ...opts, @@ -988,10 +988,10 @@ test('should sync both fields to the new exact version when their current versio }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') await selfUpdate.handler({ ...opts, @@ -1014,10 +1014,10 @@ test('should pin devEngines.packageManager to an exact version when packageManag }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') await selfUpdate.handler({ ...opts, @@ -1040,10 +1040,10 @@ test('should leave packageManager alone when it pins a different package manager }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') await selfUpdate.handler({ ...opts, @@ -1065,10 +1065,10 @@ test('should update devEngines.packageManager range when resolved version no lon }, }) const pkgJsonPath = path.join(opts.dir, 'package.json') - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.0.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.0.0') + .reply(200, createMetadata('9.0.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.0.0') const output = await selfUpdate.handler({ ...opts, @@ -1099,10 +1099,10 @@ console.log('9.2.0')`, 'utf8') // Create a hash symlink pointing to the install dir (like handleGlobalAdd does) fs.symlinkSync(installDir, path.join(globalDir, 'fake-hash')) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.2.0', opts.registries.default)).persist() - mockExeMetadata(opts.registries.default, '9.2.0') + .reply(200, createMetadata('9.2.0', opts.registriesByScope.default)).persist() + mockExeMetadata(opts.registriesByScope.default, '9.2.0') const output = await selfUpdate.handler(opts, []) @@ -1130,7 +1130,7 @@ test('self-update works globally without package.json', async () => { pnpmHomeDir, bin: path.join(pnpmHomeDir, 'bin'), } - mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default)) + mockRegistryForUpdate(opts.registriesByScope.default, '9.1.0', createMetadata('9.1.0', opts.registriesByScope.default)) await selfUpdate.handler(opts, []) @@ -1169,9 +1169,9 @@ test('self-update updates the packageManager field in package.json', async () => version: '9.0.0', }, } - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.1.0', opts.registries.default)) + .reply(200, createMetadata('9.1.0', opts.registriesByScope.default)) const output = await selfUpdate.handler(opts, []) @@ -1187,7 +1187,7 @@ test('installPnpm rejects and cleans up when the installed pnpm has no working e // Serving it as pnpm's tarball is the only way here without publishing a // broken pnpm as a fixture, so the metadata carries this tarball's integrity. const tgzWithoutBin = fs.readFileSync(require.resolve('@pnpm/tgz-fixtures/tgz/is-positive-1.0.0.tgz')) - const registry = opts.registries.default + const registry = opts.registriesByScope.default getMockAgent().get(registry.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) .reply(200, { @@ -1221,11 +1221,11 @@ test('installPnpm rejects and cleans up when the installed pnpm has no working e test('installPnpm without env lockfile uses resolution path', async () => { const opts = prepare() - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm', method: 'GET' }) - .reply(200, createMetadata('9.1.0', opts.registries.default)).persist() + .reply(200, createMetadata('9.1.0', opts.registriesByScope.default)).persist() const tgzData = fs.readFileSync(pnpmTarballPath) - getMockAgent().get(opts.registries.default.replace(/\/$/, '')) + getMockAgent().get(opts.registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/pnpm/-/pnpm-9.1.0.tgz', method: 'GET' }) .reply(200, tgzData) diff --git a/pnpm11/engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts b/pnpm11/engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts index 1cf8062be2..54b2a3888b 100644 --- a/pnpm11/engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts +++ b/pnpm11/engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts @@ -64,7 +64,7 @@ describe('verifyPnpmEngineIdentity', () => { test('throws (fails closed) when the canonical registry is the configured registry and is unreachable', async () => { // No intercept registered and net connect disabled, so the packument fetch fails. - const opts = { ...optsTrusting(createSigningKey()), registries: { default: CANONICAL_REGISTRY } } + const opts = { ...optsTrusting(createSigningKey()), registriesByScope: { default: CANONICAL_REGISTRY } } await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', opts)).rejects.toThrow(/Refusing to run pnpm/) }) @@ -73,7 +73,7 @@ describe('verifyPnpmEngineIdentity', () => { // URL-equivalent to the canonical registry and must not unlock the // warn-and-proceed path. await Promise.all(['https://Registry.NPMJS.org:443/', 'https://user:pass@registry.npmjs.org/'].map(async (canonical) => { - const opts = { ...optsTrusting(createSigningKey()), registries: { default: canonical } } + const opts = { ...optsTrusting(createSigningKey()), registriesByScope: { default: canonical } } await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', opts)).rejects.toThrow(/Refusing to run pnpm/) })) }) @@ -126,12 +126,12 @@ describe('verifyPnpmEngineIdentity', () => { const lockfile = envLockfile() ;(lockfile.packages as Record)['pnpm@9.1.0'] = { resolution: { integrity: 'sha1-8bee00286a17c00a13c7e6e6dd9a9b389220ee7f' } } - const opts = { ...optsTrusting(createSigningKey()), registries: { default: CANONICAL_REGISTRY } } + const opts = { ...optsTrusting(createSigningKey()), registriesByScope: { default: CANONICAL_REGISTRY } } await expect(verifyPnpmEngineIdentity(lockfile, '9.1.0', opts)).rejects.toThrow(/Refusing to run pnpm/) }) test('skips (no throw) when no trusted keys are provided', async () => { - await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', { registries: { default: REGISTRY }, trustedKeys: [] })).resolves.toBeUndefined() + await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', { registriesByScope: { default: REGISTRY }, trustedKeys: [] })).resolves.toBeUndefined() }) test('throws when an engine component in the lockfile has no integrity metadata', async () => { @@ -218,7 +218,7 @@ function envLockfileV12 (): EnvLockfile { function optsTrusting (key: ReturnType) { return { - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, trustedKeys: [{ expires: null, key: key.publicKey, keyid: key.keyid, keytype: 'ecdsa-sha2-nistp256', scheme: 'ecdsa-sha2-nistp256' }], } } diff --git a/pnpm11/engine/runtime/commands/src/env/node.ts b/pnpm11/engine/runtime/commands/src/env/node.ts index 980bcc26ba..ceaa51053d 100644 --- a/pnpm11/engine/runtime/commands/src/env/node.ts +++ b/pnpm11/engine/runtime/commands/src/env/node.ts @@ -25,7 +25,7 @@ export type NvmNodeCommandOptions = Pick & Omit & PnpmSettings +} & Pick & Omit & PnpmSettings export async function handler ( opts: DlxCommandOptions, @@ -163,7 +163,7 @@ export async function handler ( packages: resolvedPkgs, dlxCacheMaxAge: opts.dlxCacheMaxAge, cacheDir: opts.cacheDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, allowBuild: opts.allowBuild, supportedArchitectures: opts.supportedArchitectures, }) @@ -374,7 +374,7 @@ function findCache (opts: { packages: string[] cacheDir: string dlxCacheMaxAge: number - registries: Record + registriesByScope: Record allowBuild?: string[] supportedArchitectures?: SupportedArchitectures }): { cacheLink: string, cacheExists: boolean, cachedDir: string } { @@ -391,7 +391,7 @@ function findCache (opts: { function createDlxCommandCacheDir ( opts: { packages: string[] - registries: Record + registriesByScope: Record cacheDir: string allowBuild?: string[] supportedArchitectures?: SupportedArchitectures @@ -406,12 +406,12 @@ function createDlxCommandCacheDir ( export function createCacheKey (opts: { packages: string[] - registries: Record + registriesByScope: Record allowBuild?: string[] supportedArchitectures?: SupportedArchitectures }): string { const sortedPkgs = [...opts.packages].sort(lexCompare) - const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => lexCompare(k1, k2)) + const sortedRegistries = Object.entries(opts.registriesByScope).sort(([k1], [k2]) => lexCompare(k1, k2)) const args: unknown[] = [sortedPkgs, sortedRegistries] if (opts.allowBuild?.length) { args.push({ allowBuild: opts.allowBuild.sort(lexCompare) }) diff --git a/pnpm11/exec/commands/test/dlx.createCacheKey.test.ts b/pnpm11/exec/commands/test/dlx.createCacheKey.test.ts index 684bb7c4cd..43a9234b01 100644 --- a/pnpm11/exec/commands/test/dlx.createCacheKey.test.ts +++ b/pnpm11/exec/commands/test/dlx.createCacheKey.test.ts @@ -6,7 +6,7 @@ import { createCacheKey } from '../src/dlx.js' test('creates a hash', () => { const received = createCacheKey({ packages: ['shx', '@foo/bar'], - registries: { + registriesByScope: { default: 'https://registry.npmjs.com/', '@foo': 'https://example.com/npm-registry/foo/', }, @@ -19,8 +19,8 @@ test('creates a hash', () => { }) test('is agnostic to package order', () => { - const registries = { default: 'https://registry.npmjs.com/' } - const makeOpts = (packages: string[]) => ({ packages, registries }) + const registriesByScope = { default: 'https://registry.npmjs.com/' } + const makeOpts = (packages: string[]) => ({ packages, registriesByScope }) expect(createCacheKey(makeOpts(['a', 'c', 'b']))).toBe(createCacheKey(makeOpts(['a', 'b', 'c']))) expect(createCacheKey(makeOpts(['b', 'a', 'c']))).toBe(createCacheKey(makeOpts(['a', 'b', 'c']))) expect(createCacheKey(makeOpts(['b', 'c', 'a']))).toBe(createCacheKey(makeOpts(['a', 'b', 'c']))) @@ -34,26 +34,26 @@ test('is agnostic to registry key order', () => { const bar = 'https://example.com/bar/' expect(createCacheKey({ packages, - registries: { '@foo': foo, '@bar': bar }, + registriesByScope: { '@foo': foo, '@bar': bar }, })).toBe(createCacheKey({ packages, - registries: { '@bar': bar, '@foo': foo }, + registriesByScope: { '@bar': bar, '@foo': foo }, })) }) test('is agnostic to supportedArchitectures values order', () => { const packages = ['a', 'b', 'c'] - const registries = { default: 'https://registry.npmjs.com/' } + const registriesByScope = { default: 'https://registry.npmjs.com/' } expect(createCacheKey({ packages, - registries, + registriesByScope, supportedArchitectures: { os: ['win32', 'linux', 'darwin'], cpu: ['x86_64', 'armv7', 'i686'], }, })).toBe(createCacheKey({ packages, - registries, + registriesByScope, supportedArchitectures: { cpu: ['armv7', 'i686', 'x86_64'], os: ['darwin', 'linux', 'win32'], diff --git a/pnpm11/exec/commands/test/dlx.e2e.ts b/pnpm11/exec/commands/test/dlx.e2e.ts index 56c798ec6c..102854174f 100644 --- a/pnpm11/exec/commands/test/dlx.e2e.ts +++ b/pnpm11/exec/commands/test/dlx.e2e.ts @@ -52,7 +52,7 @@ function sanitizeDlxCacheComponent (cacheName: string): string { const createCacheKey = (...packages: string[]): string => dlx.createCacheKey({ packages, - registries: DEFAULT_OPTS.registries, + registriesByScope: DEFAULT_OPTS.registriesByScope, supportedArchitectures: DEFAULT_OPTS.supportedArchitectures, }) @@ -397,7 +397,7 @@ test('dlx builds the packages passed via --allow-build', async () => { const dlxCacheDir = path.resolve('cache', 'dlx', dlx.createCacheKey({ packages: ['@pnpm.e2e/has-bin-and-needs-build@1.0.0'], allowBuild, - registries: DEFAULT_OPTS.registries, + registriesByScope: DEFAULT_OPTS.registriesByScope, supportedArchitectures: DEFAULT_OPTS.supportedArchitectures, }), 'pkg') const builtPkg1Path = path.join(dlxCacheDir, 'node_modules/.pnpm/@pnpm.e2e+pre-and-postinstall-scripts-example@1.0.0/node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example') @@ -485,7 +485,7 @@ test('dlx should fail when the requested package does not meet the minimum age r dir: path.resolve('project'), minimumReleaseAge: 60 * 24 * 10000, minimumReleaseAgeStrict: true, - registries: { + registriesByScope: { // We must use the public registry instead of verdaccio here // because verdaccio has the "times" field in the abbreviated metadata too. default: 'https://registry.npmjs.org/', @@ -504,7 +504,7 @@ test('dlx should respect minimumReleaseAgeExclude', async () => { cacheDir: path.resolve('cache'), minimumReleaseAge: 60 * 24 * 10000, minimumReleaseAgeExclude: ['*'], - registries: { + registriesByScope: { // We must use the public registry instead of verdaccio here // because verdaccio has the "times" field in the abbreviated metadata too. default: 'https://registry.npmjs.org/', diff --git a/pnpm11/global/commands/src/globalAdd.ts b/pnpm11/global/commands/src/globalAdd.ts index 34e756011e..5a1072bd9d 100644 --- a/pnpm11/global/commands/src/globalAdd.ts +++ b/pnpm11/global/commands/src/globalAdd.ts @@ -24,7 +24,7 @@ import { readInstalledPackages } from './readInstalledPackages.js' export type GlobalAddOptions = CreateStoreControllerOptions & { bin?: string globalPkgDir?: string - registries: Record + registriesByScope: Record allowBuild?: string[] allowBuilds?: Record saveExact?: boolean @@ -158,7 +158,7 @@ async function installGroup ( const cacheHash = createGlobalCacheKey({ aliases, - registries: opts.registries, + registriesByScope: opts.registriesByScope, }) const hashLink = getHashLink(globalDir, cacheHash) const activatedBins = await activateGlobalInstall({ diff --git a/pnpm11/global/commands/test/globalAdd.test.ts b/pnpm11/global/commands/test/globalAdd.test.ts index 2275951c31..2ba2660bd5 100644 --- a/pnpm11/global/commands/test/globalAdd.test.ts +++ b/pnpm11/global/commands/test/globalAdd.test.ts @@ -123,7 +123,7 @@ test('global add activates before cleaning up a same-hash pnpm replacement', asy bin: '/global/bin', dir: '/project', globalPkgDir: '/global/v11', - registries: {}, + registriesByScope: {}, updateResolutionPolicyManifest, } as any, ['file:/tmp/pnpm'], {}) // eslint-disable-line @typescript-eslint/no-explicit-any @@ -157,7 +157,7 @@ test('global add does not clean up or persist policy when activation fails', asy bin: '/global/bin', dir: '/project', globalPkgDir: '/global/v11', - registries: {}, + registriesByScope: {}, updateResolutionPolicyManifest, } as any, ['file:/tmp/pnpm'], {})).rejects.toBe(activationError) // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/pnpm11/global/packages/src/cacheKey.ts b/pnpm11/global/packages/src/cacheKey.ts index fc8d381483..723aa86907 100644 --- a/pnpm11/global/packages/src/cacheKey.ts +++ b/pnpm11/global/packages/src/cacheKey.ts @@ -3,10 +3,10 @@ import { lexCompare } from '@pnpm/text.ordinal-comparator' export function createGlobalCacheKey (opts: { aliases: string[] - registries: Record + registriesByScope: Record }): string { const sortedAliases = [...opts.aliases].sort(lexCompare) - const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => lexCompare(k1, k2)) + const sortedRegistries = Object.entries(opts.registriesByScope).sort(([k1], [k2]) => lexCompare(k1, k2)) const hashStr = JSON.stringify([sortedAliases, sortedRegistries]) return createHexHash(hashStr) } diff --git a/pnpm11/hooks/types/src/index.ts b/pnpm11/hooks/types/src/index.ts index f7448ddfc2..13096edbcc 100644 --- a/pnpm11/hooks/types/src/index.ts +++ b/pnpm11/hooks/types/src/index.ts @@ -2,7 +2,7 @@ import type { Fetchers, FetchOptions, FetchResult, ResolutionFetchContract } fro import type { LockfileObject, PackageSnapshot } from '@pnpm/lockfile.types' import type { Resolution, WantedDependency } from '@pnpm/resolving.resolver-base' import type { Cafs } from '@pnpm/store.cafs-types' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' // Custom resolution types must use scoped naming to avoid conflicts with pnpm's built-in types export type CustomResolutionType = `@${string}/${string}` @@ -15,7 +15,12 @@ export interface PreResolutionHookContext { existsNonEmptyWantedLockfile: boolean lockfileDir: string storeDir: string - registries: Registries + /** + * Named for the `.pnpmfile.cjs` contract rather than for the config field + * it comes from (`registriesByScope`): a hook is user code, and the key it + * reads is API. pacquet passes the same name. + */ + registries: RegistriesByScope } export interface PreResolutionHookLogger { diff --git a/pnpm11/installing/client/test/index.ts b/pnpm11/installing/client/test/index.ts index ddb111b63c..ae9d50096d 100644 --- a/pnpm11/installing/client/test/index.ts +++ b/pnpm11/installing/client/test/index.ts @@ -14,7 +14,7 @@ test('createClient()', () => { const client = createClient({ configByUri: {}, cacheDir: '', - registries: { + registriesByScope: { default: 'https://reigstry.npmjs.org/', }, storeDir: '.store', @@ -27,7 +27,7 @@ test('createResolver()', () => { const { resolve } = createResolver({ configByUri: {}, cacheDir: '', - registries: { + registriesByScope: { default: 'https://reigstry.npmjs.org/', }, storeDir: '.store', diff --git a/pnpm11/installing/commands/src/install.ts b/pnpm11/installing/commands/src/install.ts index f7bfe01c68..dad1f80d7a 100644 --- a/pnpm11/installing/commands/src/install.ts +++ b/pnpm11/installing/commands/src/install.ts @@ -348,7 +348,7 @@ export type InstallCommandOptions = Pick retry?: RetryTimeoutOptions timeout?: number @@ -50,7 +50,7 @@ export async function verifyPacquetIdentity ( opts: VerifyPacquetIdentityOptions ): Promise { const trustedKeys = getNpmSigningKeys() - const toVerify = await collectPacquetPackagesToVerify(packageName, opts.rootDir, opts.registries) + const toVerify = await collectPacquetPackagesToVerify(packageName, opts.rootDir, opts.registriesByScope) if (toVerify == null) { // pacquet has no installed binary for this platform — use pnpm's own engine. return skip(opts.lockfileDir) @@ -82,7 +82,7 @@ export async function verifyPacquetIdentity ( async function collectPacquetPackagesToVerify ( packageName: string, rootDir: string, - registries: Registries + registriesByScope: RegistriesByScope ): Promise { const envLockfile = await readEnvLockfile(rootDir) if (envLockfile == null) return undefined @@ -103,8 +103,8 @@ async function collectPacquetPackagesToVerify ( if (platformIntegrity == null) return undefined return [ - { name: packageName, version: shim.version, registry: pickRegistryForPackage(registries, packageName), integrity: shimIntegrity }, - { name: platformPkgName, version: platformVersion, registry: pickRegistryForPackage(registries, platformPkgName), integrity: platformIntegrity }, + { name: packageName, version: shim.version, registry: pickRegistryForPackage(registriesByScope, packageName), integrity: shimIntegrity }, + { name: platformPkgName, version: platformVersion, registry: pickRegistryForPackage(registriesByScope, platformPkgName), integrity: platformIntegrity }, ] } diff --git a/pnpm11/installing/commands/test/add.ts b/pnpm11/installing/commands/test/add.ts index 67fd39def3..a7d953bd03 100644 --- a/pnpm11/installing/commands/test/add.ts +++ b/pnpm11/installing/commands/test/add.ts @@ -34,7 +34,7 @@ const DEFAULT_OPTIONS = { pnpmfile: ['.pnpmfile.cjs'], pnpmHomeDir: '', configByUri: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, rootProjectManifestDir: '', diff --git a/pnpm11/installing/commands/test/addJsr.ts b/pnpm11/installing/commands/test/addJsr.ts index 422e76c097..804cfddb7b 100644 --- a/pnpm11/installing/commands/test/addJsr.ts +++ b/pnpm11/installing/commands/test/addJsr.ts @@ -11,8 +11,8 @@ import { DEFAULT_OPTS } from './utils/index.js' const createOptions = (jsr: string = 'https://npm.jsr.io/') => ({ ...DEFAULT_OPTS, configByUri: {}, - registries: { - ...DEFAULT_OPTS.registries, + registriesByScope: { + ...DEFAULT_OPTS.registriesByScope, '@jsr': jsr, }, dir: process.cwd(), diff --git a/pnpm11/installing/commands/test/fetch.ts b/pnpm11/installing/commands/test/fetch.ts index f5434c28db..30ceb47333 100644 --- a/pnpm11/installing/commands/test/fetch.ts +++ b/pnpm11/installing/commands/test/fetch.ts @@ -33,7 +33,7 @@ const DEFAULT_OPTIONS = { pnpmfile: ['.pnpmfile.cjs'], pnpmHomeDir: '', configByUri: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, rootProjectManifestDir: '', diff --git a/pnpm11/installing/commands/test/import.ts b/pnpm11/installing/commands/test/import.ts index d795b9c130..6fd5392aa7 100644 --- a/pnpm11/installing/commands/test/import.ts +++ b/pnpm11/installing/commands/test/import.ts @@ -35,7 +35,7 @@ const DEFAULT_OPTS = { proxy: undefined, pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, registry: REGISTRY, rootProjectManifestDir: '', storeDir: path.join(TMP, 'store'), diff --git a/pnpm11/installing/commands/test/importRecursive.ts b/pnpm11/installing/commands/test/importRecursive.ts index 358cac6644..1885464a7a 100644 --- a/pnpm11/installing/commands/test/importRecursive.ts +++ b/pnpm11/installing/commands/test/importRecursive.ts @@ -33,7 +33,7 @@ const DEFAULT_OPTS = { proxy: undefined, pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, registry: REGISTRY, rootProjectManifestDir: '', storeDir: path.join(TMP, 'store'), diff --git a/pnpm11/installing/commands/test/install.ts b/pnpm11/installing/commands/test/install.ts index 216c1722b6..b46a04cf3c 100644 --- a/pnpm11/installing/commands/test/install.ts +++ b/pnpm11/installing/commands/test/install.ts @@ -195,7 +195,7 @@ test('install restores a deleted pnpm-lock.yaml from the current lockfile withou ...DEFAULT_OPTS, dir: process.cwd(), optimisticRepeatInstall: true, - registries: { default: 'http://127.0.0.1:9/' }, + registriesByScope: { default: 'http://127.0.0.1:9/' }, }) expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(originalLockfile) diff --git a/pnpm11/installing/commands/test/peerDependencies.ts b/pnpm11/installing/commands/test/peerDependencies.ts index f6e9ff0161..b5c49e6dcf 100644 --- a/pnpm11/installing/commands/test/peerDependencies.ts +++ b/pnpm11/installing/commands/test/peerDependencies.ts @@ -30,7 +30,7 @@ const DEFAULT_OPTIONS = { pnpmHomeDir: '', preferWorkspacePackages: true, configByUri: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, rootProjectManifestDir: '', diff --git a/pnpm11/installing/commands/test/prune.ts b/pnpm11/installing/commands/test/prune.ts index 4a7eb9877a..15dad5aab7 100644 --- a/pnpm11/installing/commands/test/prune.ts +++ b/pnpm11/installing/commands/test/prune.ts @@ -33,7 +33,7 @@ const DEFAULT_OPTIONS = { pnpmHomeDir: '', preferWorkspacePackages: true, configByUri: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, rootProjectManifestDir: '', diff --git a/pnpm11/installing/commands/test/saveCatalog.ts b/pnpm11/installing/commands/test/saveCatalog.ts index 8c3b7e9dce..12ac4c2875 100644 --- a/pnpm11/installing/commands/test/saveCatalog.ts +++ b/pnpm11/installing/commands/test/saveCatalog.ts @@ -85,7 +85,7 @@ test('saveCatalogName works with different protocols', async () => { .reply(200) const options = createOptions() - options.registries['@jsr'] = 'https://npm.jsr.io/' + options.registriesByScope['@jsr'] = 'https://npm.jsr.io/' await add.handler(options, [ '@pnpm.e2e/foo@100.1.0', 'jsr:@rus/greet@0.0.3', diff --git a/pnpm11/installing/commands/test/update/interactive.ts b/pnpm11/installing/commands/test/update/interactive.ts index 5a46689572..246a6de6dd 100644 --- a/pnpm11/installing/commands/test/update/interactive.ts +++ b/pnpm11/installing/commands/test/update/interactive.ts @@ -52,7 +52,7 @@ const DEFAULT_OPTIONS = { pnpmHomeDir: '', preferWorkspacePackages: true, configByUri: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, rootProjectManifestDir: '', diff --git a/pnpm11/installing/commands/test/update/issue-7415.ts b/pnpm11/installing/commands/test/update/issue-7415.ts index a42699a1a3..792992b88e 100644 --- a/pnpm11/installing/commands/test/update/issue-7415.ts +++ b/pnpm11/installing/commands/test/update/issue-7415.ts @@ -50,7 +50,7 @@ const DEFAULT_OPTIONS = { pnpmHomeDir: '', preferWorkspacePackages: true, configByUri: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, rootProjectManifestDir: '', diff --git a/pnpm11/installing/commands/test/update/jsr.ts b/pnpm11/installing/commands/test/update/jsr.ts index dce54f0fa2..7d2dd292af 100644 --- a/pnpm11/installing/commands/test/update/jsr.ts +++ b/pnpm11/installing/commands/test/update/jsr.ts @@ -13,8 +13,8 @@ import { DEFAULT_OPTS } from '../utils/index.js' const createOptions = (jsr: string = DEFAULT_OPTS.registry) => ({ ...DEFAULT_OPTS, configByUri: {}, - registries: { - ...DEFAULT_OPTS.registries, + registriesByScope: { + ...DEFAULT_OPTS.registriesByScope, '@jsr': jsr, }, dir: process.cwd(), diff --git a/pnpm11/installing/context/src/index.ts b/pnpm11/installing/context/src/index.ts index 1e298bb36f..1c914500f1 100644 --- a/pnpm11/installing/context/src/index.ts +++ b/pnpm11/installing/context/src/index.ts @@ -19,7 +19,7 @@ import type { ProjectRootDir, ProjectRootDirRealPath, ReadPackageHook, - Registries, + RegistriesByScope, } from '@pnpm/types' import { pathAbsolute } from 'path-absolute' import { clone } from 'ramda' @@ -67,7 +67,7 @@ export interface PnpmContext { wantedLockfile: LockfileObject wantedLockfileIsModified: boolean workspacePackages: WorkspacePackages - registries: Registries + registriesByScope: RegistriesByScope } export interface ProjectOptions { @@ -101,7 +101,7 @@ export interface GetContextOptions { nodeLinker: 'isolated' | 'hoisted' | 'pnp' readPackageHook?: ReadPackageHook include?: IncludedDependencies - registries: Registries + registriesByScope: RegistriesByScope storeDir: string useLockfile: boolean useGitBranchLockfile?: boolean @@ -173,7 +173,7 @@ export async function getContext ( projects: Object.fromEntries(importersContext.projects.map((project) => [project.rootDir, project])), publicHoistPattern: opts.publicHoistPattern, currentPublicHoistPattern: importersContext.currentPublicHoistPattern, - registries: opts.registries, + registriesByScope: opts.registriesByScope, rootModulesDir: importersContext.rootModulesDir, skipped: importersContext.skipped, storeDir: opts.storeDir, @@ -189,7 +189,7 @@ export async function getContext ( frozenLockfile: opts.frozenLockfile === true, lockfileDir: opts.lockfileDir, projects: importersContext.projects, - registry: opts.registries.default, + registry: opts.registriesByScope.default, useLockfile: opts.useLockfile, useGitBranchLockfile: opts.useGitBranchLockfile, mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles, @@ -226,7 +226,7 @@ export interface PnpmSingleContext { modulesFile: Modules | null pendingBuilds: string[] publicHoistPattern: string[] | undefined - registries: Registries + registriesByScope: RegistriesByScope rootModulesDir: string lockfileDir: string virtualStoreDir: string @@ -256,7 +256,7 @@ export async function getContextForSingleImporter ( readPackageHook?: ReadPackageHook include?: IncludedDependencies dir: string - registries: Registries + registriesByScope: RegistriesByScope storeDir: string useLockfile: boolean useGitBranchLockfile?: boolean @@ -275,7 +275,6 @@ export async function getContextForSingleImporter ( include, modules, pendingBuilds, - registries, skipped, rootModulesDir, } = await readProjectsContext( @@ -334,10 +333,7 @@ export async function getContextForSingleImporter ( pendingBuilds, prefix: opts.dir, publicHoistPattern: opts.publicHoistPattern, - registries: { - ...opts.registries, - ...registries, - }, + registriesByScope: opts.registriesByScope, rootModulesDir, skipped, storeDir, @@ -351,7 +347,7 @@ export async function getContextForSingleImporter ( frozenLockfile: false, lockfileDir: opts.lockfileDir, projects: [{ id: importerId, rootDir: opts.dir as ProjectRootDir }], - registry: opts.registries.default, + registry: opts.registriesByScope.default, useLockfile: opts.useLockfile, useGitBranchLockfile: opts.useGitBranchLockfile, mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles, diff --git a/pnpm11/installing/context/test/index.ts b/pnpm11/installing/context/test/index.ts index 65f493efb5..71edfa3e6e 100644 --- a/pnpm11/installing/context/test/index.ts +++ b/pnpm11/installing/context/test/index.ts @@ -19,7 +19,7 @@ const DEFAULT_OPTIONS: GetContextOptions = { lockfileDir: path.join(import.meta.dirname, 'lockfile'), nodeLinker: 'isolated', hoistPattern: ['*'], - registries: { default: '' }, + registriesByScope: { default: '' }, useLockfile: false, include: { dependencies: true, diff --git a/pnpm11/installing/deps-installer/src/getPeerDependencyIssues.ts b/pnpm11/installing/deps-installer/src/getPeerDependencyIssues.ts index 278a25c80e..727fbc7d4d 100644 --- a/pnpm11/installing/deps-installer/src/getPeerDependencyIssues.ts +++ b/pnpm11/installing/deps-installer/src/getPeerDependencyIssues.ts @@ -1,4 +1,4 @@ -import { DEFAULT_REGISTRIES } from '@pnpm/config.normalize-registries' +import { DEFAULT_REGISTRIES_BY_SCOPE } from '@pnpm/config.normalize-registries' import { parseOverrides } from '@pnpm/config.parse-overrides' import { createDependencyOverrider, createReadPackageHook } from '@pnpm/hooks.read-package-hook' import { getContext, type GetContextOptions, type ProjectOptions } from '@pnpm/installing.context' @@ -40,7 +40,7 @@ export async function getPeerDependencyIssues ( extraBinPaths: [], lockfileDir, nodeLinker: opts.nodeLinker ?? 'isolated', - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, useLockfile: true, allProjects: projects, ...opts, @@ -89,7 +89,7 @@ export async function getPeerDependencyIssues ( preferWorkspacePackages: opts.preferWorkspacePackages, preferredVersions, preserveWorkspaceProtocol: false, - registries: ctx.registries, + registriesByScope: ctx.registriesByScope, saveWorkspaceProtocol: false, // this doesn't matter in our case. We won't write changes to package.json files storeController: opts.storeController, tag: 'latest', diff --git a/pnpm11/installing/deps-installer/src/install/extendInstallOptions.ts b/pnpm11/installing/deps-installer/src/install/extendInstallOptions.ts index ad7a0aa0e7..ad733f1d9c 100644 --- a/pnpm11/installing/deps-installer/src/install/extendInstallOptions.ts +++ b/pnpm11/installing/deps-installer/src/install/extendInstallOptions.ts @@ -1,7 +1,7 @@ import path from 'node:path' import type { Catalogs } from '@pnpm/catalogs.types' -import { DEFAULT_REGISTRIES, normalizeRegistries } from '@pnpm/config.normalize-registries' +import { DEFAULT_REGISTRIES_BY_SCOPE, normalizeRegistriesByScope } from '@pnpm/config.normalize-registries' import { parseOverrides, type VersionOverride } from '@pnpm/config.parse-overrides' import { WANTED_LOCKFILE } from '@pnpm/constants' import { PnpmError } from '@pnpm/error' @@ -13,22 +13,12 @@ import type { IncludedDependencies } from '@pnpm/installing.modules-yaml' import type { LockfileObject } from '@pnpm/lockfile.fs' import type { PreferredVersions, ResolutionPolicyViolation, ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base' import type { StoreController } from '@pnpm/store.controller-types' -import type { - AllowedDeprecatedVersions, - PackageExtension, - PackageVulnerabilityAudit, - PeerDependencyRules, - ReadPackageHook, - Registries, - RegistryConfig, - SupportedArchitectures, - TrustPolicy, -} from '@pnpm/types' +import type { AllowedDeprecatedVersions, PackageExtension, PackageVulnerabilityAudit, PeerDependencyRules, ReadPackageHook, RegistryConfig, RegistryContext, SupportedArchitectures, TrustPolicy } from '@pnpm/types' import { pnpmPkgJson } from '../pnpmPkgJson.js' import type { ReporterFunction } from '../types.js' -export interface StrictInstallOptions { +export interface StrictInstallOptions extends RegistryContext { autoConfirmAllPrompts: boolean autoInstallPeers: boolean autoInstallPeersFromHighestMatch: boolean @@ -123,8 +113,6 @@ export interface StrictInstallOptions { childConcurrency: number userAgent: string unsafePerm: boolean - registries: Registries - namedRegistries?: Record tag: string overrides: Record ownLifecycleHooksStdio: 'inherit' | 'pipe' @@ -348,7 +336,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => { pruneLockfileImporters: false, pruneStore: false, configByUri: {}, - registries: DEFAULT_REGISTRIES, + registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE, resolutionMode: 'highest', saveWorkspaceProtocol: 'rolling', scriptsPrependNodePath: false, @@ -474,7 +462,7 @@ export function extendOptions ( if (extendedOpts.userAgent.startsWith('npm/')) { extendedOpts.userAgent = `${extendedOpts.packageManager.name}/${extendedOpts.packageManager.version} ${extendedOpts.userAgent}` } - extendedOpts.registries = normalizeRegistries(extendedOpts.registries) + extendedOpts.registriesByScope = normalizeRegistriesByScope(extendedOpts.registriesByScope) if (extendedOpts.enableGlobalVirtualStore) { if (extendedOpts.virtualStoreDir == null) { extendedOpts.virtualStoreDir = path.join(extendedOpts.storeDir, 'links') diff --git a/pnpm11/installing/deps-installer/src/install/index.ts b/pnpm11/installing/deps-installer/src/install/index.ts index 1980759d83..1b63659d7f 100644 --- a/pnpm11/installing/deps-installer/src/install/index.ts +++ b/pnpm11/installing/deps-installer/src/install/index.ts @@ -8,6 +8,7 @@ import { mergeCatalogs } from '@pnpm/catalogs.config' import { parseCatalogProtocol } from '@pnpm/catalogs.protocol-parser' import { type CatalogResultMatcher, matchCatalogResolveResult, resolveFromCatalog } from '@pnpm/catalogs.resolver' import type { Catalogs } from '@pnpm/catalogs.types' +import { toRegistryDeclarations } from '@pnpm/config.normalize-registries' import { parseOverrides } from '@pnpm/config.parse-overrides' import { createPackageVersionPolicyOrThrow, getPublishedByPolicy } from '@pnpm/config.version-policy' import { @@ -44,7 +45,7 @@ import { type WantedDependency, } from '@pnpm/installing.deps-resolver' import { extendProjectsWithTargetDirs, headlessInstall, type InstallationResultStats } from '@pnpm/installing.deps-restorer' -import { readModulesManifest, type StrictModules, writeModulesManifest } from '@pnpm/installing.modules-yaml' +import { type Modules, readModulesManifest, writeModulesManifest } from '@pnpm/installing.modules-yaml' import { type CatalogSnapshots, cleanGitBranchLockfiles, @@ -393,7 +394,7 @@ export async function mutateModules ( include: opts.include, lockfileDir: opts.lockfileDir, modulesDir: opts.modulesDir ?? 'node_modules', - registries: opts.registries, + registriesByScope: opts.registriesByScope, storeDir: opts.storeDir, virtualStoreDir: ctx.virtualStoreDir, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, @@ -502,7 +503,7 @@ export async function mutateModules ( existsNonEmptyWantedLockfile: ctx.existsNonEmptyWantedLockfile, lockfileDir: ctx.lockfileDir, storeDir: ctx.storeDir, - registries: ctx.registries, + registries: ctx.registriesByScope, }) } } @@ -594,7 +595,7 @@ export async function mutateModules ( // writeModulesManifest converts ignoredBuilds to an array before // serializing, so a Set is fine here. writtenManifest.ignoredBuilds = ignoredBuilds - await writeModulesManifest(ctx.rootModulesDir, writtenManifest as StrictModules) + await writeModulesManifest(ctx.rootModulesDir, writtenManifest as Modules) } } ignoredScriptsLogger.debug({ @@ -871,7 +872,7 @@ export async function mutateModules ( lockfileDir: opts.lockfileDir, lockfileIncludeTarballUrl: opts.lockfileIncludeTarballUrl, readPackageHook: opts.readPackageHook, - registries: ctx.registries, + registriesByScope: ctx.registriesByScope, requestPackage: opts.storeController.requestPackage, trustPolicy: opts.trustPolicy, trustPolicyExclude: opts.trustPolicyExclude @@ -1959,8 +1960,9 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { preferWorkspacePackages: opts.preferWorkspacePackages, preferredVersions, preserveWorkspaceProtocol: opts.preserveWorkspaceProtocol, - registries: ctx.registries, - namedRegistries: opts.namedRegistries, + registriesByScope: ctx.registriesByScope, + registriesByPrefix: opts.registriesByPrefix, + registryOptionsByUrl: opts.registryOptionsByUrl, resolutionMode: opts.resolutionMode, saveWorkspaceProtocol: opts.saveWorkspaceProtocol, storeController: opts.storeController, @@ -2107,7 +2109,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { pruneStore: opts.pruneStore, pruneVirtualStore: opts.pruneVirtualStore, publicHoistPattern: ctx.publicHoistPattern, - registries: ctx.registries, + registriesByScope: ctx.registriesByScope, rootModulesDir: ctx.rootModulesDir, sideEffectsCacheRead: opts.sideEffectsCacheRead, symlink: opts.symlink, @@ -2155,7 +2157,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { lockfileDir: ctx.lockfileDir, virtualStoreDir: ctx.virtualStoreDir, virtualStoreDirMaxLength: ctx.virtualStoreDirMaxLength, - registries: ctx.registries, + registriesByScope: ctx.registriesByScope, }) } @@ -2331,7 +2333,6 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { prunedAt: opts.pruneVirtualStore || ctx.modulesFile == null ? new Date().toUTCString() : ctx.modulesFile.prunedAt, - registries: ctx.registries, skipped: Array.from(ctx.skipped), storeDir: ctx.storeDir, virtualStoreDir: ctx.virtualStoreDir, @@ -3067,8 +3068,8 @@ async function installViaPnprServer ( devDependencies: projectsList ? undefined : manifest.devDependencies, optionalDependencies: projectsList ? undefined : manifest.optionalDependencies, projects: projectsList, - registry: opts.registries?.default, - namedRegistries: opts.namedRegistries, + registry: opts.registriesByScope?.default, + registries: toRegistryDeclarations(opts), authorization: pnprAuthorization, overrides: opts.overrides, // The reconstructed workspace the server builds from this request has no diff --git a/pnpm11/installing/deps-installer/src/install/link.ts b/pnpm11/installing/deps-installer/src/install/link.ts index 82bbc8e647..2e9bfbc319 100644 --- a/pnpm11/installing/deps-installer/src/install/link.ts +++ b/pnpm11/installing/deps-installer/src/install/link.ts @@ -30,7 +30,7 @@ import type { DepPath, HoistedDependencies, ProjectId, - Registries, + RegistriesByScope, SupportedArchitectures, } from '@pnpm/types' import { symlinkAllModules } from '@pnpm/worker' @@ -64,7 +64,7 @@ export interface LinkPackagesOptions { outdatedDependencies: Record pruneStore: boolean pruneVirtualStore: boolean - registries: Registries + registriesByScope: RegistriesByScope rootModulesDir: string sideEffectsCacheRead: boolean symlink: boolean @@ -137,7 +137,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe const projectIds = projects.map(({ id }) => id) const filterOpts = { include: opts.include, - registries: opts.registries, + registriesByScope: opts.registriesByScope, skipped: opts.skipped, skipRuntimes: opts.skipRuntimes, } diff --git a/pnpm11/installing/deps-installer/src/install/tryFastUpdateOverrides.ts b/pnpm11/installing/deps-installer/src/install/tryFastUpdateOverrides.ts index a5b6f7af2c..bafd62f92c 100644 --- a/pnpm11/installing/deps-installer/src/install/tryFastUpdateOverrides.ts +++ b/pnpm11/installing/deps-installer/src/install/tryFastUpdateOverrides.ts @@ -1,3 +1,4 @@ +import { getRegistryServerType } from '@pnpm/config.normalize-registries' import type { VersionOverride } from '@pnpm/config.parse-overrides' import * as dp from '@pnpm/deps.path' import type { @@ -13,7 +14,9 @@ import type { PackageManifest, PackageVersionPolicy, ReadPackageHook, - Registries, + RegistriesByScope, + RegistryOptions, + RegistryServerType, TrustPolicy, } from '@pnpm/types' import { clone, equals } from 'ramda' @@ -51,7 +54,8 @@ export type FastRewriteOptions = ResolverPolicyOptions & { lockfileIncludeTarballUrl?: boolean isLockfileUpToDate: (lockfile: LockfileObject) => Promise readPackageHook?: ReadPackageHook - registries: Registries + registriesByScope: RegistriesByScope + registryOptionsByUrl?: Record requestPackage: RequestPackageFunction verifyLockfile?: (lockfile: LockfileObject) => Promise } @@ -120,7 +124,8 @@ export async function applyFastRewrite ( const packages = rewritePackages(lockfile.packages ?? {}, { lockfileIncludeTarballUrl: opts.lockfileIncludeTarballUrl, manifests, - registries: opts.registries, + registriesByScope: opts.registriesByScope, + registryOptionsByUrl: opts.registryOptionsByUrl, rewriteContext, }) if (packages == null) return false @@ -421,7 +426,8 @@ function rewritePackages ( manifest: PackageManifest resolution: Awaited>['body']['resolution'] }> - registries: Registries + registriesByScope: RegistriesByScope + registryOptionsByUrl?: Record rewriteContext: RewriteContext } ): Record | null { @@ -458,12 +464,14 @@ function rewritePackages ( }) if (dependencies === null || optionalDependencies === null) return null + const registry = dp.getRegistryByPackageName(opts.registriesByScope, name) const newSnapshot = createPackageSnapshot(oldSnapshot, { dependencies, lockfileIncludeTarballUrl: opts.lockfileIncludeTarballUrl, manifest: resolved.manifest, optionalDependencies, - registry: dp.getRegistryByPackageName(opts.registries, name), + registry, + serverType: getRegistryServerType(opts, registry), resolution: resolved.resolution, }) const existingSnapshot = packages[newDepPath] @@ -560,6 +568,7 @@ function createPackageSnapshot ( manifest: PackageManifest optionalDependencies?: ResolvedDependencies registry: string + serverType?: RegistryServerType resolution: Awaited>['body']['resolution'] } ): PackageSnapshot { @@ -567,7 +576,11 @@ function createPackageSnapshot ( resolution: toLockfileResolution({ name: opts.manifest.name, version: opts.manifest.version, - }, opts.resolution, opts.registry, opts.lockfileIncludeTarballUrl), + }, opts.resolution, { + registry: opts.registry, + serverType: opts.serverType, + lockfileIncludeTarballUrl: opts.lockfileIncludeTarballUrl, + }), } if (opts.dependencies != null) snapshot.dependencies = opts.dependencies if (opts.optionalDependencies != null) snapshot.optionalDependencies = opts.optionalDependencies diff --git a/pnpm11/installing/deps-installer/src/install/validateModules.ts b/pnpm11/installing/deps-installer/src/install/validateModules.ts index fca036e4fe..15cd22a599 100644 --- a/pnpm11/installing/deps-installer/src/install/validateModules.ts +++ b/pnpm11/installing/deps-installer/src/install/validateModules.ts @@ -12,7 +12,7 @@ import { logger } from '@pnpm/logger' import { DEPENDENCIES_FIELDS, type ProjectRootDir, - type Registries, + type RegistriesByScope, } from '@pnpm/types' import { rimraf } from '@zkochan/rimraf' import { isSubdir } from 'is-subdir' @@ -45,7 +45,7 @@ export async function validateModules ( include?: IncludedDependencies lockfileDir: string modulesDir: string - registries: Registries + registriesByScope: RegistriesByScope storeDir: string virtualStoreDir: string virtualStoreDirMaxLength: number diff --git a/pnpm11/installing/deps-installer/test/breakingChanges.ts b/pnpm11/installing/deps-installer/test/breakingChanges.ts index 03933fd0db..8ae54e9c6f 100644 --- a/pnpm11/installing/deps-installer/test/breakingChanges.ts +++ b/pnpm11/installing/deps-installer/test/breakingChanges.ts @@ -72,7 +72,7 @@ test('forced modules repair never follows a modules directory symlink', async () forceNewModules: true, lockfileDir: projectRoot, modulesDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, storeDir: opts.storeDir, virtualStoreDir: path.join(modulesDir, '.pnpm'), virtualStoreDirMaxLength: 2, diff --git a/pnpm11/installing/deps-installer/test/install/auth.ts b/pnpm11/installing/deps-installer/test/install/auth.ts index f530a3c09f..0ed4e237a9 100644 --- a/pnpm11/installing/deps-installer/test/install/auth.ts +++ b/pnpm11/installing/deps-installer/test/install/auth.ts @@ -119,7 +119,7 @@ test('installing a package that need authentication, using password', async () = [registry.replace(/^https?:/, '')]: { '@': { basicAuth: BASIC_AUTH_CREDENTIALS } }, } await addDependenciesToPackage({}, ['@pnpm.e2e/needs-auth'], testDefaults({ - registries: { default: registry }, + registriesByScope: { default: registry }, }, { configByUri, registry, @@ -144,7 +144,7 @@ test('a scoped package that need authentication specific to scope', async () => [`//localhost:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } }, } let opts = testDefaults({ - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', '@private': `http://localhost:${REGISTRY_MOCK_PORT}/`, }, @@ -164,7 +164,7 @@ test('a scoped package that need authentication specific to scope', async () => // Recreating options to have a new storeController with clean cache opts = testDefaults({ - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', '@private': `http://localhost:${REGISTRY_MOCK_PORT}/`, }, @@ -187,7 +187,7 @@ test('a scoped package that need legacy authentication specific to scope', async [registry.replace(/^https?:/, '')]: { '@': { basicAuth: BASIC_AUTH_CREDENTIALS } }, } let opts = testDefaults({ - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', '@private': registry, }, @@ -207,7 +207,7 @@ test('a scoped package that need legacy authentication specific to scope', async // Recreating options to have a new storeController with clean cache opts = testDefaults({ - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', '@private': registry, }, @@ -236,7 +236,7 @@ skipOnNode17('a package that need authentication reuses authorization tokens for [`//127.0.0.1:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } }, } await addDependenciesToPackage({}, ['@pnpm.e2e/needs-auth'], testDefaults({ - registries: { + registriesByScope: { default: `http://127.0.0.1:${REGISTRY_MOCK_PORT}`, }, }, { @@ -262,7 +262,7 @@ skipOnNode17('a package that need authentication reuses authorization tokens for [`//127.0.0.1:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } }, } let opts = testDefaults({ - registries: { + registriesByScope: { default: `http://127.0.0.1:${REGISTRY_MOCK_PORT}`, }, }, { @@ -280,7 +280,7 @@ skipOnNode17('a package that need authentication reuses authorization tokens for // Recreating options to clean store cache opts = testDefaults({ - registries: { + registriesByScope: { default: `http://127.0.0.1:${REGISTRY_MOCK_PORT}`, }, }, { diff --git a/pnpm11/installing/deps-installer/test/install/catalogVersionFastUpdate.ts b/pnpm11/installing/deps-installer/test/install/catalogVersionFastUpdate.ts index 99871bf819..edf71f079a 100644 --- a/pnpm11/installing/deps-installer/test/install/catalogVersionFastUpdate.ts +++ b/pnpm11/installing/deps-installer/test/install/catalogVersionFastUpdate.ts @@ -32,7 +32,7 @@ function updateOptions (childRange: string, catalogSpecifier = '2.0.0') { catalogs: { default: { target: catalogSpecifier } }, parsedOverrides: [], lockfileDir: '/project', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, requestPackage: async () => ({ body: { id: 'target@2.0.0', diff --git a/pnpm11/installing/deps-installer/test/install/composedFastUpdate.ts b/pnpm11/installing/deps-installer/test/install/composedFastUpdate.ts index 55a828f9c0..e81db0e9fd 100644 --- a/pnpm11/installing/deps-installer/test/install/composedFastUpdate.ts +++ b/pnpm11/installing/deps-installer/test/install/composedFastUpdate.ts @@ -248,7 +248,7 @@ test('an override moving a cataloged package falls back', async () => { parsedOverrides: [{ selector: 'bar', newBareSpecifier: '2.1.0', targetPkg: { name: 'bar' } }], isLockfileUpToDate: async () => true, lockfileDir: '/test', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, requestPackage: (() => { throw new Error('the fast path must not resolve anything') }) as never, diff --git a/pnpm11/installing/deps-installer/test/install/cyclicPeerDeterminism.ts b/pnpm11/installing/deps-installer/test/install/cyclicPeerDeterminism.ts index fc337e3a8f..7fc3482d97 100644 --- a/pnpm11/installing/deps-installer/test/install/cyclicPeerDeterminism.ts +++ b/pnpm11/installing/deps-installer/test/install/cyclicPeerDeterminism.ts @@ -54,7 +54,7 @@ test('cyclic transitive peer dependencies resolve deterministically across insta rootDir: lockfileDir as ProjectRootDir, }] - const registryUrl = options.registries.default.replace(/\/$/, '') + const registryUrl = options.registriesByScope.default.replace(/\/$/, '') function makeMeta (name: string, deps: Record, peerDeps: Record): PackageMeta { return { @@ -69,7 +69,7 @@ test('cyclic transitive peer dependencies resolve deterministically across insta // Resolver only reads metadata when lockfileOnly is true, so the // shasum value is never checked against a tarball. shasum: '0000000000000000000000000000000000000000', - tarball: `${options.registries.default}/${encodeURIComponent(name)}-1.0.0.tgz`, + tarball: `${options.registriesByScope.default}/${encodeURIComponent(name)}-1.0.0.tgz`, }, }, }, @@ -163,7 +163,7 @@ test('aliased install with a transitive mutual-peer cycle should not hang', asyn rootDir: lockfileDir as ProjectRootDir, }] - const registryUrl = options.registries.default.replace(/\/$/, '') + const registryUrl = options.registriesByScope.default.replace(/\/$/, '') function makeMeta (name: string, deps: Record, peerDeps: Record): PackageMeta { return { @@ -176,7 +176,7 @@ test('aliased install with a transitive mutual-peer cycle should not hang', asyn peerDependencies: peerDeps, dist: { shasum: '0000000000000000000000000000000000000000', - tarball: `${options.registries.default}/${encodeURIComponent(name)}-1.0.0.tgz`, + tarball: `${options.registriesByScope.default}/${encodeURIComponent(name)}-1.0.0.tgz`, }, }, }, @@ -256,7 +256,7 @@ test('transitivePeerDependencies propagate through regular dep cycles', async () rootDir: lockfileDir as ProjectRootDir, }] - const registryUrl = options.registries.default.replace(/\/$/, '') + const registryUrl = options.registriesByScope.default.replace(/\/$/, '') function makeMeta (name: string, deps: Record, peerDeps?: Record, peerMeta?: Record): PackageMeta { return { @@ -270,7 +270,7 @@ test('transitivePeerDependencies propagate through regular dep cycles', async () ...(peerMeta ? { peerDependenciesMeta: peerMeta } : {}), dist: { shasum: '0000000000000000000000000000000000000000', - tarball: `${options.registries.default}/${encodeURIComponent(name)}-1.0.0.tgz`, + tarball: `${options.registriesByScope.default}/${encodeURIComponent(name)}-1.0.0.tgz`, }, }, }, diff --git a/pnpm11/installing/deps-installer/test/install/deepRecursive.ts b/pnpm11/installing/deps-installer/test/install/deepRecursive.ts index a6134daa3a..88fd609049 100644 --- a/pnpm11/installing/deps-installer/test/install/deepRecursive.ts +++ b/pnpm11/installing/deps-installer/test/install/deepRecursive.ts @@ -18,9 +18,9 @@ test('a package with a huge amount of circular dependencies and many peer depend testDefaults({ fastUnpack: true, lockfileOnly: true, - registries, + registriesByScope: registries, strictPeerDependencies: false, - }, { registries }) + }, { registriesByScope: registries }) ) expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy() diff --git a/pnpm11/installing/deps-installer/test/install/lifecycleScripts.ts b/pnpm11/installing/deps-installer/test/install/lifecycleScripts.ts index 6ca462ae5c..8a19b2088d 100644 --- a/pnpm11/installing/deps-installer/test/install/lifecycleScripts.ts +++ b/pnpm11/installing/deps-installer/test/install/lifecycleScripts.ts @@ -418,8 +418,8 @@ test('allowBuilds does not run lifecycle scripts for direct tarball identities', const { updatedManifest: manifest } = await addDependenciesToPackage({}, [tarball], testDefaults({ fastUnpack: false, allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true }, - registries, - }, { registries })) + registriesByScope: registries, + }, { registriesByScope: registries })) expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBe(false) expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBe(false) @@ -429,8 +429,8 @@ test('allowBuilds does not run lifecycle scripts for direct tarball identities', await install(manifest, testDefaults({ fastUnpack: false, allowBuilds: { [depPath]: true }, - registries, - }, { registries })) + registriesByScope: registries, + }, { registriesByScope: registries })) expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBe(true) expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBe(true) @@ -450,8 +450,8 @@ test('surfaces a tarball artifact as an ignored build when its depPath approval const { updatedManifest: manifest } = await addDependenciesToPackage({}, [tarball], testDefaults({ fastUnpack: false, allowBuilds: { [depPath]: true }, - registries, - }, { registries })) + registriesByScope: registries, + }, { registriesByScope: registries })) expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBe(true) // Reinstalling with the approval removed must surface the artifact as an @@ -461,8 +461,8 @@ test('surfaces a tarball artifact as an ignored build when its depPath approval fastUnpack: false, frozenLockfile: true, allowBuilds: {}, - registries, - }, { registries })) + registriesByScope: registries, + }, { registriesByScope: registries })) expect(Array.from(ignoredBuilds ?? [])).toContain(depPath) }) diff --git a/pnpm11/installing/deps-installer/test/install/misc.ts b/pnpm11/installing/deps-installer/test/install/misc.ts index 885135acfe..3db2639a0d 100644 --- a/pnpm11/installing/deps-installer/test/install/misc.ts +++ b/pnpm11/installing/deps-installer/test/install/misc.ts @@ -1540,7 +1540,7 @@ test('two dependencies have the same version and name. The only difference is th }, testDefaults({ fastUnpack: false, }, { - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, })) diff --git a/pnpm11/installing/deps-installer/test/install/optionalDependencies.ts b/pnpm11/installing/deps-installer/test/install/optionalDependencies.ts index a0efffc650..5508aa6756 100644 --- a/pnpm11/installing/deps-installer/test/install/optionalDependencies.ts +++ b/pnpm11/installing/deps-installer/test/install/optionalDependencies.ts @@ -159,7 +159,7 @@ test('skip optional dependencies whose names declare unsupported platforms when '@pnpm.e2e/has-many-optional-deps': '1.0.0', }, }, testDefaults({ - registries: { default: registryProxy }, + registriesByScope: { default: registryProxy }, supportedArchitectures: { os: ['darwin'], cpu: ['arm64'] }, })) } finally { @@ -259,7 +259,7 @@ test('fail on an optional dependency that cannot be resolved when the lockfile h // stale packuments even though the lockfile already pins the versions. await expect(install(manifest, testDefaults({ dedupe: true, - registries: { default: registryProxy }, + registriesByScope: { default: registryProxy }, }))).rejects.toMatchObject({ code: expect.stringMatching(/^ERR_PNPM_NO_(MATCHING_VERSION|VERSIONS)$/), hint: expect.stringContaining('the lockfile contains a resolution for it'), diff --git a/pnpm11/installing/deps-installer/test/install/overrideRangeFastUpdate.ts b/pnpm11/installing/deps-installer/test/install/overrideRangeFastUpdate.ts index f6aac6e5f8..9c35d7bb85 100644 --- a/pnpm11/installing/deps-installer/test/install/overrideRangeFastUpdate.ts +++ b/pnpm11/installing/deps-installer/test/install/overrideRangeFastUpdate.ts @@ -2,11 +2,11 @@ import { expect, jest, test } from '@jest/globals' import { LOCKFILE_VERSION } from '@pnpm/constants' import type { LockfileObject } from '@pnpm/lockfile.fs' import type { RequestPackageFunction } from '@pnpm/store.controller-types' -import type { DepPath, ProjectId, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, RegistriesByScope } from '@pnpm/types' import { tryFastUpdateOverrides } from '../../src/install/tryFastUpdateOverrides.js' -const registries: Registries = { default: 'https://registry.npmjs.org/' } +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/' } /** * Two importers hold `foo` at 1.0.0 and 1.2.0. The registry also has 1.3.0, @@ -38,7 +38,7 @@ function opts (newValue: string, requestPackage: RequestPackageFunction) { lockfileDir: '/test', overrides: { foo: newValue }, parsedOverrides: [{ selector: 'foo', newBareSpecifier: newValue, targetPkg: { name: 'foo' } }], - registries, + registriesByScope, requestPackage, isLockfileUpToDate: async () => true, } as never @@ -128,7 +128,7 @@ function parentScopedOpts (requestPackage: RequestPackageFunction) { targetPkg: { name: 'target' }, parentPkg: { name: 'parent' }, }], - registries, + registriesByScope, requestPackage, isLockfileUpToDate: async () => true, } as never diff --git a/pnpm11/installing/deps-installer/test/install/patchedDependenciesFastUpdate.ts b/pnpm11/installing/deps-installer/test/install/patchedDependenciesFastUpdate.ts index 8a09e4363b..18b697afe3 100644 --- a/pnpm11/installing/deps-installer/test/install/patchedDependenciesFastUpdate.ts +++ b/pnpm11/installing/deps-installer/test/install/patchedDependenciesFastUpdate.ts @@ -330,7 +330,7 @@ test('a removal override that leaves a patch unused falls back', async () => { parsedOverrides: [{ selector: 'victim', newBareSpecifier: '-', targetPkg: { name: 'victim' } }], isLockfileUpToDate: async () => true, lockfileDir: '/test', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, requestPackage: (() => { throw new Error('a removal resolves nothing') }) as never, diff --git a/pnpm11/installing/deps-installer/test/install/prereleaseWeights.ts b/pnpm11/installing/deps-installer/test/install/prereleaseWeights.ts index dff6ea9b7f..0a9ec52411 100644 --- a/pnpm11/installing/deps-installer/test/install/prereleaseWeights.ts +++ b/pnpm11/installing/deps-installer/test/install/prereleaseWeights.ts @@ -68,12 +68,12 @@ test('prerelease specifiers do not cause not-yet-used version to be resolved', a name, version: '1.1.0-beta', // Generated locally through: echo '1.1.0-beta' | sha1sum - dist: { shasum: '7957736c00bc1e5a875e5e4f8f48d8f5a3830866', tarball: `${options.registries.default}/${name}-1.1.0-beta.tgz` }, + dist: { shasum: '7957736c00bc1e5a875e5e4f8f48d8f5a3830866', tarball: `${options.registriesByScope.default}/${name}-1.1.0-beta.tgz` }, }, '1.2.0-beta': { name, version: '1.2.0-beta', - dist: { shasum: '50c0586b05b59205f39610d63cc38ea04954182c', tarball: `${options.registries.default}/${name}-1.2.0-beta.tgz` }, + dist: { shasum: '50c0586b05b59205f39610d63cc38ea04954182c', tarball: `${options.registriesByScope.default}/${name}-1.2.0-beta.tgz` }, }, }, 'dist-tags': { @@ -82,7 +82,7 @@ test('prerelease specifiers do not cause not-yet-used version to be resolved', a } await setupMockAgent() - const registryUrl = options.registries.default.replace(/\/$/, '') + const registryUrl = options.registriesByScope.default.replace(/\/$/, '') // cspell:disable-next-line const metadataPath = '/@pnpm.e2e%2Fprerelease' @@ -108,7 +108,7 @@ test('prerelease specifiers do not cause not-yet-used version to be resolved', a meta.versions['1.2.0'] = { name, version: '1.2.0', - dist: { shasum: 'f95c23882c82328c872ac94af630c49ae57f37bb', tarball: `${options.registries.default}/${name}-1.2.0.tgz` }, + dist: { shasum: 'f95c23882c82328c872ac94af630c49ae57f37bb', tarball: `${options.registriesByScope.default}/${name}-1.2.0.tgz` }, } meta['dist-tags'].latest = '1.2.0' diff --git a/pnpm11/installing/deps-installer/test/install/tryFastUpdateOverrides.test.ts b/pnpm11/installing/deps-installer/test/install/tryFastUpdateOverrides.test.ts index fdccd609e4..372ef0332a 100644 --- a/pnpm11/installing/deps-installer/test/install/tryFastUpdateOverrides.test.ts +++ b/pnpm11/installing/deps-installer/test/install/tryFastUpdateOverrides.test.ts @@ -2,11 +2,11 @@ import { expect, jest, test } from '@jest/globals' import { LOCKFILE_VERSION } from '@pnpm/constants' import type { LockfileObject } from '@pnpm/lockfile.fs' import type { RequestPackageFunction } from '@pnpm/store.controller-types' -import type { DepPath, ProjectId, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, RegistriesByScope } from '@pnpm/types' import { tryFastUpdateOverrides } from '../../src/install/tryFastUpdateOverrides.js' -const registries: Registries = { default: 'https://registry.npmjs.org/' } +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/' } /** An override bumping `foo` from 1.0.0 to 2.0.0, in the shape the fast path accepts. */ const overrides = { foo: '2.0.0' } @@ -40,7 +40,7 @@ function opts (requestPackage: RequestPackageFunction) { lockfileDir: '/test', overrides, parsedOverrides, - registries, + registriesByScope, requestPackage, isLockfileUpToDate: async () => true, } as never diff --git a/pnpm11/installing/deps-installer/test/lockfile.ts b/pnpm11/installing/deps-installer/test/lockfile.ts index 6994f8418b..010de8a569 100644 --- a/pnpm11/installing/deps-installer/test/lockfile.ts +++ b/pnpm11/installing/deps-installer/test/lockfile.ts @@ -1142,7 +1142,7 @@ test('tarball domain differs from registry domain', async () => { lockfileOnly: true, save: true, }, { - registries: { + registriesByScope: { default: 'https://registry.example.com', }, }) @@ -1200,7 +1200,7 @@ test('tarball installed through non-standard URL endpoint from the registry doma ], testDefaults({ fastUnpack: false, lockfileOnly: true, - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, save: true, diff --git a/pnpm11/installing/deps-installer/test/namedRegistriesLockfileFormat.ts b/pnpm11/installing/deps-installer/test/namedRegistriesLockfileFormat.ts index 0420a07415..fdae91c252 100644 --- a/pnpm11/installing/deps-installer/test/namedRegistriesLockfileFormat.ts +++ b/pnpm11/installing/deps-installer/test/namedRegistriesLockfileFormat.ts @@ -9,7 +9,7 @@ import { readYamlFileSync } from 'read-yaml-file' import { testDefaults } from './utils/index.js' const NAMED_REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}/` -const namedRegistries = { work: NAMED_REGISTRY } +const registriesByPrefix = { work: NAMED_REGISTRY } function readLockfile (): LockfileFile { return readYamlFileSync(WANTED_LOCKFILE) @@ -20,7 +20,7 @@ function installFoo (): Promise { dependencies: { '@pnpm.e2e/foo': 'work:1.0.0', }, - }, testDefaults({ namedRegistries }, { namedRegistries })) + }, testDefaults({ registriesByPrefix }, { registriesByPrefix })) } test('a named-registry dependency is keyed registry-qualified', async () => { @@ -62,7 +62,7 @@ test('the same package resolved from two registries gets one entry per registry' '@pnpm.e2e/foo': '1.0.0', 'foo-from-work': 'work:@pnpm.e2e/foo@1.0.0', }, - }, testDefaults({ namedRegistries }, { namedRegistries })) + }, testDefaults({ registriesByPrefix }, { registriesByPrefix })) const lockfile = readLockfile() const packageKeys = Object.keys(lockfile.packages ?? {}) @@ -101,9 +101,9 @@ test.each([ '@pnpm.e2e/peer-c': '1.0.0', }, }, testDefaults({ - namedRegistries, + registriesByPrefix, dedupePeers, - }, { namedRegistries })) + }, { registriesByPrefix })) const snapshotKeys = Object.keys(readLockfile().snapshots ?? {}) const abcKey = snapshotKeys.find((key) => key.startsWith('@pnpm.e2e/abc@')) diff --git a/pnpm11/installing/deps-installer/test/utils/testDefaults.ts b/pnpm11/installing/deps-installer/test/utils/testDefaults.ts index 7a85c5da0e..9d5cb3cc57 100644 --- a/pnpm11/installing/deps-installer/test/utils/testDefaults.ts +++ b/pnpm11/installing/deps-installer/test/utils/testDefaults.ts @@ -4,7 +4,7 @@ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base' import type { StoreController } from '@pnpm/store.controller-types' import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock' import { createTempStore } from '@pnpm/testing.temp-store' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' const registry = `http://localhost:${REGISTRY_MOCK_PORT}/` @@ -13,7 +13,7 @@ export function testDefaults ( fastUnpack?: boolean storeDir?: string prefix?: string - registries?: Registries + registriesByScope?: RegistriesByScope customResolvers?: CustomResolver[] customFetchers?: CustomFetcher[] minimumReleaseAge?: number @@ -26,7 +26,7 @@ export function testDefaults ( ): InstallOptions & { cacheDir: string - registries: Registries + registriesByScope: RegistriesByScope storeController: StoreController storeDir: string resolutionVerifiers: ResolutionVerifier[] @@ -43,7 +43,7 @@ export function testDefaults ( const { storeController, storeDir, cacheDir, resolutionVerifiers } = createTempStore({ ...opts, clientOptions: { - ...(opts?.registries != null ? { registries: opts.registries } : {}), + ...(opts?.registriesByScope != null ? { registriesByScope: opts.registriesByScope } : {}), customResolvers: opts?.customResolvers, customFetchers: opts?.customFetchers, ...policyClientOptions, @@ -60,7 +60,7 @@ export function testDefaults ( }) const result = { cacheDir, - registries: { + registriesByScope: { default: registry, }, storeController, @@ -71,7 +71,7 @@ export function testDefaults ( InstallOptions & { cacheDir: string - registries: Registries + registriesByScope: RegistriesByScope storeController: StoreController storeDir: string resolutionVerifiers: ResolutionVerifier[] diff --git a/pnpm11/installing/deps-resolver/src/index.ts b/pnpm11/installing/deps-resolver/src/index.ts index b85378ae70..613798c1fe 100644 --- a/pnpm11/installing/deps-resolver/src/index.ts +++ b/pnpm11/installing/deps-resolver/src/index.ts @@ -1,6 +1,7 @@ import path from 'node:path' import type { Catalogs } from '@pnpm/catalogs.types' +import { pickRegistryContext } from '@pnpm/config.normalize-registries' import { packageManifestLogger, } from '@pnpm/core-loggers' @@ -448,8 +449,7 @@ export async function resolveDependencies ( dependenciesGraph, lockfile: opts.wantedLockfile, prefix: opts.virtualStoreDir, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + ...pickRegistryContext(opts), lockfileIncludeTarballUrl: opts.lockfileIncludeTarballUrl, }) if (time) { diff --git a/pnpm11/installing/deps-resolver/src/resolveDependencies.ts b/pnpm11/installing/deps-resolver/src/resolveDependencies.ts index 59175dd757..cbca6ac455 100644 --- a/pnpm11/installing/deps-resolver/src/resolveDependencies.ts +++ b/pnpm11/installing/deps-resolver/src/resolveDependencies.ts @@ -2,6 +2,7 @@ import path from 'node:path' import util from 'node:util' import { type CatalogResolution, type CatalogResolver, matchCatalogResolveResult } from '@pnpm/catalogs.resolver' +import { pickRegistryContext } from '@pnpm/config.normalize-registries' import { deprecationLogger, progressLogger, @@ -43,19 +44,7 @@ import type { StoreController, } from '@pnpm/store.controller-types' import { lexCompare } from '@pnpm/text.ordinal-comparator' -import type { - AllowBuild, - AllowedDeprecatedVersions, - DepPath, - PackageManifest, - PackageVersionPolicy, - PkgIdWithPatchHash, - RangeSpecStyle, - ReadPackageHook, - Registries, - SupportedArchitectures, - TrustPolicy, -} from '@pnpm/types' +import type { AllowBuild, AllowedDeprecatedVersions, DepPath, PackageManifest, PackageVersionPolicy, PkgIdWithPatchHash, RangeSpecStyle, ReadPackageHook, RegistryContext, SupportedArchitectures, TrustPolicy } from '@pnpm/types' import normalizePath from 'normalize-path' import pDefer from 'p-defer' import { pathExists } from 'path-exists' @@ -161,7 +150,7 @@ export interface ChildrenByParentId { }> } -export interface ResolutionContext { +export interface ResolutionContext extends RegistryContext { allowBuild?: AllowBuild allPeerDepNames: Set autoInstallPeers: boolean @@ -203,8 +192,6 @@ export interface ResolutionContext { engineStrict: boolean nodeVersion?: string pnpmVersion: string - registries: Registries - namedRegistries?: Record namedRegistryPrefixes: readonly string[] resolutionMode?: 'highest' | 'time-based' | 'lowest-direct' virtualStoreDir: string @@ -646,8 +633,7 @@ async function resolveDependenciesOfImporters ( preferredDependencies: importer.options.preferredDependencies, prefix: importer.options.prefix, proceed: importer.options.proceed || ctx.forceFullResolution, - registries: ctx.registries, - namedRegistries: ctx.namedRegistries, + ...pickRegistryContext(ctx), resolvedDependencies: importer.options.resolvedDependencies, }) const postponedResolutionsQueue: PostponedResolutionFunction[] = [] @@ -852,8 +838,7 @@ export async function resolveDependencies ( preferredVersions, prefix: options.prefix, proceed: options.proceed || ctx.forceFullResolution, - registries: ctx.registries, - namedRegistries: ctx.namedRegistries, + ...pickRegistryContext(ctx), resolvedDependencies: options.resolvedDependencies, }) const postponedResolutionsQueue: PostponedResolutionFunction[] = [] @@ -1042,7 +1027,7 @@ async function resolveDependenciesOfDependency ( wantedDepIsLocallyAvailable( ctx.workspacePackages, extendedWantedDep.wantedDependency, - { defaultTag: ctx.defaultTag, registry: ctx.registries.default } + { defaultTag: ctx.defaultTag, registry: ctx.registriesByScope.default } ) ) || ctx.updatedSet.has(extendedWantedDep.infoFromLockfile.name!) @@ -1182,7 +1167,7 @@ function wantedDependencyMatchesUpdateTarget ( ): boolean { const { alias, bareSpecifier } = wantedDependency const spec = alias && bareSpecifier - ? parseBareSpecifier(bareSpecifier, alias, ctx.defaultTag ?? 'latest', ctx.registries.default) + ? parseBareSpecifier(bareSpecifier, alias, ctx.defaultTag ?? 'latest', ctx.registriesByScope.default) : null const name = spec?.name ?? alias return name != null && updateMatching(name, undefined) @@ -1573,13 +1558,11 @@ async function resolveChildren ( function getDepsToResolve ( wantedDependencies: Array, wantedLockfile: LockfileObject, - options: { + options: RegistryContext & { preferredDependencies?: ResolvedDependencies preferredVersions?: PreferredVersions prefix: string proceed: boolean - registries: Registries - namedRegistries?: Record resolvedDependencies?: ResolvedDependencies } ): ExtendedWantedDependency[] { @@ -1639,7 +1622,7 @@ function getDepsToResolve ( reference = preferredDependencies[wantedDependency.alias] } } - const infoFromLockfile = getInfoFromLockfile(wantedLockfile, { registries: options.registries, namedRegistries: options.namedRegistries }, reference, wantedDependency.alias) + const infoFromLockfile = getInfoFromLockfile(wantedLockfile, pickRegistryContext(options), reference, wantedDependency.alias) if ( !proceedAll && ( diff --git a/pnpm11/installing/deps-resolver/src/resolveDependencyTree.ts b/pnpm11/installing/deps-resolver/src/resolveDependencyTree.ts index 80cf3d1f95..35029b011c 100644 --- a/pnpm11/installing/deps-resolver/src/resolveDependencyTree.ts +++ b/pnpm11/installing/deps-resolver/src/resolveDependencyTree.ts @@ -1,25 +1,14 @@ import { resolveFromCatalog } from '@pnpm/catalogs.resolver' import type { Catalogs } from '@pnpm/catalogs.types' +import { pickRegistryContext } from '@pnpm/config.normalize-registries' import { createPackageVersionPolicyOrThrow, getPublishedByPolicy } from '@pnpm/config.version-policy' import type { LockfileObject } from '@pnpm/lockfile.types' import { globalWarn } from '@pnpm/logger' import type { PatchGroupRecord } from '@pnpm/patching.config' -import { BUILTIN_NAMED_REGISTRIES } from '@pnpm/resolving.npm-resolver' +import { BUILTIN_REGISTRIES_BY_PREFIX } from '@pnpm/resolving.npm-resolver' import type { PreferredVersions, Resolution, ResolutionPolicyViolation, WorkspacePackages } from '@pnpm/resolving.resolver-base' import type { StoreController } from '@pnpm/store.controller-types' -import type { - AllowBuild, - AllowedDeprecatedVersions, - PkgResolutionId, - ProjectId, - ProjectManifest, - ProjectRootDir, - RangeSpecStyle, - ReadPackageHook, - Registries, - SupportedArchitectures, - TrustPolicy, -} from '@pnpm/types' +import type { AllowBuild, AllowedDeprecatedVersions, PkgResolutionId, ProjectId, ProjectManifest, ProjectRootDir, RangeSpecStyle, ReadPackageHook, RegistryContext, SupportedArchitectures, TrustPolicy } from '@pnpm/types' import { partition } from 'ramda' import type { WantedDependency } from './getNonDevWantedDependencies.js' @@ -108,7 +97,7 @@ export interface ImporterToResolveGeneric extends Importer< rangeSpecStyle?: RangeSpecStyle } -export interface ResolveDependenciesOptions { +export interface ResolveDependenciesOptions extends RegistryContext { allowBuild?: AllowBuild autoInstallPeers?: boolean autoInstallPeersFromHighestMatch?: boolean @@ -128,8 +117,6 @@ export interface ResolveDependenciesOptions { } overrideBareSpecifier?: (name: string, bareSpecifier: string, dir?: string) => string | undefined nodeVersion?: string - registries: Registries - namedRegistries?: Record patchedDependencies?: PatchGroupRecord pnpmVersion: string preferredVersions?: PreferredVersions @@ -211,12 +198,11 @@ export async function resolveDependencyTree ( preferWorkspacePackages: opts.preferWorkspacePackages, readPackageHook: opts.hooks.readPackage, overrideBareSpecifier: opts.overrideBareSpecifier, - registries: opts.registries, - namedRegistries: opts.namedRegistries, + ...pickRegistryContext(opts), namedRegistryPrefixes: Array.from( new Set([ - ...Object.keys(BUILTIN_NAMED_REGISTRIES), - ...Object.keys(opts.namedRegistries ?? {}), + ...Object.keys(BUILTIN_REGISTRIES_BY_PREFIX), + ...Object.keys(opts.registriesByPrefix ?? {}), ]) ).map((alias) => `${alias}:`), resolvedPkgsById: {} as ResolvedPkgsById, diff --git a/pnpm11/installing/deps-resolver/src/updateLockfile.ts b/pnpm11/installing/deps-resolver/src/updateLockfile.ts index 6543daf4e6..f1d9b62f97 100644 --- a/pnpm11/installing/deps-resolver/src/updateLockfile.ts +++ b/pnpm11/installing/deps-resolver/src/updateLockfile.ts @@ -1,4 +1,4 @@ -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { getRegistryServerType, normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import * as dp from '@pnpm/deps.path' import { type LockfileObject, @@ -7,7 +7,7 @@ import { } from '@pnpm/lockfile.pruner' import { toLockfileResolution } from '@pnpm/lockfile.utils' import { logger } from '@pnpm/logger' -import type { DepPath, Registries } from '@pnpm/types' +import type { DepPath, RegistriesByScope, RegistryContext, RegistryServerType } from '@pnpm/types' import type { KeyValuePair } from 'ramda' import { equals, partition } from 'ramda' @@ -16,17 +16,15 @@ import type { DependenciesGraph } from './index.js' import type { ResolvedPackage } from './resolveDependencies.js' export function updateLockfile ( - { dependenciesGraph, lockfile, prefix, registries, namedRegistries, lockfileIncludeTarballUrl }: { + { dependenciesGraph, lockfile, prefix, registriesByScope, registriesByPrefix, registryOptionsByUrl, lockfileIncludeTarballUrl }: RegistryContext & { dependenciesGraph: DependenciesGraph lockfile: LockfileObject prefix: string - registries: Registries - namedRegistries?: Record lockfileIncludeTarballUrl?: boolean } ): LockfileObject { lockfile.packages = lockfile.packages ?? {} - const mergedNamedRegistries = normalizeNamedRegistries(namedRegistries) + const mergedRegistriesByPrefix = normalizeRegistriesByPrefix(registriesByPrefix) for (const [depPath, depNode] of Object.entries(dependenciesGraph)) { const [updatedOptionalDeps, updatedDeps] = partition( (child) => depNode.optionalDependencies.has(child.alias) || depNode.peerDependencies[child.alias]?.optional === true, @@ -37,13 +35,15 @@ export function updateLockfile ( // checked against its named registry, everything else against the // scope-routed one. const registryName = dp.parse(depPath).registryName + const registry = (registryName != null ? mergedRegistriesByPrefix[registryName] : undefined) ?? + dp.getRegistryByPackageName(registriesByScope, depNode.name) lockfile.packages[depPath as DepPath] = toLockfileDependency(depNode, { depGraph: dependenciesGraph, depPath, prevSnapshot: lockfile.packages[depPath as DepPath], - registries, - registry: (registryName != null ? mergedNamedRegistries[registryName] : undefined) ?? - dp.getRegistryByPackageName(registries, depNode.name), + registriesByScope, + registry, + serverType: getRegistryServerType({ registryOptionsByUrl }, registry), registryName, updatedDeps, updatedOptionalDeps, @@ -61,8 +61,9 @@ function toLockfileDependency ( opts: { depPath: string registry: string + serverType?: RegistryServerType registryName?: string - registries: Registries + registriesByScope: RegistriesByScope updatedDeps: Array<{ alias: string, depPath: DepPath }> updatedOptionalDeps: Array<{ alias: string, depPath: DepPath }> depGraph: DependenciesGraph @@ -73,8 +74,11 @@ function toLockfileDependency ( let lockfileResolution = toLockfileResolution( { name: pkg.name, version: pkg.version }, pkg.resolution, - opts.registry, - opts.lockfileIncludeTarballUrl + { + registry: opts.registry, + serverType: opts.serverType, + lockfileIncludeTarballUrl: opts.lockfileIncludeTarballUrl, + } ) if ( diff --git a/pnpm11/installing/deps-resolver/test/resolveDependencyTree.test.ts b/pnpm11/installing/deps-resolver/test/resolveDependencyTree.test.ts index 6673410e57..26f53cb054 100644 --- a/pnpm11/installing/deps-resolver/test/resolveDependencyTree.test.ts +++ b/pnpm11/installing/deps-resolver/test/resolveDependencyTree.test.ts @@ -71,7 +71,7 @@ test('shared package children are resolved from the deterministic shallowest con hooks: {}, lockfileDir: '/project', pnpmVersion: '0.0.0', - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, storeController, @@ -178,7 +178,7 @@ test('a targeted update keeps down-chain preferred-version propagation, so it de hooks: {}, lockfileDir: '/project', pnpmVersion: '0.0.0', - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, storeController, @@ -305,7 +305,7 @@ test('updateRequested matches an npm-alias dependency without a lockfile referen hooks: {}, lockfileDir: '/project', pnpmVersion: '0.0.0', - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, storeController, diff --git a/pnpm11/installing/deps-resolver/test/updateLockfile.test.ts b/pnpm11/installing/deps-resolver/test/updateLockfile.test.ts index 5936585f5b..eb3d22a9df 100644 --- a/pnpm11/installing/deps-resolver/test/updateLockfile.test.ts +++ b/pnpm11/installing/deps-resolver/test/updateLockfile.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@jest/globals' import { LOCKFILE_VERSION } from '@pnpm/constants' import type { LockfileObject, PackageSnapshot } from '@pnpm/lockfile.pruner' -import type { DepPath, ProjectId, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, RegistriesByScope } from '@pnpm/types' import type { DependenciesGraph } from '../lib/index.js' import { updateLockfile } from '../lib/updateLockfile.js' @@ -10,7 +10,7 @@ import { updateLockfile } from '../lib/updateLockfile.js' const TARBALL_URL = 'https://cdn.sheetjs.com/xlsx-0.18.5/xlsx-0.18.5.tgz' const DEP_PATH = `xlsx@${TARBALL_URL}` as DepPath const INTEGRITY = 'sha512-AaaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaA==' -const REGISTRIES: Registries = { default: 'https://registry.npmjs.org/' } +const REGISTRIES: RegistriesByScope = { default: 'https://registry.npmjs.org/' } function tarballGraph ( resolution: { tarball: string, integrity?: string }, @@ -53,7 +53,7 @@ test('integrity of a remote tarball dependency is carried over when its lockfile dependenciesGraph: tarballGraph({ tarball: TARBALL_URL }), lockfile: lockfileWith({ resolution: { tarball: TARBALL_URL, integrity: INTEGRITY } }), prefix: '.', - registries: REGISTRIES, + registriesByScope: REGISTRIES, }) expect(lockfile.packages![DEP_PATH].resolution).toStrictEqual({ tarball: TARBALL_URL, integrity: INTEGRITY }) }) @@ -64,7 +64,7 @@ test('a freshly resolved integrity is never overwritten by the previous one', () dependenciesGraph: tarballGraph({ tarball: TARBALL_URL, integrity: newIntegrity }), lockfile: lockfileWith({ resolution: { tarball: TARBALL_URL, integrity: INTEGRITY } }), prefix: '.', - registries: REGISTRIES, + registriesByScope: REGISTRIES, }) expect(lockfile.packages![DEP_PATH].resolution).toStrictEqual({ tarball: TARBALL_URL, integrity: newIntegrity }) }) @@ -87,7 +87,7 @@ test('a stale integrity is not attached when the tarball URL changed', () => { packages: { [DEP_PATH]: { resolution: { tarball: TARBALL_URL, integrity: INTEGRITY } } }, }, prefix: '.', - registries: REGISTRIES, + registriesByScope: REGISTRIES, }) expect(lockfile.packages![newDepPath].resolution).toStrictEqual({ tarball: newUrl }) }) @@ -109,7 +109,7 @@ test.each([ dependenciesGraph: tarballGraph({ tarball: TARBALL_URL }, additionalInfo), lockfile: lockfileWith({ resolution: { tarball: TARBALL_URL } }), prefix: '.', - registries: REGISTRIES, + registriesByScope: REGISTRIES, }) expect(lockfile.packages![DEP_PATH].bundledDependencies).toEqual(expected) }) @@ -122,7 +122,7 @@ test('an unchanged resolution never loses its recorded deprecation to metadata d deprecated: 'No longer maintained', }), prefix: '.', - registries: REGISTRIES, + registriesByScope: REGISTRIES, }) expect(lockfile.packages![DEP_PATH].deprecated).toBe('No longer maintained') }) @@ -136,7 +136,7 @@ test('a changed resolution takes the freshly served metadata', () => { deprecated: 'No longer maintained', }), prefix: '.', - registries: REGISTRIES, + registriesByScope: REGISTRIES, }) expect(lockfile.packages![DEP_PATH].deprecated).toBeUndefined() }) diff --git a/pnpm11/installing/deps-restorer/package.json b/pnpm11/installing/deps-restorer/package.json index 5d8f5041f6..c839a4e098 100644 --- a/pnpm11/installing/deps-restorer/package.json +++ b/pnpm11/installing/deps-restorer/package.json @@ -40,6 +40,7 @@ "@pnpm/bins.linker": "workspace:*", "@pnpm/building.during-install": "workspace:*", "@pnpm/building.policy": "workspace:*", + "@pnpm/config.normalize-registries": "workspace:*", "@pnpm/config.package-is-installable": "workspace:*", "@pnpm/constants": "workspace:*", "@pnpm/core-loggers": "workspace:*", diff --git a/pnpm11/installing/deps-restorer/src/index.ts b/pnpm11/installing/deps-restorer/src/index.ts index f3ea07fb8e..a91659bdfa 100644 --- a/pnpm11/installing/deps-restorer/src/index.ts +++ b/pnpm11/installing/deps-restorer/src/index.ts @@ -67,6 +67,7 @@ import type { PackageFilesResponse, StoreController, } from '@pnpm/store.controller-types' +import type { RegistryContext } from '@pnpm/types' import { type AllowBuild, DEPENDENCIES_FIELDS, @@ -77,7 +78,7 @@ import { type ProjectId, type ProjectManifest, type ProjectRootDir, - type Registries, + type RegistriesByScope, type RegistryConfig, type SupportedArchitectures, } from '@pnpm/types' @@ -107,7 +108,7 @@ export interface Project { rootDir: ProjectRootDir } -export interface HeadlessOptions { +export interface HeadlessOptions extends RegistryContext { allowBuilds?: Record autoInstallPeers?: boolean childConcurrency?: number @@ -170,8 +171,6 @@ export interface HeadlessOptions { configByUri: Record unsafePerm: boolean userAgent: string - registries: Registries - namedRegistries?: Record reporter?: ReporterFunction packageManager: { name: string @@ -280,7 +279,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise() const filterOpts = { include: opts.include, - registries: opts.registries, + registriesByScope: opts.registriesByScope, skipped, skipRuntimes: opts.skipRuntimes, currentEngine: opts.currentEngine, @@ -413,7 +412,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise & { +type SymlinkDirectDependenciesOpts = Pick & { filteredLockfile: LockfileObject dedupe: boolean directDependenciesByImporterId: DirectDependenciesByImporterId @@ -844,7 +842,7 @@ async function symlinkDirectDependencies ( directDependenciesByImporterId, lockfileDir, projects, - registries, + registriesByScope, symlink, }: SymlinkDirectDependenciesOpts ): Promise { @@ -871,7 +869,7 @@ async function symlinkDirectDependencies ( lockfileDir, projectDir: rootDir, importerManifestsByImporterId, - registries, + registriesByScope, rootDependencies: directDependenciesByImporterId[id], }), }])) @@ -910,7 +908,7 @@ async function linkBinsOfImporter ( async function getRootPackagesToLink ( lockfile: LockfileObject, opts: { - registries: Registries + registriesByScope: RegistriesByScope projectDir: string importerId: ProjectId importerModulesDir: string diff --git a/pnpm11/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts b/pnpm11/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts index df66a81ff0..383e2aa9d1 100644 --- a/pnpm11/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts +++ b/pnpm11/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts @@ -1,5 +1,6 @@ import path from 'node:path' +import { pickRegistryContext } from '@pnpm/config.normalize-registries' import { packageIsInstallable } from '@pnpm/config.package-is-installable' import type { DependenciesGraph, @@ -28,11 +29,11 @@ import type { FetchPackageToStoreFunction, StoreController, } from '@pnpm/store.controller-types' -import type { AllowBuild, DepPath, ProjectId, Registries, SupportedArchitectures } from '@pnpm/types' +import type { AllowBuild, DepPath, ProjectId, RegistryContext, SupportedArchitectures } from '@pnpm/types' import { pathAbsolute } from 'path-absolute' import { pathExists } from 'path-exists' -export interface LockfileToHoistedDepGraphOptions { +export interface LockfileToHoistedDepGraphOptions extends RegistryContext { allowBuild?: AllowBuild autoInstallPeers: boolean engineStrict: boolean @@ -53,8 +54,6 @@ export interface LockfileToHoistedDepGraphOptions { modulesDir?: string nodeVersion: string pnpmVersion: string - registries: Registries - namedRegistries?: Record patchedDependencies?: PatchGroupRecord /** * The dep paths a non-optional edge reaches, as classified by @@ -231,7 +230,7 @@ async function fetchDeps ( const dir = safeJoinModulesDir(modules, dep.name) const depLocation = path.relative(opts.lockfileDir, dir) - const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, { registries: opts.registries, namedRegistries: opts.namedRegistries }) + const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, pickRegistryContext(opts)) let fetchResponse!: ReturnType // We check for the existence of the package inside node_modules. // It will only be missing if the user manually removed it. diff --git a/pnpm11/installing/deps-restorer/test/lockfileToHoistedDepGraph.test.ts b/pnpm11/installing/deps-restorer/test/lockfileToHoistedDepGraph.test.ts index f411384b87..948c0ac7a1 100644 --- a/pnpm11/installing/deps-restorer/test/lockfileToHoistedDepGraph.test.ts +++ b/pnpm11/installing/deps-restorer/test/lockfileToHoistedDepGraph.test.ts @@ -48,7 +48,7 @@ function hoistedOpts (lockfileDir: string): Parameters(), diff --git a/pnpm11/installing/deps-restorer/test/utils/testDefaults.ts b/pnpm11/installing/deps-restorer/test/utils/testDefaults.ts index 44e8b05a4c..62759e5f2f 100644 --- a/pnpm11/installing/deps-restorer/test/utils/testDefaults.ts +++ b/pnpm11/installing/deps-restorer/test/utils/testDefaults.ts @@ -65,7 +65,7 @@ export async function testDefaults ( await Promise.all(projects.map(async (project) => [project.rootDir, { ...project, manifest: await safeReadPackageJsonFromDir(project.rootDir) }])) ), authConfig: {}, - registries: { + registriesByScope: { default: registry, }, sideEffectsCache: true, diff --git a/pnpm11/installing/deps-restorer/tsconfig.json b/pnpm11/installing/deps-restorer/tsconfig.json index d8fcad8569..6ec9b2bb7b 100644 --- a/pnpm11/installing/deps-restorer/tsconfig.json +++ b/pnpm11/installing/deps-restorer/tsconfig.json @@ -30,6 +30,9 @@ { "path": "../../building/policy" }, + { + "path": "../../config/normalize-registries" + }, { "path": "../../config/package-is-installable" }, diff --git a/pnpm11/installing/env-installer/src/installConfigDeps.ts b/pnpm11/installing/env-installer/src/installConfigDeps.ts index 43e7b3af5a..868048d619 100644 --- a/pnpm11/installing/env-installer/src/installConfigDeps.ts +++ b/pnpm11/installing/env-installer/src/installConfigDeps.ts @@ -11,7 +11,7 @@ import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency' import { type EnvLockfile, readEnvLockfile } from '@pnpm/lockfile.fs' import { getNpmTarballUrl } from '@pnpm/resolving.tarball-url' import type { StoreController } from '@pnpm/store.controller' -import type { ConfigDependencies, Registries } from '@pnpm/types' +import type { ConfigDependencies, RegistriesByScope } from '@pnpm/types' import { rimraf } from '@zkochan/rimraf' import { symlinkDir } from 'symlink-dir' @@ -21,7 +21,7 @@ import { verifyEnvLockfile } from './verifyEnvLockfile.js' export interface InstallConfigDepsOpts { frozenLockfile?: boolean - registries: Registries + registriesByScope: RegistriesByScope rootDir: string store: StoreController storeDir: string @@ -136,7 +136,7 @@ async function normalizeForInstall ( // If it's a EnvLockfile object (has lockfileVersion), use it directly if (isEnvLockfile(configDepsOrLockfile)) { verifyEnvLockfile(configDepsOrLockfile) - return normalizeFromLockfile(configDepsOrLockfile, opts.registries) + return normalizeFromLockfile(configDepsOrLockfile, opts.registriesByScope) } // It's ConfigDependencies from workspace manifest. @@ -144,7 +144,7 @@ async function normalizeForInstall ( const envLockfile = await readEnvLockfile(opts.rootDir) if (envLockfile) { verifyEnvLockfile(envLockfile) - return normalizeFromLockfile(envLockfile, opts.registries) + return normalizeFromLockfile(envLockfile, opts.registriesByScope) } // No env lockfile yet — migrate from old inline integrity format @@ -169,7 +169,7 @@ function isEnvLockfile (obj: ConfigDependencies | EnvLockfile): obj is EnvLockfi function normalizeFromLockfile ( lockfile: EnvLockfile, - registries: Registries + registriesByScope: RegistriesByScope ): Record { const deps: Record = {} const configDeps = lockfile.importers['.']?.configDependencies ?? {} @@ -190,10 +190,10 @@ function normalizeFromLockfile ( `pnpm-lock.yaml is corrupted or incomplete: missing integrity for "${pkgKey}"` ) } - const registry = pickRegistryForPackage(registries, pkgName) + const registry = pickRegistryForPackage(registriesByScope, pkgName) const snapshot = lockfile.snapshots[pkgKey] const optionalSubdeps = snapshot?.optionalDependencies - ? readOptionalSubdepsFromLockfile(pkgName, snapshot.optionalDependencies, lockfile, registries) + ? readOptionalSubdepsFromLockfile(pkgName, snapshot.optionalDependencies, lockfile, registriesByScope) : undefined deps[pkgName] = { version, @@ -211,7 +211,7 @@ function readOptionalSubdepsFromLockfile ( parentName: string, optionalDeps: Record, lockfile: EnvLockfile, - registries: Registries + registriesByScope: RegistriesByScope ): NormalizedSubdep[] { const subdeps: NormalizedSubdep[] = [] for (const [subdepName, subdepVersion] of Object.entries(optionalDeps)) { @@ -231,7 +231,7 @@ function readOptionalSubdepsFromLockfile ( `pnpm-lock.yaml is corrupted or incomplete: missing integrity for "${subdepKey}"` ) } - const registry = pickRegistryForPackage(registries, subdepName) + const registry = pickRegistryForPackage(registriesByScope, subdepName) subdeps.push({ name: subdepName, version: subdepVersion, diff --git a/pnpm11/installing/env-installer/src/migrateConfigDeps.ts b/pnpm11/installing/env-installer/src/migrateConfigDeps.ts index 5d67f7e295..87aab2d6da 100644 --- a/pnpm11/installing/env-installer/src/migrateConfigDeps.ts +++ b/pnpm11/installing/env-installer/src/migrateConfigDeps.ts @@ -4,14 +4,14 @@ import { PnpmError } from '@pnpm/error' import { createEnvLockfile } from '@pnpm/lockfile.fs' import { toLockfileResolution } from '@pnpm/lockfile.utils' import { getNpmTarballUrl } from '@pnpm/resolving.tarball-url' -import type { ConfigDependencies, ConfigDependencySpecifiers, Registries } from '@pnpm/types' +import type { ConfigDependencies, ConfigDependencySpecifiers, RegistriesByScope } from '@pnpm/types' import type { NormalizedConfigDep } from './parseIntegrity.js' import { parseIntegrity } from './parseIntegrity.js' import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js' interface MigrateOpts { - registries: Registries + registriesByScope: RegistriesByScope rootDir: string } @@ -34,7 +34,7 @@ export async function migrateConfigDepsToLockfile ( const normalizedDeps: Record = {} for (const [pkgName, pkgSpec] of Object.entries(configDeps)) { - const registry = pickRegistryForPackage(opts.registries, pkgName) + const registry = pickRegistryForPackage(opts.registriesByScope, pkgName) if (typeof pkgSpec === 'object') { const { version, integrity } = parseIntegrity(pkgName, pkgSpec.integrity) @@ -50,7 +50,7 @@ export async function migrateConfigDepsToLockfile ( resolution: toLockfileResolution( { name: pkgName, version }, { integrity, tarball }, - registry + { registry } ), } envLockfile.snapshots[pkgKey] = {} diff --git a/pnpm11/installing/env-installer/src/resolveAndInstallConfigDeps.ts b/pnpm11/installing/env-installer/src/resolveAndInstallConfigDeps.ts index 27ba1bbc08..a73e58069a 100644 --- a/pnpm11/installing/env-installer/src/resolveAndInstallConfigDeps.ts +++ b/pnpm11/installing/env-installer/src/resolveAndInstallConfigDeps.ts @@ -45,13 +45,13 @@ export async function resolveAndInstallConfigDeps ( if (typeof value === 'object') { // Old object format — migrate inline into lockfile if (!lockfileConfigDeps[name]) { - const registry = pickRegistryForPackage(opts.registries, name) + const registry = pickRegistryForPackage(opts.registriesByScope, name) const { version, integrity } = parseIntegrity(name, value.integrity) const tarball = value.tarball ?? getNpmTarballUrl(name, version, { registry }) const pkgKey = `${name}@${version}` lockfileConfigDeps[name] = { specifier: version, version } envLockfile.packages[pkgKey] = { - resolution: toLockfileResolution({ name, version }, { integrity, tarball }, registry), + resolution: toLockfileResolution({ name, version }, { integrity, tarball }, { registry }), } envLockfile.snapshots[pkgKey] = {} lockfileChanged = true @@ -62,13 +62,13 @@ export async function resolveAndInstallConfigDeps ( if (value.includes('+')) { // Old string format with inline integrity — migrate into lockfile if (!lockfileConfigDeps[name]) { - const registry = pickRegistryForPackage(opts.registries, name) + const registry = pickRegistryForPackage(opts.registriesByScope, name) const { version, integrity } = parseIntegrity(name, value) const tarball = getNpmTarballUrl(name, version, { registry }) const pkgKey = `${name}@${version}` lockfileConfigDeps[name] = { specifier: version, version } envLockfile.packages[pkgKey] = { - resolution: toLockfileResolution({ name, version }, { integrity, tarball }, registry), + resolution: toLockfileResolution({ name, version }, { integrity, tarball }, { registry }), } envLockfile.snapshots[pkgKey] = {} lockfileChanged = true @@ -118,7 +118,7 @@ export async function resolveAndInstallConfigDeps ( throw new PnpmError('BAD_CONFIG_DEP', `Cannot resolve ${name}@${specifier} as a configuration dependency because it has no integrity`) } const version = resolution.manifest.version - const registry = pickRegistryForPackage(opts.registries, name) + const registry = pickRegistryForPackage(opts.registriesByScope, name) const pkgKey = `${name}@${version}` lockfileConfigDeps[name] = { @@ -129,13 +129,13 @@ export async function resolveAndInstallConfigDeps ( resolution: toLockfileResolution( { name, version }, resolution.resolution, - registry + { registry } ), } const optionalSubdeps = await resolveOptionalSubdeps(name, resolution.manifest, { envLockfile, lockfileDir: opts.rootDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, resolveFromNpm, }) envLockfile.snapshots[pkgKey] = optionalSubdeps ? { optionalDependencies: optionalSubdeps } : {} diff --git a/pnpm11/installing/env-installer/src/resolveConfigDeps.ts b/pnpm11/installing/env-installer/src/resolveConfigDeps.ts index c3433746e2..5602fd3f67 100644 --- a/pnpm11/installing/env-installer/src/resolveConfigDeps.ts +++ b/pnpm11/installing/env-installer/src/resolveConfigDeps.ts @@ -52,7 +52,7 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf } const pkgName = wantedDep.alias const version = resolution.manifest.version - const registry = pickRegistryForPackage(opts.registries, pkgName) + const registry = pickRegistryForPackage(opts.registriesByScope, pkgName) // Write clean specifier to workspace manifest configDependencySpecifiers[pkgName] = wantedDep.bareSpecifier ?? version @@ -67,13 +67,13 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf resolution: toLockfileResolution( { name: pkgName, version }, resolution.resolution, - registry + { registry } ), } const optionalSubdeps = await resolveOptionalSubdeps(pkgName, resolution.manifest, { envLockfile, lockfileDir: opts.rootDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, resolveFromNpm, }) envLockfile.snapshots[pkgKey] = optionalSubdeps ? { optionalDependencies: optionalSubdeps } : {} diff --git a/pnpm11/installing/env-installer/src/resolveManifestDependencies.ts b/pnpm11/installing/env-installer/src/resolveManifestDependencies.ts index d90c2c0ea9..cfa361131e 100644 --- a/pnpm11/installing/env-installer/src/resolveManifestDependencies.ts +++ b/pnpm11/installing/env-installer/src/resolveManifestDependencies.ts @@ -14,12 +14,12 @@ import type { ProjectId, ProjectManifest, ProjectRootDir, - Registries, + RegistriesByScope, } from '@pnpm/types' export interface ResolveManifestDependenciesOpts { dir: string - registries: Registries + registriesByScope: RegistriesByScope storeController: StoreController storeDir: string } @@ -72,7 +72,7 @@ export async function resolveManifestDependencies ( pnpmVersion: '', preferWorkspacePackages: false, preserveWorkspaceProtocol: false, - registries: opts.registries, + registriesByScope: opts.registriesByScope, saveWorkspaceProtocol: false, storeController: opts.storeController, tag: 'latest', diff --git a/pnpm11/installing/env-installer/src/resolveOptionalSubdeps.ts b/pnpm11/installing/env-installer/src/resolveOptionalSubdeps.ts index d1dacdef03..ad586272fd 100644 --- a/pnpm11/installing/env-installer/src/resolveOptionalSubdeps.ts +++ b/pnpm11/installing/env-installer/src/resolveOptionalSubdeps.ts @@ -7,7 +7,7 @@ import type { EnvLockfile } from '@pnpm/lockfile.fs' import type { ResolvedDependencies } from '@pnpm/lockfile.types' import { toLockfileResolution } from '@pnpm/lockfile.utils' import type { createNpmResolver } from '@pnpm/resolving.npm-resolver' -import type { DependencyManifest, Registries } from '@pnpm/types' +import type { DependencyManifest, RegistriesByScope } from '@pnpm/types' import semver from 'semver' type ResolveFromNpm = ReturnType['resolveFromNpm'] @@ -15,7 +15,7 @@ type ResolveFromNpm = ReturnType['resolveFromNpm'] export interface ResolveOptionalSubdepsOpts { envLockfile: EnvLockfile lockfileDir: string - registries: Registries + registriesByScope: RegistriesByScope resolveFromNpm: ResolveFromNpm } @@ -85,14 +85,14 @@ export async function resolveOptionalSubdeps ( ) } const subdepVersion = resolution.manifest.version - const registry = pickRegistryForPackage(opts.registries, subdepName) + const registry = pickRegistryForPackage(opts.registriesByScope, subdepName) const subdepKey = `${subdepName}@${subdepVersion}` opts.envLockfile.packages[subdepKey] = { resolution: toLockfileResolution( { name: subdepName, version: subdepVersion }, resolution.resolution, - registry + { registry } ), ...pickPlatformFields(resolution.manifest), } diff --git a/pnpm11/installing/env-installer/src/resolvePackageManagerIntegrities.ts b/pnpm11/installing/env-installer/src/resolvePackageManagerIntegrities.ts index 8c0fe169d9..08e8714077 100644 --- a/pnpm11/installing/env-installer/src/resolvePackageManagerIntegrities.ts +++ b/pnpm11/installing/env-installer/src/resolvePackageManagerIntegrities.ts @@ -3,7 +3,7 @@ import { convertToLockfileFile, createEnvLockfile, readEnvLockfile } from '@pnpm import { pruneSharedLockfile } from '@pnpm/lockfile.pruner' import type { EnvLockfile, LockfileObject } from '@pnpm/lockfile.types' import type { StoreController } from '@pnpm/store.controller' -import type { DepPath, ProjectId, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, RegistriesByScope } from '@pnpm/types' import semver from 'semver' import { convertToLockfileEnvObject } from './pruneEnvLockfile.js' @@ -16,7 +16,7 @@ const PNPM_EXE_INTRODUCED = '6.17.1' export interface ResolvePackageManagerIntegritiesOpts { envLockfile?: EnvLockfile - registries: Registries + registriesByScope: RegistriesByScope rootDir: string storeController: StoreController storeDir: string @@ -155,7 +155,7 @@ async function resolveWantedPnpmPackages ( ): Promise { const resolveOpts = { dir: opts.rootDir, - registries: opts.registries, + registriesByScope: opts.registriesByScope, storeController: opts.storeController, storeDir: opts.storeDir, } diff --git a/pnpm11/installing/env-installer/test/installConfigDeps.ts b/pnpm11/installing/env-installer/test/installConfigDeps.ts index 6b6ee2dce4..75ff3ee6e6 100644 --- a/pnpm11/installing/env-installer/test/installConfigDeps.ts +++ b/pnpm11/installing/env-installer/test/installConfigDeps.ts @@ -48,7 +48,7 @@ test('configuration dependency is installed from env lockfile', async () => { '@pnpm.e2e/foo': { version: '100.0.0', integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') }, }) await installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -70,7 +70,7 @@ test('configuration dependency is installed from env lockfile', async () => { }) await installConfigDeps(lockfile2, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -88,7 +88,7 @@ test('configuration dependency is installed from env lockfile', async () => { const lockfile3 = createEnvLockfile() await installConfigDeps(lockfile3, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -109,7 +109,7 @@ test('a config dependency with a path-traversal name in the env lockfile is reje }) await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -142,7 +142,7 @@ test('an optional subdep with a path-traversal name in the env lockfile is rejec } await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -166,7 +166,7 @@ test('a config dependency named __proto__ in the env lockfile is rejected', asyn lockfile.snapshots['__proto__@1.0.0'] = {} await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -192,7 +192,7 @@ test('an optional subdep named __proto__ in the env lockfile is rejected', async lockfile.packages['__proto__@1.0.0'] = { resolution: { integrity: getIntegrity('@pnpm.e2e/bar', '100.0.0') } } await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -214,7 +214,7 @@ test('an invalid config dependency name in the workspace manifest is rejected be } await expect(installConfigDeps(configDeps, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -236,7 +236,7 @@ test('an invalid config dependency version in the workspace manifest is rejected } await expect(installConfigDeps(configDeps, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -258,7 +258,7 @@ test('a config dependency with a path-traversal version in the env lockfile is r }) await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -291,7 +291,7 @@ test('an optional subdep with a path-traversal version in the env lockfile is re } await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -326,7 +326,7 @@ test('optional subdep matching the current platform is installed and symlinked n } await installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -368,7 +368,7 @@ test('changing only an optional subdep version re-installs and re-symlinks the p } const installOpts = { - registries: { default: registry }, + registriesByScope: { default: registry }, rootDir: process.cwd(), store: storeController, storeDir, @@ -405,7 +405,7 @@ test('optional subdep that does not match the current platform is skipped', asyn } await installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -439,7 +439,7 @@ test('re-installs sibling symlinks even when the parent symlink is already corre } const installOpts = { - registries: { default: registry }, + registriesByScope: { default: registry }, rootDir: process.cwd(), store: storeController, storeDir, @@ -484,7 +484,7 @@ test('optional subdep that does not match the current cpu is skipped', async () } await installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -514,7 +514,7 @@ test('installation fails if the checksum of the config dependency is invalid', a }, }) await expect(installConfigDeps(lockfile, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -533,7 +533,7 @@ test('migration: installs from old inline integrity format and creates env lockf '@pnpm.e2e/foo': `100.0.0+${integrity}`, } await installConfigDeps(configDeps, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -566,7 +566,7 @@ test('migration fails with frozenLockfile when no env lockfile exists', async () '@pnpm.e2e/foo': `100.0.0+${integrity}`, } await expect(installConfigDeps(configDeps, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -590,7 +590,7 @@ test('installation fails if the config dependency does not have a checksum (old '@pnpm.e2e/foo': '100.0.0', } await expect(installConfigDeps(configDeps, { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), diff --git a/pnpm11/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts b/pnpm11/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts index 995b5e576a..52d6be4a4c 100644 --- a/pnpm11/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts +++ b/pnpm11/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts @@ -14,7 +14,7 @@ const registry = `http://localhost:${REGISTRY_MOCK_PORT}/` function createOpts () { const { storeController, storeDir } = createTempStore() return { - registries: { default: registry }, + registriesByScope: { default: registry }, rootDir: process.cwd(), cacheDir: path.resolve('cache'), userConfig: {}, diff --git a/pnpm11/installing/env-installer/test/resolveConfigDeps.test.ts b/pnpm11/installing/env-installer/test/resolveConfigDeps.test.ts index fe59ad8eea..049896398e 100644 --- a/pnpm11/installing/env-installer/test/resolveConfigDeps.test.ts +++ b/pnpm11/installing/env-installer/test/resolveConfigDeps.test.ts @@ -15,7 +15,7 @@ test('configuration dependency is resolved', async () => { const { storeController, storeDir } = createTempStore() await resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -50,7 +50,7 @@ test('one level of optionalDependencies is recorded in the env lockfile with pla const { storeController, storeDir } = createTempStore() await resolveConfigDeps(['@pnpm.e2e/support-different-architectures@1.0.0'], { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -111,7 +111,7 @@ test('config dep with no optionalDependencies keeps an empty snapshot', async () const { storeController, storeDir } = createTempStore() await resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -130,7 +130,7 @@ test('rejects an optionalDependency declared with a non-exact version', async () // @pnpm.e2e/foobar declares `@pnpm.e2e/bar: "^100.0.0"` — a range, not an exact version. await expect(resolveConfigDeps(['@pnpm.e2e/foobar@100.0.0'], { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -163,7 +163,7 @@ test('orphan optional subdeps from a previous resolution are pruned', async () = }) await resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), @@ -185,7 +185,7 @@ test('fails with frozenLockfile', async () => { const { storeController, storeDir } = createTempStore() await expect(resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], { - registries: { + registriesByScope: { default: registry, }, rootDir: process.cwd(), diff --git a/pnpm11/installing/env-installer/test/resolvePackageManagerIntegrities.test.ts b/pnpm11/installing/env-installer/test/resolvePackageManagerIntegrities.test.ts index 92382ad832..e510741eca 100644 --- a/pnpm11/installing/env-installer/test/resolvePackageManagerIntegrities.test.ts +++ b/pnpm11/installing/env-installer/test/resolvePackageManagerIntegrities.test.ts @@ -77,7 +77,7 @@ test('registry tarball URLs are dropped from package-manager resolutions; file:, } as unknown as LockfileObject) const result = await resolvePackageManagerIntegrities('12.0.0', { - registries: { default: 'https://mirror.example.com/' }, + registriesByScope: { default: 'https://mirror.example.com/' }, rootDir: '/repo', storeController: {} as never, storeDir: '/store', diff --git a/pnpm11/installing/modules-yaml/src/index.ts b/pnpm11/installing/modules-yaml/src/index.ts index 7d9bf8accf..9a781e7cc6 100644 --- a/pnpm11/installing/modules-yaml/src/index.ts +++ b/pnpm11/installing/modules-yaml/src/index.ts @@ -6,7 +6,6 @@ import type { DepPath, HoistedDependencies, IgnoredBuilds, - Registries, } from '@pnpm/types' import isWindows from 'is-windows' import { map as mapValues } from 'ramda' @@ -31,7 +30,6 @@ interface ModulesRaw { pendingBuilds: string[] ignoredBuilds?: DepPath[] prunedAt: string - registries?: Registries // nullable for backward compatibility shamefullyHoist?: boolean // for backward compatibility publicHoistPattern?: string[] skipped: string[] @@ -108,13 +106,9 @@ export async function readModulesManifest (modulesDir: string): Promise { const modulesYamlPath = path.join(modulesDir, MODULES_FILENAME) const saveModules = { ...modules, ignoredBuilds: modules.ignoredBuilds ? Array.from(modules.ignoredBuilds) : undefined } @@ -133,6 +127,10 @@ export async function writeModulesManifest ( if ((saveModules.hoistedAliases == null) || (saveModules.hoistPattern == null) && (saveModules.publicHoistPattern == null)) { delete saveModules.hoistedAliases } + // pnpm 11 and older recorded the registries the last install resolved from. + // They are read from the project's config now, so a file that still carries + // them loses them on the first rewrite rather than keeping a stale copy. + delete (saveModules as { registries?: unknown }).registries // We should store the absolute virtual store directory path on Windows // because junctions are used on Windows. Junctions will break even if // the relative path to the virtual store remains the same after moving diff --git a/pnpm11/installing/modules-yaml/test/index.ts b/pnpm11/installing/modules-yaml/test/index.ts index 126505b16a..0f633b61f2 100644 --- a/pnpm11/installing/modules-yaml/test/index.ts +++ b/pnpm11/installing/modules-yaml/test/index.ts @@ -2,14 +2,14 @@ import path from 'node:path' import { expect, test } from '@jest/globals' -import { readModulesManifest, type StrictModules, writeModulesManifest } from '@pnpm/installing.modules-yaml' +import { type Modules, readModulesManifest, writeModulesManifest } from '@pnpm/installing.modules-yaml' import isWindows from 'is-windows' import { readYamlFileSync } from 'read-yaml-file' import { temporaryDirectory } from 'tempy' test('writeModulesManifest() and readModulesManifest()', async () => { const modulesDir = temporaryDirectory() - const modulesYaml: StrictModules = { + const modulesYaml: Modules = { hoistedDependencies: {}, included: { dependencies: true, @@ -22,9 +22,6 @@ test('writeModulesManifest() and readModulesManifest()', async () => { pendingBuilds: [], publicHoistPattern: [], prunedAt: new Date().toUTCString(), - registries: { - default: 'https://registry.npmjs.org/', - }, shamefullyHoist: false, skipped: [], storeDir: '/.pnpm-store', @@ -67,7 +64,7 @@ test('backward compatible read of .modules.yaml created with shamefully-hoist=fa test('readModulesManifest() should create a node_modules directory', async () => { const modulesDir = path.join(temporaryDirectory(), 'node_modules') - const modulesYaml: StrictModules = { + const modulesYaml: Modules = { hoistedDependencies: {}, included: { dependencies: true, @@ -80,9 +77,6 @@ test('readModulesManifest() should create a node_modules directory', async () => pendingBuilds: [], publicHoistPattern: [], prunedAt: new Date().toUTCString(), - registries: { - default: 'https://registry.npmjs.org/', - }, shamefullyHoist: false, skipped: [], storeDir: '/.pnpm-store', @@ -97,3 +91,32 @@ test('readModulesManifest does not fail on empty file', async () => { const modulesYaml = await readModulesManifest(path.join(import.meta.dirname, 'fixtures/empty-modules-yaml')) expect(modulesYaml).toBeUndefined() }) + +test('writeModulesManifest() drops the registries a pnpm 11 file recorded', async () => { + // The registries a project resolves from are read from its config, so a + // recorded copy is stale the moment the config changes. + const modulesDir = temporaryDirectory() + const modulesYaml: Modules = { + hoistedDependencies: {}, + included: { + dependencies: true, + devDependencies: true, + optionalDependencies: true, + }, + layoutVersion: 1, + packageManager: 'pnpm@2', + pendingBuilds: [], + prunedAt: new Date().toUTCString(), + skipped: [], + storeDir: '/.pnpm-store', + virtualStoreDir: path.join(modulesDir, '.pnpm'), + virtualStoreDirMaxLength: 120, + } + await writeModulesManifest(modulesDir, { + ...modulesYaml, + registries: { default: 'https://registry.npmjs.org/' }, + } as Modules) + + const raw = readYamlFileSync>(path.join(modulesDir, '.modules.yaml')) + expect(raw.registries).toBeUndefined() +}) diff --git a/pnpm11/installing/package-requester/test/index.ts b/pnpm11/installing/package-requester/test/index.ts index 42bb0abb44..6540bff3ff 100644 --- a/pnpm11/installing/package-requester/test/index.ts +++ b/pnpm11/installing/package-requester/test/index.ts @@ -23,7 +23,7 @@ const registry = `http://localhost:${REGISTRY_MOCK_PORT}` const f = fixtures(import.meta.dirname) const IS_POSITIVE_TARBALL = f.find('is-positive-1.0.0.tgz') -const registries = { default: registry } +const registriesByScope = { default: registry } const storeIndexes: StoreIndex[] = [] afterAll(() => { @@ -37,7 +37,7 @@ const { resolve, fetchers } = createClient({ configByUri: {}, cacheDir: '.store', storeDir: '.store', - registries, + registriesByScope, storeIndex: topStoreIndex, }) @@ -48,7 +48,7 @@ function createFetchersForStore (storeDir: string) { configByUri: {}, cacheDir: storeDir, storeDir, - registries, + registriesByScope, storeIndex: si, }).fetchers } @@ -759,7 +759,7 @@ test('fetchPackageToStore() does not cache errors', async () => { retry: { retries: 0 }, cacheDir: '.pnpm', storeDir: '.store', - registries, + registriesByScope, storeIndex: topStoreIndex, }) diff --git a/pnpm11/installing/read-projects-context/src/index.ts b/pnpm11/installing/read-projects-context/src/index.ts index 6681752ac6..414e4afc6f 100644 --- a/pnpm11/installing/read-projects-context/src/index.ts +++ b/pnpm11/installing/read-projects-context/src/index.ts @@ -2,7 +2,6 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import util from 'node:util' -import { normalizeRegistries } from '@pnpm/config.normalize-registries' import { type Modules, readModulesManifest } from '@pnpm/installing.modules-yaml' import { getLockfileImporterId } from '@pnpm/lockfile.fs' import type { @@ -12,7 +11,6 @@ import type { ProjectId, ProjectRootDir, ProjectRootDirRealPath, - Registries, } from '@pnpm/types' import { pathAbsolute } from 'path-absolute' import { realpathMissing } from 'realpath-missing' @@ -41,7 +39,6 @@ export async function readProjectsContext ( include: Record modules: Modules | null pendingBuilds: string[] - registries: Registries | null | undefined rootModulesDir: string skipped: Set virtualStoreDirMaxLength?: number @@ -74,7 +71,6 @@ export async function readProjectsContext ( rootDirRealPath: project.rootDirRealPath ?? await realpath(project.rootDir), } })), - registries: ((modules?.registries) != null) ? normalizeRegistries(modules.registries) : undefined, rootModulesDir, skipped: new Set((modules?.skipped ?? []) as DepPath[]), virtualStoreDirMaxLength: modules?.virtualStoreDirMaxLength, diff --git a/pnpm11/lockfile/to-pnp/src/index.ts b/pnpm11/lockfile/to-pnp/src/index.ts index d865be998b..f3ebc8fab3 100644 --- a/pnpm11/lockfile/to-pnp/src/index.ts +++ b/pnpm11/lockfile/to-pnp/src/index.ts @@ -7,7 +7,7 @@ import type { LockfileObject } from '@pnpm/lockfile.fs' import { nameVerFromPkgSnapshot, } from '@pnpm/lockfile.utils' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { generateInlinedScript, type PackageRegistry } from '@yarnpkg/pnp' import normalizePath from 'normalize-path' @@ -32,7 +32,7 @@ export async function writePnpFile ( lockfileDir: string virtualStoreDir: string virtualStoreDirMaxLength: number - registries: Registries + registriesByScope: RegistriesByScope } ): Promise { const packageRegistry = lockfileToPackageRegistry(lockfile, opts) @@ -54,7 +54,7 @@ export function lockfileToPackageRegistry ( lockfileDir: string virtualStoreDir: string virtualStoreDirMaxLength: number - registries: Registries + registriesByScope: RegistriesByScope } ): PackageRegistry { const packageRegistry = new Map() diff --git a/pnpm11/lockfile/to-pnp/test/index.ts b/pnpm11/lockfile/to-pnp/test/index.ts index 8a6f6d554e..eb0aa5d438 100644 --- a/pnpm11/lockfile/to-pnp/test/index.ts +++ b/pnpm11/lockfile/to-pnp/test/index.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { expect, test } from '@jest/globals' import type { LockfileObject } from '@pnpm/lockfile.fs' import { dependenciesGraphToPackageMap, lockfileToPackageMap, lockfileToPackageRegistry } from '@pnpm/lockfile.to-pnp' -import type { DepPath, ProjectId, Registries } from '@pnpm/types' +import type { DepPath, ProjectId, RegistriesByScope } from '@pnpm/types' test('lockfileToPackageRegistry', () => { const packageRegistry = lockfileToPackageRegistry({ @@ -61,7 +61,7 @@ test('lockfileToPackageRegistry', () => { importer2: 'importer2', }, lockfileDir: process.cwd(), - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, virtualStoreDir: path.resolve('node_modules/.pnpm'), @@ -626,7 +626,7 @@ test('lockfileToPackageRegistry packages that have peer deps', () => { importer: 'importer', }, lockfileDir: process.cwd(), - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, virtualStoreDir: path.resolve('node_modules/.pnpm'), @@ -728,7 +728,7 @@ test('lockfileToPackageRegistry rejects a package name with path-traversal', () lockfileDir: '/home/user/project', virtualStoreDir: '/home/user/project/node_modules/.pnpm', virtualStoreDirMaxLength: 120, - registries: { default: 'https://registry.npmjs.org/' } as Registries, + registriesByScope: { default: 'https://registry.npmjs.org/' } as RegistriesByScope, }) ).toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' })) }) diff --git a/pnpm11/lockfile/utils/src/pkgSnapshotToResolution.ts b/pnpm11/lockfile/utils/src/pkgSnapshotToResolution.ts index b1b899ea8a..474ea7ab23 100644 --- a/pnpm11/lockfile/utils/src/pkgSnapshotToResolution.ts +++ b/pnpm11/lockfile/utils/src/pkgSnapshotToResolution.ts @@ -1,23 +1,17 @@ import url from 'node:url' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { getRegistryServerType, normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import * as dp from '@pnpm/deps.path' import { PnpmError } from '@pnpm/error' import type { PackageSnapshot, TarballResolution } from '@pnpm/lockfile.types' import type { Resolution } from '@pnpm/resolving.resolver-base' import { getNpmTarballUrl } from '@pnpm/resolving.tarball-url' -import type { Registries } from '@pnpm/types' +import type { RegistryContext } from '@pnpm/types' import { nameVerFromPkgSnapshot } from './nameVerFromPkgSnapshot.js' -export interface PkgSnapshotToResolutionOptions { - registries: Registries - /** - * User-configured named registries (`namedRegistries` setting). Built-in - * aliases are merged in here, so callers pass the setting verbatim. - */ - namedRegistries?: Record -} +/** The registry facts alone; the tarball URL is rebuilt from them. */ +export type PkgSnapshotToResolutionOptions = RegistryContext export function pkgSnapshotToResolution ( depPath: string, @@ -49,17 +43,17 @@ export function pkgSnapshotToResolution ( const { name, version, registryName } = nameVerFromPkgSnapshot(depPath, pkgSnapshot) let registry: string = '' if (registryName != null) { - registry = normalizeNamedRegistries(opts.namedRegistries)[registryName] + registry = normalizeRegistriesByPrefix(opts.registriesByPrefix)[registryName] if (!registry) { throw new PnpmError('MISSING_NAMED_REGISTRY', - `Cannot install package "${depPath}": it was resolved from the named registry '${registryName}:', which is not present in the namedRegistries setting.`, - { hint: `Add '${registryName}' to the namedRegistries setting in pnpm-workspace.yaml.` }) + `Cannot install package "${depPath}": it was resolved from the named registry '${registryName}:', which is not present in the registriesByPrefix setting.`, + { hint: `Add '${registryName}' to the registriesByPrefix setting in pnpm-workspace.yaml.` }) } } else if (name != null && name[0] === '@') { - registry = opts.registries[name.split('/')[0]] + registry = opts.registriesByScope[name.split('/')[0]] } if (!registry) { - registry = opts.registries.default + registry = opts.registriesByScope.default } let tarball!: string if (!resolution.tarball) { @@ -78,6 +72,9 @@ export function pkgSnapshotToResolution ( if (!name || !version) { throw new Error(`Couldn't get tarball URL from dependency path ${depPath}`) } - return getNpmTarballUrl(name, version, { registry }) + return getNpmTarballUrl(name, version, { + registry, + serverType: getRegistryServerType(opts, registry), + }) } } diff --git a/pnpm11/lockfile/utils/src/toLockfileResolution.ts b/pnpm11/lockfile/utils/src/toLockfileResolution.ts index d1f0b5041b..e062c03022 100644 --- a/pnpm11/lockfile/utils/src/toLockfileResolution.ts +++ b/pnpm11/lockfile/utils/src/toLockfileResolution.ts @@ -1,6 +1,17 @@ import type { LockfileResolution } from '@pnpm/lockfile.types' import { type GitResolution, isGitHostedTarballUrl, type Resolution, type TarballResolution } from '@pnpm/resolving.resolver-base' import { isCanonicalRegistryTarballUrl } from '@pnpm/resolving.tarball-url' +import type { RegistryServerType } from '@pnpm/types' + +export interface ToLockfileResolutionOptions { + registry: string + /** + * Undeclared by default, which is the strict reading: only the exact + * canonical URL is dropped. See {@link RegistryServerType}. + */ + serverType?: RegistryServerType + lockfileIncludeTarballUrl?: boolean +} export function toLockfileResolution ( pkg: { @@ -8,9 +19,9 @@ export function toLockfileResolution ( version: string }, resolution: Resolution, - registry: string, - lockfileIncludeTarballUrl?: boolean + opts: ToLockfileResolutionOptions ): LockfileResolution { + const { registry, serverType, lockfileIncludeTarballUrl } = opts if (resolution.type !== undefined || !resolution['integrity']) { // Nothing checks a git checkout against a hash — the commit pins the // content — so an `integrity` some other tool recorded on a git @@ -45,7 +56,7 @@ export function toLockfileResolution ( !lockfileIncludeTarballUrl && !gitHosted && !tarball.startsWith('file:') && - isCanonicalRegistryTarballUrl(tarball, pkg, registry) + isCanonicalRegistryTarballUrl(tarball, pkg, { registry, serverType }) ) { return { integrity: resolution['integrity'] } } diff --git a/pnpm11/lockfile/utils/test/pkgSnapshotToResolution.ts b/pnpm11/lockfile/utils/test/pkgSnapshotToResolution.ts index 1dedb8871c..e8706284a3 100644 --- a/pnpm11/lockfile/utils/test/pkgSnapshotToResolution.ts +++ b/pnpm11/lockfile/utils/test/pkgSnapshotToResolution.ts @@ -1,5 +1,5 @@ import { expect, test } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { pkgSnapshotToResolution } from '@pnpm/lockfile.utils' const GIT_TARBALL = 'https://codeload.github.com/foo/bar/tar.gz/0123456789abcdef0123456789abcdef01234567' @@ -13,7 +13,7 @@ test('pkgSnapshotToResolution() fails closed on a non-string tarball', () => { integrity: 'sha512-AAAA', tarball: ['https://attacker.example/foo.tgz'], }, - } as never, { registries: { default: 'https://registry.npmjs.org/' } })).toThrow( + } as never, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toThrow( expect.objectContaining({ code: 'ERR_PNPM_INVALID_TARBALL_RESOLUTION' }) ) }) @@ -23,7 +23,7 @@ test('pkgSnapshotToResolution()', () => { resolution: { integrity: 'AAAA', }, - }, { registries: { default: 'https://registry.npmjs.org/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({ integrity: 'AAAA', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz', }) @@ -33,7 +33,7 @@ test('pkgSnapshotToResolution()', () => { integrity: 'AAAA', tarball: '@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz', }, - }, { registries: { default: 'https://registry.npmjs.org/', '@mycompany': 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/', '@mycompany': 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/' } })).toEqual({ integrity: 'AAAA', tarball: 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz', }) @@ -43,7 +43,7 @@ test('pkgSnapshotToResolution()', () => { integrity: 'AAAA', tarball: '@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz', }, - }, { registries: { default: 'https://registry.npmjs.org/', '@mycompany': 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/', '@mycompany': 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local' } })).toEqual({ integrity: 'AAAA', tarball: 'https://mycompany.jfrog.io/mycompany/api/npm/npm-local/@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz', }) @@ -53,7 +53,7 @@ test('pkgSnapshotToResolution()', () => { integrity: 'sha512-CCCC', tarball: 'https://cdn.sheetjs.com/xlsx-0.18.9/xlsx-0.18.9.tgz', }, - }, { registries: { default: 'https://registry.npmjs.org/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({ integrity: 'sha512-CCCC', tarball: 'https://cdn.sheetjs.com/xlsx-0.18.9/xlsx-0.18.9.tgz', }) @@ -65,7 +65,7 @@ test('pkgSnapshotToResolution()', () => { integrity: 'sha512-AAAA', }, version: '1.0.0', - }, { registries: { default: 'https://registry.npmjs.org/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({ integrity: 'sha512-AAAA', tarball: 'file:test-package-1.0.0.tgz', }) @@ -80,7 +80,7 @@ test('pkgSnapshotToResolution() converts git-hosted and file: tarball snapshots' tarball: GIT_TARBALL, gitHosted: true, }, - }, { registries: { default: 'https://registry.npmjs.org/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({ tarball: GIT_TARBALL, gitHosted: true, }) @@ -89,7 +89,7 @@ test('pkgSnapshotToResolution() converts git-hosted and file: tarball snapshots' resolution: { tarball: LEGACY_GIT_TARBALL, }, - }, { registries: { default: 'https://registry.npmjs.org/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({ tarball: LEGACY_GIT_TARBALL, }) @@ -100,7 +100,7 @@ test('pkgSnapshotToResolution() converts git-hosted and file: tarball snapshots' tarball: 'file:local-pkg-1.0.0.tgz', }, version: '1.0.0', - }, { registries: { default: 'https://registry.npmjs.org/' } })).toEqual({ + }, { registriesByScope: { default: 'https://registry.npmjs.org/' } })).toEqual({ tarball: 'file:local-pkg-1.0.0.tgz', }) }) @@ -111,8 +111,8 @@ test('pkgSnapshotToResolution() reconstructs the tarball of a registry-qualified integrity: 'sha512-AAAA', }, }, { - registries: { default: 'https://registry.npmjs.org/' }, - namedRegistries: normalizeNamedRegistries({ work: 'https://npm.enterprise.example.com/' }), + registriesByScope: { default: 'https://registry.npmjs.org/' }, + registriesByPrefix: normalizeRegistriesByPrefix({ work: 'https://npm.enterprise.example.com/' }), })).toEqual({ integrity: 'sha512-AAAA', tarball: 'https://npm.enterprise.example.com/foo/-/foo-1.0.0.tgz', @@ -125,7 +125,7 @@ test('pkgSnapshotToResolution() reconstructs the tarball of a registry-qualified tarball: 'https://npm.pkg.github.com/download/@acme/private/2.1.0/abcdef', }, }, { - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, })).toEqual({ integrity: 'sha512-AAAA', tarball: 'https://npm.pkg.github.com/download/@acme/private/2.1.0/abcdef', @@ -138,7 +138,7 @@ test('pkgSnapshotToResolution() fails when a registry-qualified snapshot names a integrity: 'sha512-AAAA', }, }, { - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, })).toThrow( expect.objectContaining({ code: 'ERR_PNPM_MISSING_NAMED_REGISTRY' }) ) @@ -154,7 +154,7 @@ test('pkgSnapshotToResolution() rejects an alias that only exists on Object.prot integrity: 'sha512-AAAA', }, }, { - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, })).toThrow( expect.objectContaining({ code: 'ERR_PNPM_MISSING_NAMED_REGISTRY' }) ) diff --git a/pnpm11/lockfile/utils/test/toLockfileResolution.ts b/pnpm11/lockfile/utils/test/toLockfileResolution.ts index 8eac0fbb9e..28d34204b5 100644 --- a/pnpm11/lockfile/utils/test/toLockfileResolution.ts +++ b/pnpm11/lockfile/utils/test/toLockfileResolution.ts @@ -1,5 +1,5 @@ -import { expect, test } from '@jest/globals' -import { toLockfileResolution } from '@pnpm/lockfile.utils' +import { describe, expect, test } from '@jest/globals' +import { pkgSnapshotToResolution, toLockfileResolution } from '@pnpm/lockfile.utils' const REGISTRY = 'https://registry.npmjs.org/' const GIT_TARBALL = 'https://codeload.github.com/foo/bar/tar.gz/0123456789abcdef0123456789abcdef01234567' @@ -8,8 +8,7 @@ test('keeps the tarball when lockfileIncludeTarballUrl is true', () => { expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz' }, - REGISTRY, - true + { registry: REGISTRY, lockfileIncludeTarballUrl: true } )).toEqual({ integrity: 'sha512-AAAA', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz', @@ -20,7 +19,7 @@ test('drops the tarball for standard registry URLs by default', () => { expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz' }, - REGISTRY + { registry: REGISTRY } )).toEqual({ integrity: 'sha512-AAAA', }) @@ -30,8 +29,7 @@ test('drops the tarball for standard registry URLs when lockfileIncludeTarballUr expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz' }, - REGISTRY, - false + { registry: REGISTRY, lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', }) @@ -45,8 +43,7 @@ test('keeps the tarball for non-standard registry URLs when lockfileIncludeTarba expect(toLockfileResolution( { name: 'esprima-fb', version: '3001.1.0-dev-harmony-fb' }, { integrity: 'sha512-AAAA', tarball: 'https://example.com/esprima-fb/-/esprima-fb-3001.1.0-dev-harmony-fb.tgz' }, - REGISTRY, - false + { registry: REGISTRY, lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', tarball: 'https://example.com/esprima-fb/-/esprima-fb-3001.1.0-dev-harmony-fb.tgz', @@ -60,8 +57,7 @@ test.each([ expect(toLockfileResolution( { name: '@babel/core', version: '7.0.0' }, { integrity: 'sha512-AAAA', tarball }, - 'https://npm.example.com/', - false + { registry: 'https://npm.example.com/', lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', tarball, @@ -75,8 +71,7 @@ test.each([ expect(toLockfileResolution( { name: '@babel/core', version: '7.0.0' }, { integrity: 'sha512-AAAA', tarball }, - REGISTRY, - false + { registry: REGISTRY, lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', }) @@ -89,8 +84,7 @@ test('keeps GitHub Packages /download/ tarball URLs when lockfileIncludeTarballU expect(toLockfileResolution( { name: '@example/private', version: '1.2.3' }, { integrity: 'sha512-AAAA', tarball: 'https://npm.pkg.github.com/download/@example/private/1.2.3/0123456789abcdef0123456789abcdef01234567' }, - 'https://npm.pkg.github.com/', - false + { registry: 'https://npm.pkg.github.com/', lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', tarball: 'https://npm.pkg.github.com/download/@example/private/1.2.3/0123456789abcdef0123456789abcdef01234567', @@ -103,8 +97,7 @@ test('keeps file: tarballs even when lockfileIncludeTarballUrl is false', () => expect(toLockfileResolution( { name: 'test-package', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: 'file:test-package-1.0.0.tgz' }, - REGISTRY, - false + { registry: REGISTRY, lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', tarball: 'file:test-package-1.0.0.tgz', @@ -115,7 +108,7 @@ test('keeps file: tarballs even when lockfileIncludeTarballUrl is undefined', () expect(toLockfileResolution( { name: 'test-package', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: 'file:test-package-1.0.0.tgz' }, - REGISTRY + { registry: REGISTRY } )).toEqual({ integrity: 'sha512-AAAA', tarball: 'file:test-package-1.0.0.tgz', @@ -126,8 +119,7 @@ test('keeps git-hosted tarballs when lockfileIncludeTarballUrl is false', () => expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: GIT_TARBALL }, - REGISTRY, - false + { registry: REGISTRY, lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', tarball: GIT_TARBALL, @@ -142,8 +134,7 @@ test('keeps the path of a git-hosted tarball pointing to a subdirectory', () => expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: GIT_TARBALL, gitHosted: true, path: '/packages/foo' }, - REGISTRY, - false + { registry: REGISTRY, lockfileIncludeTarballUrl: false } )).toEqual({ integrity: 'sha512-AAAA', tarball: GIT_TARBALL, @@ -156,8 +147,7 @@ test('keeps the path of a git-hosted tarball when lockfileIncludeTarballUrl is t expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: GIT_TARBALL, gitHosted: true, path: '/packages/foo' }, - REGISTRY, - true + { registry: REGISTRY, lockfileIncludeTarballUrl: true } )).toEqual({ integrity: 'sha512-AAAA', tarball: GIT_TARBALL, @@ -170,8 +160,7 @@ test('records gitHosted on the lockfile entry when set on the resolution', () => expect(toLockfileResolution( { name: 'foo', version: '1.0.0' }, { integrity: 'sha512-AAAA', tarball: GIT_TARBALL, gitHosted: true }, - REGISTRY, - true + { registry: REGISTRY, lockfileIncludeTarballUrl: true } )).toEqual({ integrity: 'sha512-AAAA', tarball: GIT_TARBALL, @@ -188,7 +177,7 @@ test('drops an integrity recorded on a git resolution', () => { commit: 'e63c09e460269b0c535e4c34debf69bb91d57b22', integrity: 'sha512-AAAA', } as never, - REGISTRY + { registry: REGISTRY } )).toEqual({ type: 'git', repo: 'https://github.com/foo/bar.git', @@ -205,7 +194,7 @@ test('keeps a git resolution without an integrity untouched', () => { commit: 'e63c09e460269b0c535e4c34debf69bb91d57b22', path: '/packages/foo', }, - REGISTRY + { registry: REGISTRY } )).toEqual({ type: 'git', repo: 'https://github.com/foo/bar.git', @@ -213,3 +202,71 @@ test('keeps a git resolution without an integrity untouched', () => { path: '/packages/foo', }) }) + +describe('on a registry declared as Artifactory', () => { + const registry = 'https://artifactory.example/artifactory/api/npm/npm-virtual/' + const registryOptionsByUrl = { [registry]: { serverType: 'artifactory' as const } } + const pkg = { name: '@acme/widget', version: '1.2.3' } + const tarball = `${registry}@acme/widget/-/@acme/widget-1.2.3.tgz` + + test('drops the scoped tarball URL it advertises', () => { + expect(toLockfileResolution( + pkg, + { integrity: 'sha512-AAAA', tarball }, + { registry, serverType: 'artifactory' } + )).toEqual({ + integrity: 'sha512-AAAA', + }) + }) + + // The invariant the whole setting rests on: whatever the writer omits, the + // reader has to rebuild byte for byte, or a frozen install fetches a URL the + // registry never served. + test('rebuilds the dropped URL exactly', () => { + const lockfileResolution = toLockfileResolution( + pkg, + { integrity: 'sha512-AAAA', tarball }, + { registry, serverType: 'artifactory' } + ) + expect(pkgSnapshotToResolution('@acme/widget@1.2.3', { resolution: lockfileResolution }, { + registriesByScope: { default: registry }, + registryOptionsByUrl, + })).toEqual({ integrity: 'sha512-AAAA', tarball }) + }) + + test('keeps the npm-layout URL, which this registry does not serve', () => { + const npmLayoutTarball = `${registry}@acme/widget/-/widget-1.2.3.tgz` + expect(toLockfileResolution( + pkg, + { integrity: 'sha512-AAAA', tarball: npmLayoutTarball }, + { registry, serverType: 'artifactory' } + )).toEqual({ + integrity: 'sha512-AAAA', + tarball: npmLayoutTarball, + }) + }) + + test('keeps the scoped tarball URL when lockfileIncludeTarballUrl is true', () => { + expect(toLockfileResolution( + pkg, + { integrity: 'sha512-AAAA', tarball }, + { registry, serverType: 'artifactory', lockfileIncludeTarballUrl: true } + )).toEqual({ + integrity: 'sha512-AAAA', + tarball, + }) + }) +}) + +test('keeps the Artifactory-shaped URL of a registry that was not declared as Artifactory', () => { + const registry = 'https://artifactory.example/artifactory/api/npm/npm-virtual/' + const tarball = `${registry}@acme/widget/-/@acme/widget-1.2.3.tgz` + expect(toLockfileResolution( + { name: '@acme/widget', version: '1.2.3' }, + { integrity: 'sha512-AAAA', tarball }, + { registry } + )).toEqual({ + integrity: 'sha512-AAAA', + tarball, + }) +}) diff --git a/pnpm11/patching/commands/src/patch.ts b/pnpm11/patching/commands/src/patch.ts index a9cb01b928..9c0494f0b0 100644 --- a/pnpm11/patching/commands/src/patch.ts +++ b/pnpm11/patching/commands/src/patch.ts @@ -60,7 +60,7 @@ export function help (): string { export type PatchCommandOptions = Pick +export type WritePackageOptions = CreateStoreControllerOptions & Pick export async function writePackage (dep: ParseWantedDependencyResult, dest: string, opts: WritePackageOptions): Promise { const store = await createStoreController({ diff --git a/pnpm11/patching/commands/test/patch.test.ts b/pnpm11/patching/commands/test/patch.test.ts index e3b16617a0..380d3df0b4 100644 --- a/pnpm11/patching/commands/test/patch.test.ts +++ b/pnpm11/patching/commands/test/patch.test.ts @@ -45,7 +45,7 @@ const f = fixtures(import.meta.dirname) const basePatchOption = { pnpmHomeDir: '', configByUri: {}, - registries: { default: `http://localhost:${REGISTRY_MOCK_PORT}/` }, + registriesByScope: { default: `http://localhost:${REGISTRY_MOCK_PORT}/` }, userConfig: {}, virtualStoreDir: 'node_modules/.pnpm', virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, diff --git a/pnpm11/pnpm/src/switchCliVersion.test.ts b/pnpm11/pnpm/src/switchCliVersion.test.ts index ae39d6361f..098d7d0f83 100644 --- a/pnpm11/pnpm/src/switchCliVersion.test.ts +++ b/pnpm11/pnpm/src/switchCliVersion.test.ts @@ -132,7 +132,7 @@ test('switchCliVersion uses trusted package-manager registries instead of projec noProxy: 'project.internal', packageManagerRegistries, packageManagerNetworkConfig, - registries: projectRegistries, + registriesByScope: projectRegistries, strictSsl: false, virtualStoreDirMaxLength: 120, } as unknown as Config @@ -153,15 +153,15 @@ test('switchCliVersion uses trusted package-manager registries instead of projec httpProxy: packageManagerNetworkConfig.httpProxy, httpsProxy: packageManagerNetworkConfig.httpsProxy, noProxy: packageManagerNetworkConfig.noProxy, - registries: packageManagerRegistries, + registriesByScope: packageManagerRegistries, strictSsl: packageManagerNetworkConfig.strictSsl, })) expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({ - registries: packageManagerRegistries, + registriesByScope: packageManagerRegistries, })) expect(installPnpmToStore).not.toHaveBeenCalledWith('9.3.0', expect.objectContaining({ - registries: projectRegistries, + registriesByScope: projectRegistries, })) exit.mockRestore() @@ -183,7 +183,7 @@ test('switchCliVersion defaults package-manager registries to npmjs instead of p httpProxy: 'http://project-http-proxy.example.com:8080', httpsProxy: 'http://project-https-proxy.example.com:8080', noProxy: 'project.internal', - registries: projectRegistries, + registriesByScope: projectRegistries, strictSsl: false, virtualStoreDirMaxLength: 120, } as unknown as Config @@ -204,15 +204,15 @@ test('switchCliVersion defaults package-manager registries to npmjs instead of p httpProxy: undefined, httpsProxy: undefined, noProxy: undefined, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, strictSsl: undefined, })) expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, })) expect(installPnpmToStore).not.toHaveBeenCalledWith('9.3.0', expect.objectContaining({ - registries: projectRegistries, + registriesByScope: projectRegistries, })) exit.mockRestore() @@ -224,7 +224,7 @@ test('switchCliVersion installs from a registry-only package-manager lockfile wi }) as typeof process.exit) await expect(switchCliVersion({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, virtualStoreDirMaxLength: 120, } as unknown as Config, { rootProjectManifestDir: '/repo', @@ -283,7 +283,7 @@ test('switchCliVersion accepts registry-only package-manager lockfiles with peer readEnvLockfile.mockResolvedValueOnce(peerLockfile) await expect(switchCliVersion({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, virtualStoreDirMaxLength: 120, } as unknown as Config, { rootProjectManifestDir: '/repo', @@ -321,7 +321,7 @@ test('switchCliVersion discards package-manager lockfile resolutions with non-in }) as typeof process.exit) try { await expect(switchCliVersion({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, virtualStoreDirMaxLength: 120, } as unknown as Config, { rootProjectManifestDir: '/repo', @@ -372,7 +372,7 @@ test('switchCliVersion discards package-manager lockfile dependencies with non-r }) as typeof process.exit) try { await expect(switchCliVersion({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, virtualStoreDirMaxLength: 120, } as unknown as Config, { rootProjectManifestDir: '/repo', @@ -408,7 +408,7 @@ test('switchCliVersion rejects a package-manager lockfile that is still invalid resolvePackageManagerIntegrities.mockResolvedValueOnce(poisonLinuxX64(envLockfileFor('9.3.0'))) await expect(switchCliVersion({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, virtualStoreDirMaxLength: 120, } as unknown as Config, { rootProjectManifestDir: '/repo', diff --git a/pnpm11/pnpm/src/switchCliVersion.ts b/pnpm11/pnpm/src/switchCliVersion.ts index 7dbdb5f107..b5af1bf0d2 100644 --- a/pnpm11/pnpm/src/switchCliVersion.ts +++ b/pnpm11/pnpm/src/switchCliVersion.ts @@ -42,7 +42,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig }) envLockfile = await resolvePackageManagerIntegrities(pm.version, { envLockfile, - registries: packageManagerConfig.registries, + registriesByScope: packageManagerConfig.registriesByScope, rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, @@ -59,7 +59,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig }) envLockfile = await resolvePackageManagerIntegrities(pmVersion, { envLockfile, - registries: packageManagerConfig.registries, + registriesByScope: packageManagerConfig.registriesByScope, rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, @@ -110,7 +110,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): storeToUse ??= await createStoreController({ ...config, ...context, ...packageManagerConfig }) envLockfile = await resolvePackageManagerIntegrities(pm.version, { envLockfile, - registries: packageManagerConfig.registries, + registriesByScope: packageManagerConfig.registriesByScope, rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, @@ -146,7 +146,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): envLockfile, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, - registries: packageManagerConfig.registries, + registriesByScope: packageManagerConfig.registriesByScope, virtualStoreDirMaxLength: config.virtualStoreDirMaxLength, packageManager: { name: packageManager.name, version: packageManager.version }, // Network settings so the engine identity check can reach the canonical diff --git a/pnpm11/pnpm/src/syncEnvLockfile.test.ts b/pnpm11/pnpm/src/syncEnvLockfile.test.ts index 4659c50ddd..4f5408e19a 100644 --- a/pnpm11/pnpm/src/syncEnvLockfile.test.ts +++ b/pnpm11/pnpm/src/syncEnvLockfile.test.ts @@ -50,7 +50,7 @@ function makeContext (rootDir: string, overrides: Partial = {}): } as ConfigContext } -const baseConfig = { registries: { default: 'https://registry.npmjs.org/' } } as unknown as Config +const baseConfig = { registriesByScope: { default: 'https://registry.npmjs.org/' } } as unknown as Config test('no-op when wantedPackageManager is undefined', async () => { const dir = tempDir() @@ -164,7 +164,7 @@ test('uses trusted package-manager registries instead of project registries', as noProxy: 'project.internal', packageManagerRegistries, packageManagerNetworkConfig, - registries: projectRegistries, + registriesByScope: projectRegistries, strictSsl: false, } as unknown as Config, makeContext(dir, { wantedPackageManager: { name: 'pnpm', version: packageManager.version, fromDevEngines: true }, @@ -175,14 +175,14 @@ test('uses trusted package-manager registries instead of project registries', as httpProxy: packageManagerNetworkConfig.httpProxy, httpsProxy: packageManagerNetworkConfig.httpsProxy, noProxy: packageManagerNetworkConfig.noProxy, - registries: packageManagerRegistries, + registriesByScope: packageManagerRegistries, strictSsl: packageManagerNetworkConfig.strictSsl, })) expect(resolvePackageManagerIntegrities).toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ - registries: packageManagerRegistries, + registriesByScope: packageManagerRegistries, })) expect(resolvePackageManagerIntegrities).not.toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ - registries: projectRegistries, + registriesByScope: projectRegistries, })) }) @@ -200,7 +200,7 @@ test('defaults package-manager registries to npmjs instead of project registries httpProxy: 'http://project-http-proxy.example.com:8080', httpsProxy: 'http://project-https-proxy.example.com:8080', noProxy: 'project.internal', - registries: projectRegistries, + registriesByScope: projectRegistries, strictSsl: false, } as unknown as Config, makeContext(dir, { wantedPackageManager: { name: 'pnpm', version: packageManager.version, fromDevEngines: true }, @@ -211,14 +211,14 @@ test('defaults package-manager registries to npmjs instead of project registries httpProxy: undefined, httpsProxy: undefined, noProxy: undefined, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, strictSsl: undefined, })) expect(resolvePackageManagerIntegrities).toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, })) expect(resolvePackageManagerIntegrities).not.toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ - registries: projectRegistries, + registriesByScope: projectRegistries, })) }) diff --git a/pnpm11/pnpm/src/syncEnvLockfile.ts b/pnpm11/pnpm/src/syncEnvLockfile.ts index 64fbb44e29..faedc197df 100644 --- a/pnpm11/pnpm/src/syncEnvLockfile.ts +++ b/pnpm11/pnpm/src/syncEnvLockfile.ts @@ -36,7 +36,7 @@ export async function syncEnvLockfile (config: Config, context: ConfigContext): try { await resolvePackageManagerIntegrities(packageManager.version, { envLockfile: envLockfile ?? undefined, - registries: packageManagerConfig.registries, + registriesByScope: packageManagerConfig.registriesByScope, rootDir: context.rootProjectManifestDir, storeController: store.ctrl, storeDir: store.dir, diff --git a/pnpm11/pnpm/test/dlx.ts b/pnpm11/pnpm/test/dlx.ts index 123dbdf5f9..1e67cedf94 100644 --- a/pnpm11/pnpm/test/dlx.ts +++ b/pnpm11/pnpm/test/dlx.ts @@ -13,15 +13,15 @@ import { writeYamlFileSync } from 'write-yaml-file' import { execPnpm, execPnpmSync } from './utils/index.js' -let registries: Record +let registriesByScope: Record beforeAll(async () => { const { config } = await getConfig({ cliOptions: {}, packageManager: { name: '', version: '' } }) - registries = config.registries - registries.default = `http://localhost:${REGISTRY_MOCK_PORT}/` + registriesByScope = config.registriesByScope + registriesByScope.default = `http://localhost:${REGISTRY_MOCK_PORT}/` }) -const createCacheKey = (...packages: string[]): string => dlx.createCacheKey({ packages, registries }) +const createCacheKey = (...packages: string[]): string => dlx.createCacheKey({ packages, registriesByScope }) const describeOnLinuxOnly = process.platform === 'linux' ? describe : describe.skip const skipOnWindows = process.platform === 'win32' ? test.skip : test diff --git a/pnpm11/registry-access/commands/src/access.ts b/pnpm11/registry-access/commands/src/access.ts index 4f664a98e8..5019467e74 100644 --- a/pnpm11/registry-access/commands/src/access.ts +++ b/pnpm11/registry-access/commands/src/access.ts @@ -4,7 +4,7 @@ import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type FetchFromRegistry } from '@pnpm/network.fetch' import npa from '@pnpm/npm-package-arg' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { renderHelp } from 'render-help' import { normalizeRegistryUrl, rcOptionsTypes, readErrorBody } from './common.js' @@ -13,8 +13,8 @@ export { rcOptionsTypes } const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/' -function getRegistries (opts: AccessOptions): Registries { - return opts.registries ?? { default: DEFAULT_REGISTRY_URL } +function getRegistries (opts: AccessOptions): RegistriesByScope { + return opts.registriesByScope ?? { default: DEFAULT_REGISTRY_URL } } export function cliOptionsTypes (): Record { @@ -78,7 +78,7 @@ export function help (): string { name: '--json', }, { - description: 'One-time password for registries that require two-factor authentication.', + description: 'One-time password for registriesByScope that require two-factor authentication.', name: '--otp', }, ], @@ -103,7 +103,7 @@ export interface AccessOptions extends CreateFetchFromRegistryOptions { otp?: string } configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } export async function handler ( @@ -159,7 +159,7 @@ export async function handler ( } interface ListRequestContext { - registries: Registries + registriesByScope: RegistriesByScope fetchFromRegistry: FetchFromRegistry authHeader: string | undefined jsonMode: boolean @@ -169,11 +169,11 @@ async function listPackages ( opts: AccessOptions, params: string[] ): Promise { - const registries = getRegistries(opts) + const registriesByScope = getRegistries(opts) const fetchFromRegistry = createFetchFromRegistry(opts) - const authHeader = getAuthHeaderForRegistry(opts.configByUri, registries.default ?? DEFAULT_REGISTRY_URL) + const authHeader = getAuthHeaderForRegistry(opts.configByUri, registriesByScope.default ?? DEFAULT_REGISTRY_URL) const jsonMode = opts.cliOptions?.json ?? false - const ctx: ListRequestContext = { registries, fetchFromRegistry, authHeader, jsonMode } + const ctx: ListRequestContext = { registriesByScope, fetchFromRegistry, authHeader, jsonMode } let entity: string | undefined let entityType: 'user' | 'org' | 'team' | undefined @@ -200,7 +200,7 @@ async function listPackages ( } async function listOwnPackages (ctx: ListRequestContext): Promise { - const registryUrl = normalizeRegistryUrl(ctx.registries.default ?? DEFAULT_REGISTRY_URL) + const registryUrl = normalizeRegistryUrl(ctx.registriesByScope.default ?? DEFAULT_REGISTRY_URL) const url = new URL('-/-/package?format=cli', registryUrl).href return fetchListResponse(url, ctx) } @@ -210,7 +210,7 @@ async function listEntityPackages ( entity: string, ctx: ListRequestContext ): Promise { - const registryUrl = normalizeRegistryUrl(ctx.registries.default ?? DEFAULT_REGISTRY_URL) + const registryUrl = normalizeRegistryUrl(ctx.registriesByScope.default ?? DEFAULT_REGISTRY_URL) let listUrl: string if (entityType === 'team') { @@ -266,8 +266,8 @@ async function listCollaborators ( const packageName = params[0] const user = params[1] - const registries = getRegistries(opts) - const registryUrl = pickRegistryForPackage(registries, packageName) + const registriesByScope = getRegistries(opts) + const registryUrl = pickRegistryForPackage(registriesByScope, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const jsonMode = opts.cliOptions?.json ?? false @@ -317,8 +317,8 @@ async function getStatus ( } const packageName = params[0] - const registries = getRegistries(opts) - const registryUrl = pickRegistryForPackage(registries, packageName) + const registriesByScope = getRegistries(opts) + const registryUrl = pickRegistryForPackage(registriesByScope, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const jsonMode = opts.cliOptions?.json ?? false @@ -375,8 +375,8 @@ async function setStatus ( throw new PnpmError('ACCESS_SET_STATUS_UNSCOPED', 'Access settings can only be changed for scoped packages (@scope/name). Unscoped packages are always public.') } - const registries = getRegistries(opts) - const registryUrl = pickRegistryForPackage(registries, packageName) + const registriesByScope = getRegistries(opts) + const registryUrl = pickRegistryForPackage(registriesByScope, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const otp = opts.cliOptions?.otp @@ -417,8 +417,8 @@ async function setMfa ( throw new PnpmError('ACCESS_SET_MFA_PACKAGE_REQUIRED', 'Package name is required (e.g., pnpm access set mfa=automation @scope/pkg)') } - const registries = getRegistries(opts) - const registryUrl = pickRegistryForPackage(registries, packageName) + const registriesByScope = getRegistries(opts) + const registryUrl = pickRegistryForPackage(registriesByScope, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const otp = opts.cliOptions?.otp @@ -467,8 +467,8 @@ async function grantAccess ( } const [scope, team] = scopeTeam.split(':') - const registries = getRegistries(opts) - const registryUrl = pickRegistryForPackage(registries, packageName) + const registriesByScope = getRegistries(opts) + const registryUrl = pickRegistryForPackage(registriesByScope, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const otp = opts.cliOptions?.otp @@ -510,8 +510,8 @@ async function revokeAccess ( } const [scope, team] = scopeTeam.split(':') - const registries = getRegistries(opts) - const registryUrl = pickRegistryForPackage(registries, packageName) + const registriesByScope = getRegistries(opts) + const registryUrl = pickRegistryForPackage(registriesByScope, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const otp = opts.cliOptions?.otp diff --git a/pnpm11/registry-access/commands/src/deprecation/common.ts b/pnpm11/registry-access/commands/src/deprecation/common.ts index 5d32084db7..7e28b997ef 100644 --- a/pnpm11/registry-access/commands/src/deprecation/common.ts +++ b/pnpm11/registry-access/commands/src/deprecation/common.ts @@ -4,7 +4,7 @@ import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions } from '@pnpm/network.fetch' import npa from '@pnpm/npm-package-arg' import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import semver from 'semver' import { parsePackageSpec, rcOptionsTypes } from '../common.js' @@ -23,7 +23,7 @@ export interface DeprecateOptions extends CreateFetchFromRegistryOptions { otp?: string } configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } interface UpdateDeprecationOptions { @@ -36,7 +36,7 @@ export async function updateDeprecation ( opts: DeprecateOptions, { deprecated, packageName, versionRange }: UpdateDeprecationOptions ): Promise { - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}) diff --git a/pnpm11/registry-access/commands/src/distTag.ts b/pnpm11/registry-access/commands/src/distTag.ts index d87d3aec01..310528413d 100644 --- a/pnpm11/registry-access/commands/src/distTag.ts +++ b/pnpm11/registry-access/commands/src/distTag.ts @@ -15,7 +15,7 @@ import { } from '@pnpm/network.web-auth' import npa from '@pnpm/npm-package-arg' import { setDistTag } from '@pnpm/registry-access.client' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { renderHelp } from 'render-help' import semver from 'semver' @@ -83,7 +83,7 @@ export interface DistTagOptions extends CreateFetchFromRegistryOptions { otp?: string } configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } export async function handler ( @@ -114,7 +114,7 @@ async function distTagLs ( } const packageName = params[0] - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) @@ -147,7 +147,7 @@ async function distTagAdd ( const tag = params[1] ?? 'latest' - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const cliOtp = opts.cliOptions?.otp @@ -186,7 +186,7 @@ async function distTagRm ( throw new PnpmError('DIST_TAG_RM_LATEST', 'Removing the "latest" dist-tag is not allowed') } - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const cliOtp = opts.cliOptions?.otp diff --git a/pnpm11/registry-access/commands/src/owner.ts b/pnpm11/registry-access/commands/src/owner.ts index 1c90229b54..8dd61895d3 100644 --- a/pnpm11/registry-access/commands/src/owner.ts +++ b/pnpm11/registry-access/commands/src/owner.ts @@ -3,7 +3,7 @@ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package' import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type FetchFromRegistry } from '@pnpm/network.fetch' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { renderHelp } from 'render-help' import { parsePackageSpec, rcOptionsTypes } from './common.js' @@ -70,7 +70,7 @@ export interface OwnerOptions extends CreateFetchFromRegistryOptions { otp?: string } configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } export async function handler ( @@ -99,7 +99,7 @@ async function ownerLs ( } const { name: packageName, escapedName } = parsePackageSpec(params[0]) - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) @@ -123,7 +123,7 @@ async function ownerAdd ( const { name: packageName, escapedName } = parsePackageSpec(params[0]) const owner = params[1] - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const otp = opts.cliOptions?.otp @@ -157,7 +157,7 @@ async function ownerRm ( const { name: packageName, escapedName } = parsePackageSpec(params[0]) const owner = params[1] - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const fetchFromRegistry = createFetchFromRegistry(opts) const otp = opts.cliOptions?.otp diff --git a/pnpm11/registry-access/commands/src/search.ts b/pnpm11/registry-access/commands/src/search.ts index 47ce1606fc..210f58dec0 100644 --- a/pnpm11/registry-access/commands/src/search.ts +++ b/pnpm11/registry-access/commands/src/search.ts @@ -2,7 +2,7 @@ import { docsUrl } from '@pnpm/cli.utils' import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions } from '@pnpm/network.fetch' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import chalk from 'chalk' import { renderHelp } from 'render-help' @@ -62,7 +62,7 @@ export interface SearchResult { export interface SearchOptions extends CreateFetchFromRegistryOptions { configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope json?: boolean searchLimit?: number } @@ -77,7 +77,7 @@ export async function handler ( throw new PnpmError('MISSING_SEARCH_QUERY', 'Search query is required. Usage: pnpm search ') } - const registry = opts.registries?.default ?? 'https://registry.npmjs.org/' + const registry = opts.registriesByScope?.default ?? 'https://registry.npmjs.org/' const registryUrl = new URL(registry) if (!registryUrl.pathname.endsWith('/')) { registryUrl.pathname = `${registryUrl.pathname}/` diff --git a/pnpm11/registry-access/commands/src/star/common.ts b/pnpm11/registry-access/commands/src/star/common.ts index 64579155b8..e9937ee70f 100644 --- a/pnpm11/registry-access/commands/src/star/common.ts +++ b/pnpm11/registry-access/commands/src/star/common.ts @@ -2,7 +2,7 @@ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package' import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions } from '@pnpm/network.fetch' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { normalizeRegistryUrl, parsePackageSpec, rcOptionsTypes as commonRcOptionsTypes } from '../common.js' import { fetchWhoami } from '../whoami.js' @@ -19,7 +19,7 @@ export function rcOptionsTypes (): Record { export interface StarOptions extends CreateFetchFromRegistryOptions { configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } interface StarActionArgs { @@ -36,7 +36,7 @@ interface PackumentWithStars { export async function performStarAction (opts: StarOptions, { packageName, star }: StarActionArgs): Promise { const { escapedName } = parsePackageSpec(packageName) const registryUrl = normalizeRegistryUrl( - pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) ) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName) const action = star ? 'star' : 'unstar' diff --git a/pnpm11/registry-access/commands/src/star/stars.ts b/pnpm11/registry-access/commands/src/star/stars.ts index b0fe6a38b1..7c6d844113 100644 --- a/pnpm11/registry-access/commands/src/star/stars.ts +++ b/pnpm11/registry-access/commands/src/star/stars.ts @@ -20,7 +20,7 @@ export function help (): string { } export async function handler (opts: StarOptions, params: string[]): Promise { - const registryUrl = normalizeRegistryUrl(opts.registries?.default ?? 'https://registry.npmjs.org/') + const registryUrl = normalizeRegistryUrl(opts.registriesByScope?.default ?? 'https://registry.npmjs.org/') const fetchFromRegistry = createFetchFromRegistry(opts) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl) diff --git a/pnpm11/registry-access/commands/src/team.ts b/pnpm11/registry-access/commands/src/team.ts index 4d77809f32..f7338b7d1e 100644 --- a/pnpm11/registry-access/commands/src/team.ts +++ b/pnpm11/registry-access/commands/src/team.ts @@ -3,7 +3,7 @@ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package' import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type FetchFromRegistry } from '@pnpm/network.fetch' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { renderHelp } from 'render-help' import { normalizeRegistryUrl, rcOptionsTypes, readErrorBody } from './common.js' @@ -90,7 +90,7 @@ export interface TeamOptions extends CreateFetchFromRegistryOptions { json?: boolean } configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } export async function handler ( @@ -414,7 +414,7 @@ function getRegistryAndAuthForOrg ( scope: string ): { registryUrl: string, authHeader: string | undefined } { const pkgName = `@${scope}/__pnpm_team__` - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, pkgName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, pkgName) const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, pkgName) if (!authHeader) { throw new PnpmError('TEAM_MISSING_AUTH', 'Authentication required for registry access') diff --git a/pnpm11/registry-access/commands/src/unpublish.ts b/pnpm11/registry-access/commands/src/unpublish.ts index 583566e6e4..96c28eaceb 100644 --- a/pnpm11/registry-access/commands/src/unpublish.ts +++ b/pnpm11/registry-access/commands/src/unpublish.ts @@ -4,7 +4,7 @@ import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type FetchFromRegistry } from '@pnpm/network.fetch' import npa from '@pnpm/npm-package-arg' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { renderHelp } from 'render-help' import semver from 'semver' @@ -58,7 +58,7 @@ export interface UnpublishOptions extends CreateFetchFromRegistryOptions { otp?: string } configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } interface PackumentResponse { @@ -102,7 +102,7 @@ async function unpublishPackage ( versionRange: string | undefined, opts: UnpublishOptions ): Promise { - const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName) + const registryUrl = pickRegistryForPackage(opts.registriesByScope ?? { default: 'https://registry.npmjs.org/' }, packageName) const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}) diff --git a/pnpm11/registry-access/commands/src/whoami.ts b/pnpm11/registry-access/commands/src/whoami.ts index ef54a49d4a..78aa84846b 100644 --- a/pnpm11/registry-access/commands/src/whoami.ts +++ b/pnpm11/registry-access/commands/src/whoami.ts @@ -2,7 +2,7 @@ import { docsUrl } from '@pnpm/cli.utils' import { PnpmError } from '@pnpm/error' import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header' import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type FetchFromRegistry } from '@pnpm/network.fetch' -import type { Registries, RegistryConfig } from '@pnpm/types' +import type { RegistriesByScope, RegistryConfig } from '@pnpm/types' import { renderHelp } from 'render-help' import { normalizeRegistryUrl, rcOptionsTypes as commonRcOptionsTypes } from './common.js' @@ -19,7 +19,7 @@ export function rcOptionsTypes (): Record { export interface WhoamiOptions extends CreateFetchFromRegistryOptions { configByUri?: Record - registries?: Registries + registriesByScope?: RegistriesByScope } export const commandNames = ['whoami'] @@ -33,7 +33,7 @@ export function help (): string { } export async function handler (opts: WhoamiOptions): Promise { - const registryUrl = normalizeRegistryUrl(opts.registries?.default ?? 'https://registry.npmjs.org/') + const registryUrl = normalizeRegistryUrl(opts.registriesByScope?.default ?? 'https://registry.npmjs.org/') const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}) const authHeader = getAuthHeader(registryUrl) if (!authHeader) { diff --git a/pnpm11/registry-access/commands/test/access.ts b/pnpm11/registry-access/commands/test/access.ts index cddebcc247..7190aea942 100644 --- a/pnpm11/registry-access/commands/test/access.ts +++ b/pnpm11/registry-access/commands/test/access.ts @@ -43,7 +43,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, []) }).rejects.toThrow('A subcommand is required') }) @@ -56,7 +56,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['get', 'status', '@pnpm/test']) expect(result).toContain('package: @pnpm/test') @@ -67,7 +67,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['get', 'status']) }).rejects.toThrow('Package name is required') }) @@ -81,7 +81,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['get', 'status', 'nonexistent-pkg-test']) }).rejects.toThrow('not found') }) @@ -95,7 +95,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=public', '@pnpm/test']) expect(result).toBe('@pnpm/test: public') @@ -110,7 +110,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=private', '@pnpm/test']) expect(result).toBe('@pnpm/test: restricted') @@ -120,7 +120,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set']) }).rejects.toThrow('A value is required') }) @@ -129,7 +129,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=invalid', '@pnpm/test']) }).rejects.toThrow('Invalid access value') }) @@ -138,7 +138,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=public', 'unscoped-pkg']) }).rejects.toThrow('Unscoped packages are always public') }) @@ -147,7 +147,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=public']) }).rejects.toThrow('Package name is required') }) @@ -161,7 +161,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=public', '@pnpm/test']) }).rejects.toThrow('logged in') }) @@ -175,7 +175,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'status=public', '@pnpm/test']) }).rejects.toThrow('permission') }) @@ -189,7 +189,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'mfa=automation', '@pnpm/test']) expect(result).toBe('@pnpm/test: mfa=automation') @@ -204,7 +204,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'mfa=none', '@pnpm/test']) expect(result).toBe('@pnpm/test: mfa=none') @@ -214,7 +214,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['set', 'mfa=invalid', '@pnpm/test']) }).rejects.toThrow('Invalid MFA value') }) @@ -228,7 +228,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['grant', 'read-only', 'myscope:developers', '@myscope/pkg']) expect(result).toBe('+myscope:developers (read-only): @myscope/pkg') @@ -243,7 +243,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['grant', 'read-write', 'myscope:developers', '@myscope/pkg']) expect(result).toBe('+myscope:developers (read-write): @myscope/pkg') @@ -253,7 +253,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['grant']) }).rejects.toThrow('Permissions and scope:team are required') }) @@ -262,7 +262,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['grant', 'invalid', 'myscope:developers', '@myscope/pkg']) }).rejects.toThrow('Invalid permissions') }) @@ -271,7 +271,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['grant', 'read-only', 'invalidteam', '@myscope/pkg']) }).rejects.toThrow('Invalid team') }) @@ -285,7 +285,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['revoke', 'myscope:developers', '@myscope/pkg']) expect(result).toBe('-myscope:developers: @myscope/pkg') @@ -295,7 +295,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['revoke']) }).rejects.toThrow('scope:team and package name are required') }) @@ -304,7 +304,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['revoke', 'invalidteam', '@myscope/pkg']) }).rejects.toThrow('Invalid team') }) @@ -317,7 +317,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'packages', 'alice']) expect(result).toContain('@alice/pkg: read-write') @@ -332,7 +332,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'packages', '@myscope']) expect(result).toContain('@myscope/pkg: read-write') @@ -346,7 +346,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'packages', 'myscope:developers']) expect(result).toContain('@myscope/pkg: read-write') @@ -363,7 +363,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'collaborators', '@pnpm/test']) expect(result).toContain('alice') @@ -376,7 +376,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'collaborators']) }).rejects.toThrow('Package name is required') }) @@ -391,7 +391,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'collaborators', '@pnpm/test', 'alice']) expect(result).toContain('alice') @@ -406,7 +406,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['public', '@pnpm/test']) expect(result).toBe('@pnpm/test: public') @@ -421,7 +421,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['restricted', '@pnpm/test']) expect(result).toBe('@pnpm/test: restricted') @@ -435,7 +435,7 @@ describe('access command', () => { const result = await access.handler({ cliOptions: { json: true }, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', 'packages', 'alice']) const parsed = JSON.parse(result) @@ -446,7 +446,7 @@ describe('access command', () => { await expect(async () => { await access.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['unknown']) }).rejects.toThrow('Unknown subcommand') }) diff --git a/pnpm11/registry-access/commands/test/dist-tag.ts b/pnpm11/registry-access/commands/test/dist-tag.ts index a3d6dc8b37..991aa85c3f 100644 --- a/pnpm11/registry-access/commands/test/dist-tag.ts +++ b/pnpm11/registry-access/commands/test/dist-tag.ts @@ -68,7 +68,7 @@ test('dist-tag ls: should use package-scoped auth', async () => { '@scope': { authToken: 'scoped-token' }, }, }, - registries: { default: 'https://registry.example.com/' }, + registriesByScope: { default: 'https://registry.example.com/' }, }, ['ls', '@scope/pkg']) expect(result).toBe('latest: 1.0.0') diff --git a/pnpm11/registry-access/commands/test/owner.ts b/pnpm11/registry-access/commands/test/owner.ts index c1682c9c21..b03d477f2d 100644 --- a/pnpm11/registry-access/commands/test/owner.ts +++ b/pnpm11/registry-access/commands/test/owner.ts @@ -47,7 +47,7 @@ describe('owner command', () => { const result = await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['ls', '@pnpm/test']) @@ -60,7 +60,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['ls']) @@ -76,7 +76,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['ls', 'nonexistent-pkg-12345']) @@ -92,7 +92,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['ls', '@pnpm/test']) @@ -108,7 +108,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['ls', '@pnpm/test']) @@ -124,7 +124,7 @@ describe('owner command', () => { const result = await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['add', '@pnpm/test', 'newowner']) @@ -136,7 +136,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['add']) @@ -147,7 +147,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['add', '@pnpm/test']) @@ -162,7 +162,7 @@ describe('owner command', () => { const result = await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['rm', '@pnpm/test', 'newowner']) @@ -174,7 +174,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['rm']) @@ -185,7 +185,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['rm', '@pnpm/test']) @@ -201,7 +201,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, }, ['add', '@pnpm/test', 'newowner']) @@ -217,7 +217,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, }, ['add', '@pnpm/test', 'newowner']) @@ -233,7 +233,7 @@ describe('owner command', () => { await expect(async () => { await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['rm', 'nonexistent-pkg-12345', 'oldowner']) @@ -250,7 +250,7 @@ describe('owner command', () => { const result = await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['@pnpm/test']) @@ -268,7 +268,7 @@ describe('owner command', () => { const result = await owner.handler({ cliOptions: {}, - registries: { + registriesByScope: { default: REGISTRY_URL, }, }, ['list', '@pnpm/test']) diff --git a/pnpm11/registry-access/commands/test/search.ts b/pnpm11/registry-access/commands/test/search.ts index 308011f594..521600ccb4 100644 --- a/pnpm11/registry-access/commands/test/search.ts +++ b/pnpm11/registry-access/commands/test/search.ts @@ -7,7 +7,7 @@ const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}` const SEARCH_OPTS = { ...DEFAULT_OPTS, - registries: { default: `${REGISTRY_URL}/` }, + registriesByScope: { default: `${REGISTRY_URL}/` }, } test('search: missing query throws error', async () => { @@ -43,7 +43,7 @@ test('search: non-OK registry response throws SEARCH_FAILED', async () => { await expect( search.handler({ ...SEARCH_OPTS, - registries: { default: `${REGISTRY_URL}/nonexistent-registry-path/` }, + registriesByScope: { default: `${REGISTRY_URL}/nonexistent-registry-path/` }, }, ['create-touch-file-one-bin']) ).rejects.toMatchObject({ code: 'ERR_PNPM_SEARCH_FAILED' }) }) diff --git a/pnpm11/registry-access/commands/test/star.ts b/pnpm11/registry-access/commands/test/star.ts index 9a6ce72722..d58320edce 100644 --- a/pnpm11/registry-access/commands/test/star.ts +++ b/pnpm11/registry-access/commands/test/star.ts @@ -29,7 +29,7 @@ describe('star', () => { await expect(star.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['foo'])).resolves.toBeUndefined() }) @@ -58,20 +58,20 @@ describe('star', () => { await expect(star.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['foo'])).resolves.toBeUndefined() }) it('throws when no package name is given', async () => { await expect(star.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, [])).rejects.toThrow('Package name is required') }) it('throws when not logged in', async () => { await expect(star.handler({ - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['foo'])).rejects.toThrow('You must be logged in to star') }) @@ -84,7 +84,7 @@ describe('star', () => { await expect(star.handler({ configByUri: CONFIG_BY_URI, - registries: { default: `${REGISTRY}/custom-prefix` }, + registriesByScope: { default: `${REGISTRY}/custom-prefix` }, }, ['foo'])).resolves.toBeUndefined() }) @@ -98,14 +98,14 @@ describe('star', () => { await expect(star.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['@scope/pkg'])).resolves.toBeUndefined() }) it('throws a helpful error for invalid package specs', async () => { await expect(star.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['@@invalid'])).rejects.toThrow('Invalid package spec') }) }) @@ -128,14 +128,14 @@ describe('unstar', () => { await expect(unstar.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['foo'])).resolves.toBeUndefined() }) it('throws when no package name is given', async () => { await expect(unstar.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, [])).rejects.toThrow('Package name is required') }) }) @@ -162,7 +162,7 @@ describe('stars', () => { const result = await stars.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, []) expect(result).toBe('foo\nbar') @@ -177,7 +177,7 @@ describe('stars', () => { const result = await stars.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['bob']) expect(result.split('\n').sort()).toEqual(['bar', 'foo']) @@ -185,7 +185,7 @@ describe('stars', () => { it('throws when no user is given and the user is not logged in', async () => { await expect(stars.handler({ - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, [])).rejects.toThrow('You must be logged in') }) @@ -202,7 +202,7 @@ describe('stars', () => { await expect(stars.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['bob'])).rejects.toThrow('User "bob" not found') }) @@ -215,7 +215,7 @@ describe('stars', () => { const result = await stars.handler({ configByUri: CONFIG_BY_URI, - registries: { default: `${REGISTRY}/custom-prefix` }, + registriesByScope: { default: `${REGISTRY}/custom-prefix` }, }, ['bob']) expect(result).toBe('foo') diff --git a/pnpm11/registry-access/commands/test/team.ts b/pnpm11/registry-access/commands/test/team.ts index 14b57fb82e..d9d6b40a78 100644 --- a/pnpm11/registry-access/commands/test/team.ts +++ b/pnpm11/registry-access/commands/test/team.ts @@ -55,7 +55,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@myorg:newteam']) expect(result).toBe('+myorg:newteam') @@ -65,7 +65,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create']) }).rejects.toThrow('Team scope is required') }) @@ -74,7 +74,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@myorg']) }).rejects.toThrow('Team name is required') }) @@ -88,7 +88,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { otp: '123456' }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@myorg:newteam']) expect(result).toBe('+myorg:newteam') @@ -104,7 +104,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@myorg:newteam']) }).rejects.toThrow('logged in') }) @@ -119,7 +119,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@myorg:newteam']) }).rejects.toThrow('permission') }) @@ -128,7 +128,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@myorg:']) }).rejects.toThrow('Team spec must start with @scope') }) @@ -137,7 +137,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@']) }).rejects.toThrow('Team spec must start with @scope') }) @@ -146,7 +146,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', '@:newteam']) }).rejects.toThrow('Team spec must start with @scope') }) @@ -162,7 +162,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['destroy', '@myorg:oldteam']) expect(result).toBe('-myorg:oldteam') @@ -172,7 +172,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['destroy']) }).rejects.toThrow('Team scope is required') }) @@ -181,7 +181,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['destroy', '@myorg']) }).rejects.toThrow('Team name is required') }) @@ -196,7 +196,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['destroy', '@myorg:oldteam']) }).rejects.toThrow('logged in') }) @@ -211,7 +211,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['destroy', '@myorg:oldteam']) }).rejects.toThrow('permission') }) @@ -225,7 +225,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { otp: '654321' }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['destroy', '@myorg:oldteam']) expect(result).toBe('-myorg:oldteam') @@ -243,7 +243,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add', '@myorg:team1', 'alice']) expect(result).toBe('+alice added to @myorg:team1') @@ -253,7 +253,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add']) }).rejects.toThrow('Team scope and user are required') }) @@ -262,7 +262,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add', '@myorg:team1']) }).rejects.toThrow('Team scope and user are required') }) @@ -271,7 +271,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add', '@myorg', 'alice']) }).rejects.toThrow('Team name is required') }) @@ -286,7 +286,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add', '@myorg:team1', 'alice']) }).rejects.toThrow('logged in') }) @@ -301,7 +301,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add', '@myorg:team1', 'alice']) }).rejects.toThrow('permission') }) @@ -315,7 +315,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { otp: '123456' }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['add', '@myorg:team1', 'alice']) expect(result).toBe('+alice added to @myorg:team1') @@ -333,7 +333,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['rm', '@myorg:team1', 'bob']) expect(result).toBe('-bob removed from @myorg:team1') @@ -343,7 +343,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['rm']) }).rejects.toThrow('Team scope and user are required') }) @@ -352,7 +352,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['rm', '@myorg:team1']) }).rejects.toThrow('Team scope and user are required') }) @@ -367,7 +367,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['rm', '@myorg:team1', 'bob']) }).rejects.toThrow('logged in') }) @@ -382,7 +382,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['rm', '@myorg:team1', 'bob']) }).rejects.toThrow('permission') }) @@ -396,7 +396,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { otp: '123456' }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['rm', '@myorg:team1', 'bob']) expect(result).toBe('-bob removed from @myorg:team1') @@ -416,7 +416,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) expect(result).toContain('@myorg has the following teams:') @@ -436,7 +436,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg:developers']) expect(result).toContain('@myorg:developers has the following members:') @@ -456,7 +456,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { parseable: true }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) expect(result).toBe('developers\nadmins') @@ -474,7 +474,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { json: true }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) expect(result).toContain('"developers"') @@ -490,7 +490,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) expect(result).toBe('@myorg has no teams') @@ -505,7 +505,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg:empty-team']) expect(result).toBe('@myorg:empty-team has no members') @@ -521,7 +521,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@nonexistent']) }).rejects.toThrow('not found') }) @@ -536,7 +536,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg:nonexistent']) }).rejects.toThrow('not found') }) @@ -545,7 +545,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls']) }).rejects.toThrow('Organization scope is required') }) @@ -559,7 +559,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['list', '@myorg']) expect(result).toContain('@myorg:developers') @@ -576,7 +576,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['@myorg']) expect(result).toContain('@myorg:developers') @@ -591,7 +591,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: {}, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['@myorg:developers']) expect(result).toContain('alice') @@ -601,7 +601,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['invalid']) }).rejects.toThrow('Subcommand is required') }) @@ -612,7 +612,7 @@ describe('team command', () => { await expect(async () => { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['create', 'invalid']) }).rejects.toThrow('Team spec must start with @scope') }) @@ -622,7 +622,7 @@ describe('team command', () => { await team.handler({ cliOptions: {}, configByUri: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) throw new Error('Expected error to be thrown') } catch (err: unknown) { @@ -636,7 +636,7 @@ describe('team command', () => { try { await team.handler({ cliOptions: {}, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) throw new Error('Expected error to be thrown') } catch (err: unknown) { @@ -654,7 +654,7 @@ describe('team command', () => { const result = await team.handler({ cliOptions: { parseable: true }, configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }, ['ls', '@myorg']) expect(result).toBe('developers') diff --git a/pnpm11/registry-access/commands/test/whoami.ts b/pnpm11/registry-access/commands/test/whoami.ts index 21b060e45b..c162aecf75 100644 --- a/pnpm11/registry-access/commands/test/whoami.ts +++ b/pnpm11/registry-access/commands/test/whoami.ts @@ -31,7 +31,7 @@ describe('whoami', () => { const result = await whoami.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, }) expect(result).toBe('alice') @@ -39,7 +39,7 @@ describe('whoami', () => { it('throws when not logged in', async () => { await expect(whoami.handler({ - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, })).rejects.toThrow('You must be logged in') }) @@ -52,7 +52,7 @@ describe('whoami', () => { await expect(whoami.handler({ configByUri: CONFIG_BY_URI, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, })).rejects.toThrow('Failed to find the current user') }) @@ -65,7 +65,7 @@ describe('whoami', () => { const result = await whoami.handler({ configByUri: CONFIG_BY_URI, - registries: { default: `${REGISTRY}/custom-prefix` }, + registriesByScope: { default: `${REGISTRY}/custom-prefix` }, }) expect(result).toBe('alice') diff --git a/pnpm11/releasing/commands/src/publish/pack.ts b/pnpm11/releasing/commands/src/publish/pack.ts index 1b1e938985..72fa1112a6 100644 --- a/pnpm11/releasing/commands/src/publish/pack.ts +++ b/pnpm11/releasing/commands/src/publish/pack.ts @@ -118,7 +118,7 @@ export type PackOptions = Pick & Pick { - const registry = pickRegistryForPackage(opts.registries, pkgName) + const registry = pickRegistryForPackage(opts.registriesByScope, pkgName) let fetchResult try { fetchResult = await fetchMetadataFromFromRegistry( diff --git a/pnpm11/releasing/commands/src/publish/publishPackedPkg.ts b/pnpm11/releasing/commands/src/publish/publishPackedPkg.ts index c75b6cc843..17b9b4fac2 100644 --- a/pnpm11/releasing/commands/src/publish/publishPackedPkg.ts +++ b/pnpm11/releasing/commands/src/publish/publishPackedPkg.ts @@ -30,7 +30,7 @@ export type PublishPackedPkgOptions = Pick & Partial, + { configByUri, registriesByScope }: Pick, publishConfigRegistry?: string ): Partial { // eslint-disable-next-line regexp/no-unused-capturing-group const scopedMatches = /@(?[^/]+)\/(?[^/]+)/.exec(name) const registryName = scopedMatches?.groups ? `@${scopedMatches.groups.scope}` : 'default' - const nonNormalizedRegistry = publishConfigRegistry ?? registries[registryName] ?? registries.default + const nonNormalizedRegistry = publishConfigRegistry ?? registriesByScope[registryName] ?? registriesByScope.default const supportedRegistryInfo = parseSupportedRegistryUrl(nonNormalizedRegistry) if (!supportedRegistryInfo) { diff --git a/pnpm11/releasing/commands/src/publish/recursivePublish.ts b/pnpm11/releasing/commands/src/publish/recursivePublish.ts index 36ca650169..b417754945 100644 --- a/pnpm11/releasing/commands/src/publish/recursivePublish.ts +++ b/pnpm11/releasing/commands/src/publish/recursivePublish.ts @@ -5,7 +5,7 @@ import type { Config, ConfigContext } from '@pnpm/config.reader' import { createResolver } from '@pnpm/installing.client' import { logger } from '@pnpm/logger' import type { ResolveFunction } from '@pnpm/resolving.resolver-base' -import type { ProjectRootDir, Registries } from '@pnpm/types' +import type { ProjectRootDir, RegistriesByScope } from '@pnpm/types' import { sortFilteredProjects } from '@pnpm/workspace.projects-sorter' import pFilter from 'p-filter' import { pick } from 'ramda' @@ -22,7 +22,7 @@ export type PublishRecursiveOpts = Required> & Required[0] { return { configByUri: {}, fetchTimeout: 60_000, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, } as Parameters[0] } diff --git a/pnpm11/releasing/commands/test/publish/oidcOrchestrator.test.ts b/pnpm11/releasing/commands/test/publish/oidcOrchestrator.test.ts index 44a8432012..78df998b3c 100644 --- a/pnpm11/releasing/commands/test/publish/oidcOrchestrator.test.ts +++ b/pnpm11/releasing/commands/test/publish/oidcOrchestrator.test.ts @@ -45,7 +45,7 @@ const FAKE_JWT = `header.${Buffer.from(JSON.stringify({ project_visibility: 'pub const baseOptions = { configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, // Disable retries so a single 5xx response in a test doesn't trigger 3 calls and // exhaust an interceptor / drag the test out. fetchRetries: 0, diff --git a/pnpm11/releasing/commands/test/publish/publish.ts b/pnpm11/releasing/commands/test/publish/publish.ts index d46efe8b57..e24971f27f 100644 --- a/pnpm11/releasing/commands/test/publish/publish.ts +++ b/pnpm11/releasing/commands/test/publish/publish.ts @@ -318,7 +318,7 @@ test('publish: package with publishConfig.registry overrides the default registr argv: { original: ['publish'] }, configByUri: CONFIG_BY_URI, dir: process.cwd(), - registries: { default: 'https://__fake_npm_registry__.com' }, + registriesByScope: { default: 'https://__fake_npm_registry__.com' }, }, []) await checkPkgExists(pkgName, '1.0.0') diff --git a/pnpm11/releasing/commands/test/publish/publishConfigAccess.test.ts b/pnpm11/releasing/commands/test/publish/publishConfigAccess.test.ts index 3ba4d2a7fb..9120860204 100644 --- a/pnpm11/releasing/commands/test/publish/publishConfigAccess.test.ts +++ b/pnpm11/releasing/commands/test/publish/publishConfigAccess.test.ts @@ -16,7 +16,7 @@ function baseOpts (): Parameters[1] { return { configByUri: {}, fetchTimeout: 60_000, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, } as Parameters[1] } diff --git a/pnpm11/releasing/commands/test/publish/publishReadme.test.ts b/pnpm11/releasing/commands/test/publish/publishReadme.test.ts index e32315579d..c5982ae9b3 100644 --- a/pnpm11/releasing/commands/test/publish/publishReadme.test.ts +++ b/pnpm11/releasing/commands/test/publish/publishReadme.test.ts @@ -95,7 +95,7 @@ async function runPublish (dir: string, embedReadme: boolean, params: string[] = embedReadme, skipManifestObfuscation: false, catalogs: {}, - registries: { default: registry }, + registriesByScope: { default: registry }, configByUri: { [registry]: { '@//': { authToken: 'test' } } }, tag: 'latest', userAgent: 'test', diff --git a/pnpm11/releasing/commands/test/publish/recursivePublish.ts b/pnpm11/releasing/commands/test/publish/recursivePublish.ts index bc5ac84237..1324b75f0b 100644 --- a/pnpm11/releasing/commands/test/publish/recursivePublish.ts +++ b/pnpm11/releasing/commands/test/publish/recursivePublish.ts @@ -379,8 +379,8 @@ test('errors on fake registry', async () => { ...DEFAULT_OPTS, ...await filterProjectsBySelectorObjectsFromDir(process.cwd(), []), configByUri: {}, - registries: { - ...DEFAULT_OPTS.registries, + registriesByScope: { + ...DEFAULT_OPTS.registriesByScope, default: fakeRegistry, }, dir: process.cwd(), diff --git a/pnpm11/releasing/commands/test/stage.test.ts b/pnpm11/releasing/commands/test/stage.test.ts index 7e53d17fac..2b63ec5d75 100644 --- a/pnpm11/releasing/commands/test/stage.test.ts +++ b/pnpm11/releasing/commands/test/stage.test.ts @@ -423,7 +423,7 @@ function stageOpts (registry: string): Parameters[0] { configByUri: {}, dir: process.cwd(), gitChecks: false, - registries: { default: registry }, + registriesByScope: { default: registry }, registry, } as Parameters[0] } diff --git a/pnpm11/resolving/default-resolver/src/index.ts b/pnpm11/resolving/default-resolver/src/index.ts index 18e56f51c8..f75882f96a 100644 --- a/pnpm11/resolving/default-resolver/src/index.ts +++ b/pnpm11/resolving/default-resolver/src/index.ts @@ -178,7 +178,7 @@ export function createResolver ( } export type ResolutionVerifierFactoryOptions = - & Pick + & Pick & Pick { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) const result = await resolve( @@ -76,7 +76,7 @@ test('custom resolver with synchronous methods', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) const result = await resolve( @@ -120,7 +120,7 @@ test('multiple custom resolvers - first matching wins', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) const result = await resolve( @@ -153,7 +153,7 @@ test('custom resolver error handling', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) await expect(resolve({ alias: 'any', bareSpecifier: '1.0.0' }, { lockfileDir: '/test', projectDir: '/test', preferredVersions: {} })).rejects.toThrow('Custom resolver failed') @@ -177,7 +177,7 @@ test('preferredVersions are passed to custom resolver', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) await resolvePackage( @@ -217,7 +217,7 @@ test('custom resolver can intercept any protocol', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) const result = await resolve( @@ -248,7 +248,7 @@ test('custom resolver falls through when not supported', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) await expect( @@ -281,7 +281,7 @@ test('custom resolver can override npm registry resolution', async () => { retry: {}, timeout: 60000, storeDir: '.store', - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) const result = await resolve( @@ -319,7 +319,7 @@ test('custom custom fetcher: reuse local tarball fetcher', async () => { preferOffline: false, retry: {}, timeout: 60000, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, storeDir: '.store', }) @@ -356,7 +356,7 @@ test('custom custom fetcher: reuse remote tarball downloader', async () => { preferOffline: false, retry: {}, timeout: 60000, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, storeDir: '.store', }) @@ -397,7 +397,7 @@ test('custom custom fetcher: wrap npm registry with custom logic', async () => { preferOffline: false, retry: {}, timeout: 60000, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, storeDir: '.store', }) @@ -446,7 +446,7 @@ test('custom resolver receives currentPkg when provided', async () => { preferOffline: false, retry: {}, timeout: 60000, - registries: { default: 'https://registry.npmjs.org/' }, + registriesByScope: { default: 'https://registry.npmjs.org/' }, }) // First call without currentPkg diff --git a/pnpm11/resolving/default-resolver/test/index.ts b/pnpm11/resolving/default-resolver/test/index.ts index efb0d77603..5b3d514542 100644 --- a/pnpm11/resolving/default-resolver/test/index.ts +++ b/pnpm11/resolving/default-resolver/test/index.ts @@ -7,7 +7,7 @@ test('createResolver()', () => { const getAuthHeader = () => undefined const { resolve } = createResolver(createFetchFromRegistry({}), getAuthHeader, { cacheDir: '.cache', - registries: { + registriesByScope: { default: 'https://registry.npmjs.org/', }, storeDir: '.store', diff --git a/pnpm11/resolving/default-resolver/test/namedRegistry.ts b/pnpm11/resolving/default-resolver/test/namedRegistry.ts index 3a146dfa6a..2842be42e8 100644 --- a/pnpm11/resolving/default-resolver/test/namedRegistry.ts +++ b/pnpm11/resolving/default-resolver/test/namedRegistry.ts @@ -2,7 +2,7 @@ import path from 'node:path' import { afterEach, beforeEach, expect, test } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createResolver } from '@pnpm/resolving.default-resolver' import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent' @@ -18,7 +18,7 @@ const ghAcmePrivateMeta = loadJsonFileSync( const GH_REGISTRY = 'https://npm.pkg.github.com/' const ENTERPRISE_REGISTRY = 'https://npm.enterprise.example.com/' -const registries = { +const registriesByScope = { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', } @@ -45,7 +45,7 @@ test('createResolver() routes :@scope/pkg through the named-registry reso const { resolve } = createResolver(fetch, () => undefined, { cacheDir: temporaryDirectory(), storeDir: temporaryDirectory(), - registries, + registriesByScope, }) const result = await resolve( @@ -63,8 +63,8 @@ test('createResolver() routes a user-configured named registry alias through the const { resolve } = createResolver(fetch, () => undefined, { cacheDir: temporaryDirectory(), storeDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: ENTERPRISE_REGISTRY, }), }) @@ -87,8 +87,8 @@ test.each([ expect(() => createResolver(fetch, () => undefined, { cacheDir: temporaryDirectory(), storeDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ [alias]: ENTERPRISE_REGISTRY, }), })).toThrow(expect.objectContaining({ code: 'ERR_PNPM_RESERVED_NAMED_REGISTRY_NAME' })) @@ -98,8 +98,8 @@ test('createResolver() rejects a malformed named-registry alias', () => { expect(() => createResolver(fetch, () => undefined, { cacheDir: temporaryDirectory(), storeDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ 'no colons:allowed': ENTERPRISE_REGISTRY, }), })).toThrow(expect.objectContaining({ code: 'ERR_PNPM_RESERVED_NAMED_REGISTRY_NAME' })) @@ -111,8 +111,8 @@ test('createResolver() qualifies a named-registry id with the registry alias', a const { resolve } = createResolver(fetch, () => undefined, { cacheDir: temporaryDirectory(), storeDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: ENTERPRISE_REGISTRY, }), }) diff --git a/pnpm11/resolving/npm-resolver/src/createNpmResolutionVerifier.ts b/pnpm11/resolving/npm-resolver/src/createNpmResolutionVerifier.ts index 7b4d49d676..bd57c9d7f6 100644 --- a/pnpm11/resolving/npm-resolver/src/createNpmResolutionVerifier.ts +++ b/pnpm11/resolving/npm-resolver/src/createNpmResolutionVerifier.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { namedRegistryTarballPrefixes, pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package' import { createPackageVersionPolicy } from '@pnpm/config.version-policy' import { FULL_META_DIR } from '@pnpm/constants' @@ -12,7 +12,7 @@ import { type Resolution, type ResolutionVerifier, } from '@pnpm/resolving.resolver-base' -import type { PackageVersionPolicy, Registries, TrustPolicy } from '@pnpm/types' +import type { PackageVersionPolicy, RegistriesByScope, TrustPolicy } from '@pnpm/types' import semver from 'semver' import type { FetchMetadataFromFromRegistryOptions } from './fetch.js' @@ -72,14 +72,14 @@ export interface CreateNpmResolutionVerifierOptions { trustPolicy?: TrustPolicy trustPolicyExclude?: string[] trustPolicyIgnoreAfter?: number - registries: Registries + registriesByScope: RegistriesByScope /** - * Registries reached via the named-registry resolver chain (e.g. `gh:` → + * RegistriesByScope reached via the named-registry resolver chain (e.g. `gh:` → * GitHub Packages). When a lockfile entry's tarball URL falls under one of * these registry base URLs, route the manifest fetch there instead of the * scope-derived default. */ - namedRegistries?: Record + registriesByPrefix?: Record /** * Cache-aware full-metadata fetcher. Decoupled from the resolver pipeline * so abbreviated metadata and `peekManifestFromStore` fast paths cannot @@ -138,8 +138,8 @@ export function createNpmResolutionVerifier ( ? createExcludePolicy(opts.trustPolicyExclude, 'trustPolicyExclude') : undefined - const mergedNamedRegistries = normalizeNamedRegistries(opts.namedRegistries) - const namedRegistryPrefixes = namedRegistryTarballPrefixes(mergedNamedRegistries) + const mergedRegistriesByPrefix = normalizeRegistriesByPrefix(opts.registriesByPrefix) + const namedRegistryPrefixes = namedRegistryTarballPrefixes(mergedRegistriesByPrefix) // Per-install dedup of every network/disk fetch the verifier issues. // The maturity check uses the layered `fetchPublishedAt` lookup; the @@ -210,19 +210,19 @@ export function createNpmResolutionVerifier ( // Registry-qualified entries name their registry in the dep path, so // routing does not depend on a recorded tarball URL (canonical URLs // are omitted from the lockfile in the 12.0 format). - const namedRegistry = mergedNamedRegistries[registryName] + const namedRegistry = mergedRegistriesByPrefix[registryName] if (!namedRegistry) { // Fail closed: without the registry URL, none of the metadata-backed // checks below can vouch for this entry. return { ok: false, code: MISSING_NAMED_REGISTRY_VIOLATION_CODE, - reason: `was resolved from the named registry '${registryName}:', which is not present in the namedRegistries setting`, + reason: `was resolved from the named registry '${registryName}:', which is not present in the registriesByPrefix setting`, } } registry = namedRegistry } else { - registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl) + registry = pickRegistryForVersion(opts.registriesByScope, namedRegistryPrefixes, name, tarballUrl) } // A registry entry that pins an explicit tarball URL must point at the @@ -268,11 +268,11 @@ export function createNpmResolutionVerifier ( // stays trusted after its exclude entry has been pulled. const sortedMinAgeExcludes = [...new Set(opts.minimumReleaseAgeExclude ?? [])].sort() const sortedTrustExcludes = [...new Set(opts.trustPolicyExclude ?? [])].sort() - const sortedNamedRegistries = Object.fromEntries( - Object.entries(mergedNamedRegistries).sort(([aliasA], [aliasB]) => aliasA.localeCompare(aliasB)) + const sortedRegistriesByPrefix = Object.fromEntries( + Object.entries(mergedRegistriesByPrefix).sort(([aliasA], [aliasB]) => aliasA.localeCompare(aliasB)) ) const namedRegistriesRouting = createHash('sha256') - .update(JSON.stringify(sortedNamedRegistries)) + .update(JSON.stringify(sortedRegistriesByPrefix)) .digest('hex') return { verify, @@ -933,7 +933,7 @@ function fetchFullMetaTime ( } function pickRegistryForVersion ( - registries: Registries, + registriesByScope: RegistriesByScope, namedRegistryPrefixes: readonly string[], name: string, tarballUrl: string | undefined @@ -953,7 +953,7 @@ function pickRegistryForVersion ( if (normalized.startsWith(canonicalTarballUrl(prefix))) return prefix } } - return pickRegistryForPackage(registries, name) + return pickRegistryForPackage(registriesByScope, name) } function tryParseUrl (url: string): URL | null { diff --git a/pnpm11/resolving/npm-resolver/src/fetch.ts b/pnpm11/resolving/npm-resolver/src/fetch.ts index 54dd7b4d20..ec74418a88 100644 --- a/pnpm11/resolving/npm-resolver/src/fetch.ts +++ b/pnpm11/resolving/npm-resolver/src/fetch.ts @@ -273,7 +273,7 @@ export function notModifiedWithoutCacheError (pkgName: string): PnpmError { * carry the megabytes of install-irrelevant data (scripts, exports, readme, * custom fields) that a full document contains. * - * Registries that honor the header (e.g. the npm registry) echo the abbreviated + * RegistriesByScope that honor the header (e.g. the npm registry) echo the abbreviated * `Content-Type`, so this is a no-op for them: no re-serialization, no field * stripping — the happy path pays nothing. */ diff --git a/pnpm11/resolving/npm-resolver/src/index.ts b/pnpm11/resolving/npm-resolver/src/index.ts index e8285a36be..bdadbec4d8 100644 --- a/pnpm11/resolving/npm-resolver/src/index.ts +++ b/pnpm11/resolving/npm-resolver/src/index.ts @@ -36,7 +36,7 @@ import type { DependencyManifest, PackageVersionPolicy, RangeSpecStyle, - Registries, + RegistriesByScope, TrustPolicy, } from '@pnpm/types' import { @@ -55,7 +55,7 @@ import { fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOption import { memoizeFetchMetadata } from './memoizeFetchMetadata.js' import { normalizeRegistryUrl } from './normalizeRegistryUrl.js' import { - BUILTIN_NAMED_REGISTRIES, + BUILTIN_REGISTRIES_BY_PREFIX, parseBareSpecifier, parseJsrSpecifierToRegistryPackageSpec, parseNamedRegistrySpecifierToRegistryPackageSpec, @@ -117,7 +117,7 @@ export function formatTimeAgo (date: Date): string | null { } export { - BUILTIN_NAMED_REGISTRIES, + BUILTIN_REGISTRIES_BY_PREFIX, fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, type PackageMeta, @@ -145,8 +145,8 @@ export interface ResolverFactoryOptions { preferOffline?: boolean retry?: RetryTimeoutOptions timeout?: number - registries: Registries - namedRegistries?: Record + registriesByScope: RegistriesByScope + registriesByPrefix?: Record saveWorkspaceProtocol?: boolean | 'rolling' preserveAbsolutePaths?: boolean ignoreMissingTimeField?: boolean @@ -259,8 +259,8 @@ export function createNpmResolver ( return request } } - const namedRegistries = mergeNamedRegistries(opts.namedRegistries) - const namedRegistryNames: ReadonlySet = new Set(Object.keys(namedRegistries)) + const registriesByPrefix = mergeNamedRegistries(opts.registriesByPrefix) + const namedRegistryNames: ReadonlySet = new Set(Object.keys(registriesByPrefix)) const ctx: ResolveFromNpmContext = { getAuthHeaderValueByURI: getAuthHeader, pickPackage: pickPackage.bind(null, { @@ -273,8 +273,8 @@ export function createNpmResolver ( cacheDir: opts.cacheDir, ignoreMissingTimeField: opts.ignoreMissingTimeField, }), - registries: opts.registries, - namedRegistries, + registriesByScope: opts.registriesByScope, + registriesByPrefix, namedRegistryNames, saveWorkspaceProtocol: opts.saveWorkspaceProtocol, peekManifestFromStore, @@ -283,7 +283,7 @@ export function createNpmResolver ( const boundResolveFromNpm = resolveNpm.bind(null, ctx) const boundResolveFromJsr = resolveJsr.bind(null, ctx) const boundResolveFromNamedRegistry = resolveFromNamedRegistry.bind(null, ctx) - const defaultRegistry = opts.registries.default + const defaultRegistry = opts.registriesByScope.default return { resolveFromNpm: boundResolveFromNpm, resolveFromJsr: boundResolveFromJsr, @@ -472,8 +472,8 @@ function createResolveLatest ( export interface ResolveFromNpmContext { pickPackage: (spec: RegistryPackageSpec, opts: PickPackageOptions) => ReturnType getAuthHeaderValueByURI: GetAuthHeader - registries: Registries - namedRegistries: Record + registriesByScope: RegistriesByScope + registriesByPrefix: Record namedRegistryNames: ReadonlySet saveWorkspaceProtocol?: boolean | 'rolling' peekManifestFromStore?: (opts: { @@ -528,8 +528,8 @@ async function resolveNpm ( ): Promise { const defaultTag = opts.defaultTag ?? 'latest' const registry = wantedDependency.alias - ? pickRegistryForPackage(ctx.registries, wantedDependency.alias, wantedDependency.bareSpecifier) - : ctx.registries.default + ? pickRegistryForPackage(ctx.registriesByScope, wantedDependency.alias, wantedDependency.bareSpecifier) + : ctx.registriesByScope.default if (wantedDependency.bareSpecifier?.startsWith('workspace:')) { if (wantedDependency.bareSpecifier.startsWith('workspace:.')) return null const resolvedFromWorkspace = tryResolveFromWorkspace(wantedDependency, { @@ -754,7 +754,7 @@ async function resolveJsr ( const spec = parseJsrSpecifierToRegistryPackageSpec(wantedDependency.bareSpecifier, wantedDependency.alias, opts.defaultTag ?? 'latest') if (spec == null) return null - const picked = await pickFromSimpleRegistry(ctx, wantedDependency, opts, spec, ctx.registries['@jsr']!) // '@jsr' is always defined + const picked = await pickFromSimpleRegistry(ctx, wantedDependency, opts, spec, ctx.registriesByScope['@jsr']!) // '@jsr' is always defined return { ...picked, normalizedBareSpecifier: opts.calcSpecifier @@ -774,7 +774,7 @@ async function resolveJsr ( // another specifier scheme (e.g. `git`, `github`, `jsr`) is silently shadowed // by that scheme's dedicated resolver — no cross-resolver knowledge needed. function mergeNamedRegistries (userDefined?: Record): Record { - const merged: Record = { ...BUILTIN_NAMED_REGISTRIES } + const merged: Record = { ...BUILTIN_REGISTRIES_BY_PREFIX } if (!userDefined) return merged for (const [alias, url] of Object.entries(userDefined)) { if (RESERVED_VERSION_PREFIXES.has(alias) || !isWellFormedRegistryName(alias)) { @@ -783,7 +783,7 @@ function mergeNamedRegistries (userDefined?: Record): Record:` specifier from one of the configured named registries. // The `gh:` alias ships as a built-in default pointing at the GitHub Packages // npm registry; additional aliases come from pnpm-workspace.yaml's -// `namedRegistries` field. Auth tokens are looked up by the resolved registry +// `registriesByPrefix` field. Auth tokens are looked up by the resolved registry // URL, so a `//npm.pkg.github.com/:_authToken=...` entry in `.npmrc` is // picked up automatically for `gh:` specifiers (and analogously for any user- // configured alias). @@ -829,7 +829,7 @@ async function resolveFromNamedRegistry ( ) if (spec == null) return null - const registry = ctx.namedRegistries[spec.registryName] + const registry = ctx.registriesByPrefix[spec.registryName] if (!registry) return null // defensive: should never trigger because parse checks the alias set const picked = await pickFromSimpleRegistry(ctx, wantedDependency, opts, spec, registry) diff --git a/pnpm11/resolving/npm-resolver/src/parseBareSpecifier.ts b/pnpm11/resolving/npm-resolver/src/parseBareSpecifier.ts index e748eb11a2..d96fcb53d4 100644 --- a/pnpm11/resolving/npm-resolver/src/parseBareSpecifier.ts +++ b/pnpm11/resolving/npm-resolver/src/parseBareSpecifier.ts @@ -85,7 +85,7 @@ export function parseJsrSpecifierToRegistryPackageSpec ( } } -export { BUILTIN_NAMED_REGISTRIES } from '@pnpm/constants' +export { BUILTIN_REGISTRIES_BY_PREFIX } from '@pnpm/constants' export interface NamedRegistryPackageSpec extends RegistryPackageSpec { registryName: string diff --git a/pnpm11/resolving/npm-resolver/test/clearCache.test.ts b/pnpm11/resolving/npm-resolver/test/clearCache.test.ts index 63c8a0233b..e706127776 100644 --- a/pnpm11/resolving/npm-resolver/test/clearCache.test.ts +++ b/pnpm11/resolving/npm-resolver/test/clearCache.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, expect, test } from '@jest/globals' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' import type { PackageMeta } from '@pnpm/resolving.registry.types' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { temporaryDirectory } from 'tempy' import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js' -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } @@ -51,7 +51,7 @@ test('metadata is fetched again after calling clearCache()', async () => { const { resolveFromNpm, clearCache } = createResolveFromNpm({ cacheDir, fullMetadata: true, - registries, + registriesByScope, storeDir: temporaryDirectory(), }) diff --git a/pnpm11/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts b/pnpm11/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts index 717ae06145..0e359ed78d 100644 --- a/pnpm11/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts +++ b/pnpm11/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts @@ -1,16 +1,16 @@ import { afterEach, beforeEach, expect, test } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { ABBREVIATED_META_DIR } from '@pnpm/constants' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolutionVerifier } from '@pnpm/resolving.npm-resolver' import type { Resolution } from '@pnpm/resolving.resolver-base' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { temporaryDirectory } from 'tempy' import { getPkgMirrorPath, prepareJsonForDisk, saveMeta } from '../src/pickPackage.js' import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js' -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } @@ -19,7 +19,7 @@ const getAuthHeaderValueByURI = (): undefined => undefined function makeVerifierOpts (overrides: Partial[0]> = {}): Parameters[0] { return { - registries, + registriesByScope, fetchOpts: { fetch: fetchFromRegistry, retry: { retries: 0 }, @@ -96,7 +96,7 @@ test('createNpmResolutionVerifier() verifies tarball URLs from cached metadata i modified: '2020-01-01T00:00:00.000Z', } await saveMeta( - getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, registries.default, 'offline-pkg'), + getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, registriesByScope.default, 'offline-pkg'), prepareJsonForDisk(meta, '"cached"') ) @@ -142,7 +142,7 @@ test('createNpmResolutionVerifier() passes package name to auth header lookup', { name: '@scope/pkg', version: '1.0.0' } ) expect(result).toStrictEqual({ ok: true }) - expect(calls).toContainEqual({ uri: registries.default, pkgName: '@scope/pkg' }) + expect(calls).toContainEqual({ uri: registriesByScope.default, pkgName: '@scope/pkg' }) }) test('createNpmResolutionVerifier() flags a trustedPublisher → provenance downgrade', async () => { @@ -603,10 +603,10 @@ test('createNpmResolutionVerifier() canTrustPastCheck rejects when the trust-exc test('createNpmResolutionVerifier() canTrustPastCheck rejects a changed named-registry mapping', () => { const verifier = createNpmResolutionVerifier(makeVerifierOpts({ - namedRegistries: normalizeNamedRegistries({ work: 'https://registry.example.test' }), + registriesByPrefix: normalizeRegistriesByPrefix({ work: 'https://registry.example.test' }), })) const changedVerifier = createNpmResolutionVerifier(makeVerifierOpts({ - namedRegistries: normalizeNamedRegistries({ work: 'https://other.example.test' }), + registriesByPrefix: normalizeRegistriesByPrefix({ work: 'https://other.example.test' }), })) expect(verifier.canTrustPastCheck(verifier.policy)).toBe(true) diff --git a/pnpm11/resolving/npm-resolver/test/distTagsByDate.test.ts b/pnpm11/resolving/npm-resolver/test/distTagsByDate.test.ts index afcbf26c69..551d139fa2 100644 --- a/pnpm11/resolving/npm-resolver/test/distTagsByDate.test.ts +++ b/pnpm11/resolving/npm-resolver/test/distTagsByDate.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, expect, test } from '@jest/globals' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { temporaryDirectory } from 'tempy' import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js' -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } @@ -62,7 +62,7 @@ test('repopulate dist-tag to highest same-major version within the date cutoff', // Cutoff before 3.2.0, so latest must be remapped to 3.1.0 (same major 3) const cutoff = new Date('2020-04-01T00:00:00.000Z') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${name}`, method: 'GET' }) .reply(200, meta) @@ -70,7 +70,7 @@ test('repopulate dist-tag to highest same-major version within the date cutoff', const { resolveFromNpm } = createResolveFromNpm({ cacheDir, fullMetadata: true, - registries, + registriesByScope, storeDir: temporaryDirectory(), }) @@ -122,7 +122,7 @@ test('repopulate dist-tag to highest same-major version within the date cutoff. // Cutoff before 3.2.0, so latest must be remapped to 3.1.0 (same major 3) const cutoff = new Date('2020-04-01T00:00:00.000Z') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${name}`, method: 'GET' }) .reply(200, meta) @@ -130,7 +130,7 @@ test('repopulate dist-tag to highest same-major version within the date cutoff. const { resolveFromNpm } = createResolveFromNpm({ cacheDir, fullMetadata: true, - registries, + registriesByScope, storeDir: temporaryDirectory(), }) @@ -181,7 +181,7 @@ test('repopulate dist-tag to highest non-prerelease same-major version within th // Cutoff before 3.2.0, so latest must be remapped to 3.1.0 (same major 3) const cutoff = new Date('2020-04-01T00:00:00.000Z') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${name}`, method: 'GET' }) .reply(200, meta) @@ -189,7 +189,7 @@ test('repopulate dist-tag to highest non-prerelease same-major version within th const { resolveFromNpm } = createResolveFromNpm({ cacheDir, fullMetadata: true, - registries, + registriesByScope, storeDir: temporaryDirectory(), }) @@ -246,7 +246,7 @@ test('repopulate dist-tag to highest prerelease same-major version within the da // Cutoff before 3.2.0 and 3.0.0-alpha.2, so latest must be remapped to 3.0.0-alpha.1 (the highest prerelease version within the cutoff) const cutoff = new Date('2020-04-01T00:00:00.000Z') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${name}`, method: 'GET' }) .reply(200, meta) @@ -254,7 +254,7 @@ test('repopulate dist-tag to highest prerelease same-major version within the da const { resolveFromNpm } = createResolveFromNpm({ cacheDir, fullMetadata: true, - registries, + registriesByScope, storeDir: temporaryDirectory(), }) @@ -282,7 +282,7 @@ test('keep dist-tag if original version is within the date cutoff', async () => const cutoff = new Date('2020-02-01T00:00:00.000Z') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${name}`, method: 'GET' }) .reply(200, meta) @@ -290,7 +290,7 @@ test('keep dist-tag if original version is within the date cutoff', async () => const { resolveFromNpm } = createResolveFromNpm({ cacheDir, fullMetadata: true, - registries, + registriesByScope, storeDir: temporaryDirectory(), }) diff --git a/pnpm11/resolving/npm-resolver/test/fixtures/resolve-bloated-metadata.mjs b/pnpm11/resolving/npm-resolver/test/fixtures/resolve-bloated-metadata.mjs index 9de6784335..aef75717cb 100644 --- a/pnpm11/resolving/npm-resolver/test/fixtures/resolve-bloated-metadata.mjs +++ b/pnpm11/resolving/npm-resolver/test/fixtures/resolve-bloated-metadata.mjs @@ -49,7 +49,7 @@ const registry = `http://127.0.0.1:${server.address().port}/` const cacheDir = mkdtempSync(path.join(os.tmpdir(), 'pnpm-memory-bounded-')) const { resolveFromNpm } = createNpmResolver(createFetchFromRegistry({}), () => undefined, { cacheDir, - registries: { default: registry }, + registriesByScope: { default: registry }, }) // Sequential on purpose, so the heap cap measures what is retained across diff --git a/pnpm11/resolving/npm-resolver/test/heldBackUpdateWarning.test.ts b/pnpm11/resolving/npm-resolver/test/heldBackUpdateWarning.test.ts index 64a26feb30..2234b87740 100644 --- a/pnpm11/resolving/npm-resolver/test/heldBackUpdateWarning.test.ts +++ b/pnpm11/resolving/npm-resolver/test/heldBackUpdateWarning.test.ts @@ -3,15 +3,15 @@ import { createPackageVersionPolicy } from '@pnpm/config.version-policy' import { type LogBase, streamParser } from '@pnpm/logger' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { temporaryDirectory } from 'tempy' import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js' -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } -const registryOrigin = registries.default.slice(0, -1) +const registryOrigin = registriesByScope.default.slice(0, -1) const fetch = createFetchFromRegistry({}) const getAuthHeader = () => undefined @@ -73,7 +73,7 @@ test('does not warn about a held-back update when minimumReleaseAge is the reaso storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, { updateRequested: true, @@ -99,7 +99,7 @@ test('still warns about a held-back update when a manifest pin is the reason the storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, { updateRequested: true, @@ -123,7 +123,7 @@ test('still warns about a held-back update when the newer version is mature unde storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, { updateRequested: true, @@ -148,7 +148,7 @@ test('keeps the unfiltered baseline for a package excluded from the age gate via storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, { updateRequested: true, @@ -176,7 +176,7 @@ test('keeps a version the age gate trusts by exact version in the baseline', asy storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, { updateRequested: true, diff --git a/pnpm11/resolving/npm-resolver/test/ifModifiedSince.test.ts b/pnpm11/resolving/npm-resolver/test/ifModifiedSince.test.ts index 6dd6929f11..fbba1e0d2c 100644 --- a/pnpm11/resolving/npm-resolver/test/ifModifiedSince.test.ts +++ b/pnpm11/resolving/npm-resolver/test/ifModifiedSince.test.ts @@ -6,7 +6,7 @@ import { ABBREVIATED_META_DIR, FULL_META_DIR } from '@pnpm/constants' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' import { fixtures } from '@pnpm/test-fixtures' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { loadJsonFileSync } from 'load-json-file' import { temporaryDirectory } from 'tempy' @@ -19,7 +19,7 @@ import { getMockAgent, retryLoadJsonFile, setupMockAgent, teardownMockAgent } fr const f = fixtures(import.meta.dirname) -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } @@ -64,7 +64,7 @@ beforeEach(async () => { test.each(cachedMetadataCases)('cached $kind metadata uses the mirror without registry access in offline mode', async ({ fetchMetadata, metaDir }) => { const cacheDir = temporaryDirectory() - const pkgMirror = getPkgMirrorPath(cacheDir, metaDir, registries.default, 'is-positive') + const pkgMirror = getPkgMirrorPath(cacheDir, metaDir, registriesByScope.default, 'is-positive') await saveMeta(pkgMirror, prepareJsonForDisk(isPositiveMeta, '"cached"')) const result = await fetchMetadata({ @@ -74,7 +74,7 @@ test.each(cachedMetadataCases)('cached $kind metadata uses the mirror without re fetchWarnTimeoutMs: 30_000, }, 'is-positive', { cacheDir, - registry: registries.default, + registry: registriesByScope.default, offline: true, }) @@ -92,7 +92,7 @@ test('cached metadata reports NO_OFFLINE_META without registry access when offli fetchWarnTimeoutMs: 30_000, }, 'is-positive', { cacheDir, - registry: registries.default, + registry: registriesByScope.default, offline: true, })).rejects.toMatchObject({ code: 'ERR_PNPM_NO_OFFLINE_META', @@ -114,7 +114,7 @@ test('use local cache when registry returns 304 Not Modified', async () => { ) // Registry returns 304 Not Modified — verify conditional headers are sent - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET', @@ -127,7 +127,7 @@ test('use local cache when registry returns 304 Not Modified', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm( { alias: 'is-positive', bareSpecifier: '^3.0.0' }, @@ -152,7 +152,7 @@ test('if-modified-since is sent as an HTTP-date derived from the stored ISO-8601 'utf8' ) - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET', @@ -165,7 +165,7 @@ test('if-modified-since is sent as an HTTP-date derived from the stored ISO-8601 const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm( { alias: 'is-positive', bareSpecifier: '^3.0.0' }, @@ -187,7 +187,7 @@ test('a 304 Not Modified renews the metadata file mtime so the publishedBy fresh const aged = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) fs.utimesSync(metaPath, aged, aged) - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET', @@ -200,7 +200,7 @@ test('a 304 Not Modified renews the metadata file mtime so the publishedBy fresh const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm( { alias: 'is-positive', bareSpecifier: '^3.0.0' }, @@ -229,14 +229,14 @@ test('store etag from 200 response in cache', async () => { etag: '"xyz789"', } - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta, { headers: responseHeaders }) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm( { alias: 'is-positive', bareSpecifier: '^3.0.0' }, @@ -255,7 +255,7 @@ test('store etag from 200 response in cache', async () => { test('fetch without conditional headers when no local cache exists', async () => { // No cache file → no ETag/Last-Modified to send → normal 200 response - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -263,7 +263,7 @@ test('fetch without conditional headers when no local cache exists', async () => const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm( { alias: 'is-positive', bareSpecifier: '^3.0.0' }, @@ -275,7 +275,7 @@ test('fetch without conditional headers when no local cache exists', async () => }) test('retry when an unconditional metadata request receives 304 Not Modified', async () => { - const registry = getMockAgent().get(registries.default.replace(/\/$/, '')) + const registry = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) registry .intercept({ path: '/is-positive', method: 'GET' }) .reply(304, '') @@ -292,7 +292,7 @@ test('retry when an unconditional metadata request receives 304 Not Modified', a const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm( { alias: 'is-positive', bareSpecifier: '^3.0.0' }, @@ -304,7 +304,7 @@ test('retry when an unconditional metadata request receives 304 Not Modified', a }) test('report an invalid response when an unconditional 304 retry also returns 304', async () => { - const registry = getMockAgent().get(registries.default.replace(/\/$/, '')) + const registry = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) registry .intercept({ path: '/is-positive', method: 'GET' }) .reply(304, '') @@ -321,7 +321,7 @@ test('report an invalid response when an unconditional 304 retry also returns 30 const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) await expect(resolveFromNpm( @@ -339,12 +339,12 @@ test.each(cachedMetadataCases)('cached $kind metadata retries once without valid stripsScripts, }) => { const cacheDir = temporaryDirectory() - const pkgMirror = getPkgMirrorPath(cacheDir, metaDir, registries.default, 'is-positive') + const pkgMirror = getPkgMirrorPath(cacheDir, metaDir, registriesByScope.default, 'is-positive') await saveMeta(pkgMirror, prepareJsonForDisk(isPositiveMeta, '"stale"')) const responseMeta = structuredClone(isPositiveMeta) responseMeta.versions['3.1.0'].scripts = { postinstall: 'echo cache-race-marker' } - const registry = getMockAgent().get(registries.default.replace(/\/$/, '')) + const registry = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) registry .intercept({ path: '/is-positive', @@ -375,7 +375,7 @@ test.each(cachedMetadataCases)('cached $kind metadata retries once without valid fetchWarnTimeoutMs: 30_000, }, 'is-positive', { cacheDir, - registry: registries.default, + registry: registriesByScope.default, }) expect(result.name).toBe('is-positive') @@ -389,10 +389,10 @@ test.each(cachedMetadataCases)('cached $kind metadata retries once without valid test('cached metadata stops after one cache-loss fallback', async () => { const cacheDir = temporaryDirectory() - const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registries.default, 'is-positive') + const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registriesByScope.default, 'is-positive') await saveMeta(pkgMirror, prepareJsonForDisk(isPositiveMeta, '"stale"')) - const registry = getMockAgent().get(registries.default.replace(/\/$/, '')) + const registry = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) registry .intercept({ path: '/is-positive', @@ -418,17 +418,17 @@ test('cached metadata stops after one cache-loss fallback', async () => { fetchWarnTimeoutMs: 30_000, }, 'is-positive', { cacheDir, - registry: registries.default, + registry: registriesByScope.default, })).rejects.toMatchObject({ code: 'ERR_PNPM_META_NOT_MODIFIED_WITHOUT_CACHE' }) getMockAgent().assertNoPendingInterceptors() }) test('cached metadata keeps body retries in cache-bypass mode', async () => { const cacheDir = temporaryDirectory() - const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registries.default, 'is-positive') + const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registriesByScope.default, 'is-positive') await saveMeta(pkgMirror, prepareJsonForDisk(isPositiveMeta, '"stale"')) - const registry = getMockAgent().get(registries.default.replace(/\/$/, '')) + const registry = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) registry .intercept({ path: '/is-positive', @@ -463,7 +463,7 @@ test('cached metadata keeps body retries in cache-bypass mode', async () => { fetchWarnTimeoutMs: 30_000, }, 'is-positive', { cacheDir, - registry: registries.default, + registry: registriesByScope.default, }) expect(result.name).toBe('is-positive') @@ -474,10 +474,10 @@ test('cached metadata keeps body retries in cache-bypass mode', async () => { test('cached metadata propagates a cache-loss fallback registry error', async () => { const cacheDir = temporaryDirectory() - const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registries.default, 'is-positive') + const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registriesByScope.default, 'is-positive') await saveMeta(pkgMirror, prepareJsonForDisk(isPositiveMeta, '"stale"')) - const registry = getMockAgent().get(registries.default.replace(/\/$/, '')) + const registry = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) registry .intercept({ path: '/is-positive', @@ -503,7 +503,7 @@ test('cached metadata propagates a cache-loss fallback registry error', async () fetchWarnTimeoutMs: 30_000, }, 'is-positive', { cacheDir, - registry: registries.default, + registry: registriesByScope.default, })).rejects.toMatchObject({ code: 'ERR_PNPM_FETCH_403', response: { status: 403 }, diff --git a/pnpm11/resolving/npm-resolver/test/index.ts b/pnpm11/resolving/npm-resolver/test/index.ts index 8705e2a219..166bd5f0a1 100644 --- a/pnpm11/resolving/npm-resolver/test/index.ts +++ b/pnpm11/resolving/npm-resolver/test/index.ts @@ -13,7 +13,7 @@ import { RegistryResponseError, } from '@pnpm/resolving.npm-resolver' import { fixtures } from '@pnpm/test-fixtures' -import type { ProjectRootDir, Registries } from '@pnpm/types' +import type { ProjectRootDir, RegistriesByScope } from '@pnpm/types' import { loadJsonFileSync } from 'load-json-file' import { omit } from 'ramda' import { temporaryDirectory } from 'tempy' @@ -31,10 +31,10 @@ const jsonMeta = loadJsonFileSync(f.find('JSON.json')) const brokenIntegrity = loadJsonFileSync(f.find('broken-integrity.json')) /* eslint-enable @typescript-eslint/no-explicit-any */ -const registries = { +const registriesByScope = { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', -} satisfies Registries +} satisfies RegistriesByScope const fetch = createFetchFromRegistry({}) const getAuthHeader = () => undefined @@ -49,7 +49,7 @@ beforeEach(async () => { }) test('resolveFromNpm()', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -57,7 +57,7 @@ test('resolveFromNpm()', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, { calcSpecifier: true }) @@ -82,7 +82,7 @@ test('resolveFromNpm()', async () => { }) test('resolveFromNpm() strips port 80 from http tarball URLs', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -101,7 +101,7 @@ test('resolveFromNpm() strips port 80 from http tarball URLs', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, { calcSpecifier: true }) @@ -112,7 +112,7 @@ test('resolveFromNpm() strips port 80 from http tarball URLs', async () => { }) test('resolveFromNpm() does not save mutated meta to the cache', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -120,7 +120,7 @@ test('resolveFromNpm() does not save mutated meta to the cache', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) @@ -133,7 +133,7 @@ test('resolveFromNpm() does not save mutated meta to the cache', async () => { }) test('resolveFromNpm() should save metadata to a unique file when the package name has upper case letters', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/JSON', method: 'GET' }) .reply(200, jsonMeta) @@ -141,7 +141,7 @@ test('resolveFromNpm() should save metadata to a unique file when the package na const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'JSON', bareSpecifier: '1.0.0' }, {}) @@ -161,7 +161,7 @@ test('relative workspace protocol is skipped', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ bareSpecifier: 'workspace:../is-positive' }, { projectDir: '/home/istvan/src', @@ -171,7 +171,7 @@ test('relative workspace protocol is skipped', async () => { }) test('dry run', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -179,7 +179,7 @@ test('dry run', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, { dryRun: true, @@ -202,28 +202,28 @@ test('dry run', async () => { }) test('resolve to latest when no bareSpecifier specified', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive' }, {}) expect(resolveResult!.id).toBe('is-positive@3.1.0') }) test('resolve to defaultTag when no bareSpecifier specified', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive' }, { defaultTag: 'stable', @@ -232,14 +232,14 @@ test('resolve to defaultTag when no bareSpecifier specified', async () => { }) test('resolve to biggest non-deprecated version that satisfies the range', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMetaWithDeprecated) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3' }, { }) @@ -247,56 +247,56 @@ test('resolve to biggest non-deprecated version that satisfies the range', async }) test('resolve to a deprecated version if there are no non-deprecated ones that satisfy the range', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMetaWithDeprecated) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '2' }, {}) expect(resolveResult!.id).toBe('is-positive@2.0.0') }) test('can resolve aliased dependency', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'positive', bareSpecifier: 'npm:is-positive@1.0.0' }, {}) expect(resolveResult!.id).toBe('is-positive@1.0.0') }) test('can resolve aliased dependency w/o version specifier', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'positive', bareSpecifier: 'npm:is-positive' }, {}) expect(resolveResult!.id).toBe('is-positive@3.1.0') }) test('can resolve aliased dependency w/o version specifier to default tag', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'positive', bareSpecifier: 'npm:is-positive' }, { defaultTag: 'stable', @@ -307,21 +307,21 @@ test('can resolve aliased dependency w/o version specifier to default tag', asyn }) test('can resolve aliased scoped dependency', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/@sindresorhus%2Fis', method: 'GET' }) .reply(200, sindresorhusIsMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is', bareSpecifier: 'npm:@sindresorhus/is@0.6.0' }, {}) expect(resolveResult!.id).toBe('@sindresorhus/is@0.6.0') }) test('resolveFromNpm() passes package name to auth header lookup', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/@sindresorhus%2Fis', method: 'GET', @@ -337,58 +337,58 @@ test('resolveFromNpm() passes package name to auth header lookup', async () => { const { resolveFromNpm } = createNpmResolver(fetch, scopedGetAuthHeader, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is', bareSpecifier: 'npm:@sindresorhus/is@0.6.0' }, {}) expect(resolveResult!.id).toBe('@sindresorhus/is@0.6.0') - expect(calls).toContainEqual({ uri: registries.default, pkgName: '@sindresorhus/is' }) + expect(calls).toContainEqual({ uri: registriesByScope.default, pkgName: '@sindresorhus/is' }) }) test('can resolve aliased scoped dependency w/o version specifier', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/@sindresorhus%2Fis', method: 'GET' }) .reply(200, sindresorhusIsMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is', bareSpecifier: 'npm:@sindresorhus/is' }, {}) expect(resolveResult!.id).toBe('@sindresorhus/is@0.7.0') }) test('can resolve package with version prefixed with v', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'v1.0.0' }, {}) expect(resolveResult!.id).toBe('is-positive@1.0.0') }) test('can resolve package version loosely', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '= 1.0.0' }, {}) expect(resolveResult!.id).toBe('is-positive@1.0.0') }) test("resolves to latest if it's inside the wanted range. Even if there are newer versions available inside the range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -398,7 +398,7 @@ test("resolves to latest if it's inside the wanted range. Even if there are newe const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -410,7 +410,7 @@ test("resolves to latest if it's inside the wanted range. Even if there are newe }) test("resolves to latest if it's inside the preferred range. Even if there are newer versions available inside the preferred range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -420,7 +420,7 @@ test("resolves to latest if it's inside the preferred range. Even if there are n const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -436,7 +436,7 @@ test("resolves to latest if it's inside the preferred range. Even if there are n }) test("resolve using the wanted range, when it doesn't intersect with the preferred range. Even if the preferred range contains the latest version", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -446,7 +446,7 @@ test("resolve using the wanted range, when it doesn't intersect with the preferr const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -461,7 +461,7 @@ test("resolve using the wanted range, when it doesn't intersect with the preferr }) test("use the preferred version if it's inside the wanted range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -471,7 +471,7 @@ test("use the preferred version if it's inside the wanted range", async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -487,7 +487,7 @@ test("use the preferred version if it's inside the wanted range", async () => { }) test("ignore the preferred version if it's not inside the wanted range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -497,7 +497,7 @@ test("ignore the preferred version if it's not inside the wanted range", async ( const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -515,7 +515,7 @@ test('ignore the lockfile-derived preferred version when updateRequested is true // (seeded at EXISTING_VERSION_SELECTOR_WEIGHT) must not hold it at the // old version — ignoring them is what makes it an update. Every selector // a fresh install would apply stays in effect (see the tests below). - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -525,7 +525,7 @@ test('ignore the lockfile-derived preferred version when updateRequested is true const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -544,7 +544,7 @@ test('keep honoring manifest and chain-propagated preferred versions when update // (weight 1000) and onto versions propagated down the dependency chain // (plain selectors). An update of the same package must produce the same // result instead of installing a duplicate version. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -555,7 +555,7 @@ test('keep honoring manifest and chain-propagated preferred versions when update const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const manifestPinResult = await resolveFromNpm({ alias: 'is-positive', @@ -585,7 +585,7 @@ test('a manifest pin that is also locked keeps only its manifest weight when upd // onto a manifest entry that pins the same version (1000 + 1_000_000). // The strip must subtract the lockfile contribution rather than drop the // selector — a fresh install would still honor the manifest pin. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -595,7 +595,7 @@ test('a manifest pin that is also locked keeps only its manifest weight when upd const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -614,7 +614,7 @@ test('still honor the preferred version when updateRequested is false (dedup dur // install-time dedup. With updateRequested unset/false, a preferred // version inside the wanted range continues to win over latest so // pnpm avoids creating a duplicate. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -624,7 +624,7 @@ test('still honor the preferred version when updateRequested is false (dedup dur const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -645,14 +645,14 @@ test('preserve vulnerability-avoidance range selectors even when updateRequested // selectors in preferredVersions. Only the lockfile-derived pins may be // dropped for the targeted package — the range penalties must survive, or // the "fix" would re-pick the vulnerable highest-in-range version. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) // latest is 3.1.0 const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -674,7 +674,7 @@ test('preserve vulnerability-avoidance range selectors even when updateRequested }) test('use the preferred range if it intersects with the wanted range', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -684,7 +684,7 @@ test('use the preferred range if it intersects with the wanted range', async () const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -700,7 +700,7 @@ test('use the preferred range if it intersects with the wanted range', async () }) test('use the preferred range if it intersects with the wanted range (an array of preferred versions is passed)', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -710,7 +710,7 @@ test('use the preferred range if it intersects with the wanted range (an array o const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -729,7 +729,7 @@ test('use the preferred range if it intersects with the wanted range (an array o }) test("ignore the preferred range if it doesn't intersect with the wanted range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -739,7 +739,7 @@ test("ignore the preferred range if it doesn't intersect with the wanted range", const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -753,7 +753,7 @@ test("ignore the preferred range if it doesn't intersect with the wanted range", }) test("use the preferred dist-tag if it's inside the wanted range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -766,7 +766,7 @@ test("use the preferred dist-tag if it's inside the wanted range", async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -780,7 +780,7 @@ test("use the preferred dist-tag if it's inside the wanted range", async () => { }) test("ignore the preferred dist-tag if it's not inside the wanted range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -793,7 +793,7 @@ test("ignore the preferred dist-tag if it's not inside the wanted range", async const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -807,7 +807,7 @@ test("ignore the preferred dist-tag if it's not inside the wanted range", async }) test("prefer a version that is both inside the wanted and preferred ranges. Even if it's not the latest of any of them", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -819,7 +819,7 @@ test("prefer a version that is both inside the wanted and preferred ranges. Even const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -833,14 +833,14 @@ test("prefer a version that is both inside the wanted and preferred ranges. Even }) test('prefer the version that is matched by more preferred selectors', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -855,14 +855,14 @@ test('prefer the version that is matched by more preferred selectors', async () }) test('prefer the version that has bigger weight in preferred selectors', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -881,14 +881,14 @@ test('prefer the version that has bigger weight in preferred selectors', async ( }) test('versions without selector weights should have higher priority than negatively weighted versions', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -912,7 +912,7 @@ test('offline resolution fails when package meta not found in the store', async offline: true, storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {})).rejects @@ -922,7 +922,7 @@ test('offline resolution fails when package meta not found in the store', async }) test('offline resolution succeeds when package meta is found in the store', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -933,7 +933,7 @@ test('offline resolution succeeds when package meta is found in the store', asyn offline: false, storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) // This request will save the package's meta in the store @@ -945,7 +945,7 @@ test('offline resolution succeeds when package meta is found in the store', asyn offline: true, storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) @@ -954,7 +954,7 @@ test('offline resolution succeeds when package meta is found in the store', asyn }) test('prefer offline resolution does not fail when package meta not found in the store', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -962,7 +962,7 @@ test('prefer offline resolution does not fail when package meta not found in the preferOffline: true, storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) @@ -970,7 +970,7 @@ test('prefer offline resolution does not fail when package meta not found in the }) test('when prefer offline is used, meta from store is used, where latest might be out-of-date', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -983,14 +983,14 @@ test('when prefer offline is used, meta from store is used, where latest might b const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) // This request will save the package's meta in the store await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) } - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -1002,7 +1002,7 @@ test('when prefer offline is used, meta from store is used, where latest might b preferOffline: true, storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {}) @@ -1014,20 +1014,20 @@ test('when prefer offline is used, meta from store is used, where latest might b test('error is thrown when package is not found in the registry', async () => { const notExistingPackage = 'foo' - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${notExistingPackage}`, method: 'GET' }) .reply(404, {}) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: notExistingPackage, bareSpecifier: '1.0.0' }, {})).rejects .toThrow( new RegistryResponseError( { - url: `${registries.default}${notExistingPackage}`, + url: `${registriesByScope.default}${notExistingPackage}`, }, { status: 404, @@ -1055,7 +1055,7 @@ test('error is thrown when registry not responding', async () => { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), retry: { retries: 1 }, - registries: { + registriesByScope: { default: notExistingRegistry, }, }) @@ -1074,20 +1074,20 @@ test('error is thrown when registry not responding', async () => { test('extra info is shown if package has valid semver appended', async () => { const notExistingPackage = 'foo1.0.0' - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: `/${notExistingPackage}`, method: 'GET' }) .reply(404, {}) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: notExistingPackage, bareSpecifier: '1.0.0' }, {})).rejects .toThrow( new RegistryResponseError( { - url: `${registries.default}${notExistingPackage}`, + url: `${registriesByScope.default}${notExistingPackage}`, }, { status: 404, @@ -1099,14 +1099,14 @@ test('extra info is shown if package has valid semver appended', async () => { }) test('error is thrown when there is no package found for the requested version', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const wantedDependency = { alias: 'is-positive', bareSpecifier: '1000.0.0' } await expect(resolveFromNpm(wantedDependency, {})).rejects @@ -1114,26 +1114,26 @@ test('error is thrown when there is no package found for the requested version', new NoMatchingVersionError({ wantedDependency, packageMeta: isPositiveMeta, - registry: registries.default, + registry: registriesByScope.default, }) ) }) test('error is thrown when package needs authorization', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/needs-auth', method: 'GET' }) .reply(403, {}) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: 'needs-auth', bareSpecifier: '*' }, {})).rejects .toThrow( new RegistryResponseError( { - url: `${registries.default}needs-auth`, + url: `${registriesByScope.default}needs-auth`, }, { status: 403, @@ -1145,20 +1145,20 @@ test('error is thrown when package needs authorization', async () => { }) test('error is thrown when registry returns 400 Bad Request', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/bad-pkg', method: 'GET' }) .reply(400) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: 'bad-pkg', bareSpecifier: '1.0.0' }, {})).rejects .toThrow( new RegistryResponseError( { - url: `${registries.default}bad-pkg`, + url: `${registriesByScope.default}bad-pkg`, }, { status: 400, @@ -1170,14 +1170,14 @@ test('error is thrown when registry returns 400 Bad Request', async () => { }) test('error is thrown when there is no package found for the requested range', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const wantedDependency = { alias: 'is-positive', bareSpecifier: '^1000.0.0' } await expect(resolveFromNpm(wantedDependency, {})).rejects @@ -1185,20 +1185,20 @@ test('error is thrown when there is no package found for the requested range', a new NoMatchingVersionError({ wantedDependency, packageMeta: isPositiveMeta, - registry: registries.default, + registry: registriesByScope.default, }) ) }) test('error is thrown when there is no package found for the requested tag', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const wantedDependency = { alias: 'is-positive', bareSpecifier: 'unknown-tag' } await expect(resolveFromNpm(wantedDependency, {})).rejects @@ -1206,13 +1206,13 @@ test('error is thrown when there is no package found for the requested tag', asy new NoMatchingVersionError({ wantedDependency, packageMeta: isPositiveMeta, - registry: registries.default, + registry: registriesByScope.default, }) ) }) test('resolveFromNpm() loads full metadata even if non-full metadata is already cached in store', async () => { - const mockPool = getMockAgent().get(registries.default.replace(/\/$/, '')) + const mockPool = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) // First request returns abbreviated metadata mockPool.intercept({ path: '/is-positive', method: 'GET' }).reply(200, isPositiveMeta) // Second request returns full metadata @@ -1225,7 +1225,7 @@ test('resolveFromNpm() loads full metadata even if non-full metadata is already fullMetadata: false, storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) expect(resolveResult!.manifest!['scripts']).toBeFalsy() @@ -1236,7 +1236,7 @@ test('resolveFromNpm() loads full metadata even if non-full metadata is already fullMetadata: true, storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) expect(resolveResult!.manifest!['scripts']).toBeTruthy() @@ -1244,7 +1244,7 @@ test('resolveFromNpm() loads full metadata even if non-full metadata is already }) test('resolve when tarball URL is requested from the registry', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1252,11 +1252,11 @@ test('resolve when tarball URL is requested from the registry', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', - bareSpecifier: `${registries.default}is-positive/-/is-positive-1.0.0.tgz`, + bareSpecifier: `${registriesByScope.default}is-positive/-/is-positive-1.0.0.tgz`, }, { calcSpecifier: true, }) @@ -1271,7 +1271,7 @@ test('resolve when tarball URL is requested from the registry', async () => { expect(resolveResult!.manifest).toBeTruthy() expect(resolveResult!.manifest!.name).toBe('is-positive') expect(resolveResult!.manifest!.version).toBe('1.0.0') - expect(resolveResult!.normalizedBareSpecifier).toBe(`${registries.default}is-positive/-/is-positive-1.0.0.tgz`) + expect(resolveResult!.normalizedBareSpecifier).toBe(`${registriesByScope.default}is-positive/-/is-positive-1.0.0.tgz`) // The resolve function does not wait for the package meta cache file to be saved // so we must delay for a bit in order to read it @@ -1282,7 +1282,7 @@ test('resolve when tarball URL is requested from the registry', async () => { }) test('resolve when tarball URL is requested from the registry and alias is not specified', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1290,9 +1290,9 @@ test('resolve when tarball URL is requested from the registry and alias is not s const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) - const resolveResult = await resolveFromNpm({ bareSpecifier: `${registries.default}is-positive/-/is-positive-1.0.0.tgz` }, { calcSpecifier: true }) + const resolveResult = await resolveFromNpm({ bareSpecifier: `${registriesByScope.default}is-positive/-/is-positive-1.0.0.tgz` }, { calcSpecifier: true }) expect(resolveResult!.resolvedVia).toBe('npm-registry') expect(resolveResult!.id).toBe('is-positive@1.0.0') @@ -1304,7 +1304,7 @@ test('resolve when tarball URL is requested from the registry and alias is not s expect(resolveResult!.manifest).toBeTruthy() expect(resolveResult!.manifest!.name).toBe('is-positive') expect(resolveResult!.manifest!.version).toBe('1.0.0') - expect(resolveResult!.normalizedBareSpecifier).toBe(`${registries.default}is-positive/-/is-positive-1.0.0.tgz`) + expect(resolveResult!.normalizedBareSpecifier).toBe(`${registriesByScope.default}is-positive/-/is-positive-1.0.0.tgz`) // The resolve function does not wait for the package meta cache file to be saved // so we must delay for a bit in order to read it @@ -1315,7 +1315,7 @@ test('resolve when tarball URL is requested from the registry and alias is not s }) test('resolve from local directory when it matches the latest version of the package', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1323,7 +1323,7 @@ test('resolve from local directory when it matches the latest version of the pac const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, { projectDir: '/home/istvan/src', @@ -1353,7 +1353,7 @@ test('resolve from local directory when it matches the latest version of the pac }) test('resolve injected dependency from local directory when it matches the latest version of the package', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1361,7 +1361,7 @@ test('resolve injected dependency from local directory when it matches the lates const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', injected: true, bareSpecifier: '1.0.0' }, { projectDir: '/home/istvan/src', @@ -1394,7 +1394,7 @@ test('resolve injected dependency from local directory when it matches the lates }) test('do not resolve from local directory when alwaysTryWorkspacePackages is false', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1402,7 +1402,7 @@ test('do not resolve from local directory when alwaysTryWorkspacePackages is fal const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, { alwaysTryWorkspacePackages: false, @@ -1437,7 +1437,7 @@ test('resolve from local directory when alwaysTryWorkspacePackages is false but const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'workspace:*' }, { alwaysTryWorkspacePackages: false, @@ -1471,7 +1471,7 @@ test('resolve from local directory when alwaysTryWorkspacePackages is false but const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'positive', bareSpecifier: 'workspace:is-positive@*' }, { alwaysTryWorkspacePackages: false, @@ -1501,7 +1501,7 @@ test('resolve from local directory when alwaysTryWorkspacePackages is false but }) test('use version from the registry if it is newer than the local one', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -1511,7 +1511,7 @@ test('use version from the registry if it is newer than the local one', async () const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -1544,7 +1544,7 @@ test('use version from the registry if it is newer than the local one', async () }) test('preferWorkspacePackages: use version from the workspace even if there is newer version in the registry', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -1554,7 +1554,7 @@ test('preferWorkspacePackages: use version from the workspace even if there is n const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -1585,7 +1585,7 @@ test('preferWorkspacePackages: use version from the workspace even if there is n }) test('use local version if it is newer than the latest in the registry', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { ...isPositiveMeta, @@ -1595,7 +1595,7 @@ test('use local version if it is newer than the latest in the registry', async ( const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', @@ -1628,7 +1628,7 @@ test('use local version if it is newer than the latest in the registry', async ( }) test('resolve from local directory when package is not found in the registry', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(404, {}) @@ -1636,7 +1636,7 @@ test('resolve from local directory when package is not found in the registry', a const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1' }, { projectDir: '/home/istvan/src/foo', @@ -1680,7 +1680,7 @@ test('resolve from local directory when package is not found in the registry', a }) test('resolve from local directory when package is not found in the registry and latest installed', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(404, {}) @@ -1688,7 +1688,7 @@ test('resolve from local directory when package is not found in the registry and const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'latest' }, { projectDir: '/home/istvan/src', @@ -1732,7 +1732,7 @@ test('resolve from local directory when package is not found in the registry and }) test('resolve from local directory when package is not found in the registry and local prerelease available', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(404, {}) @@ -1740,7 +1740,7 @@ test('resolve from local directory when package is not found in the registry and const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'latest' }, { projectDir: '/home/istvan/src', @@ -1770,7 +1770,7 @@ test('resolve from local directory when package is not found in the registry and }) test('resolve from local directory when package is not found in the registry and specific version is requested', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(404, {}) @@ -1778,7 +1778,7 @@ test('resolve from local directory when package is not found in the registry and const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.1.0' }, { projectDir: '/home/istvan/src/foo', @@ -1822,7 +1822,7 @@ test('resolve from local directory when package is not found in the registry and }) test('resolve from local directory when the requested version is not found in the registry but is available locally', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1830,7 +1830,7 @@ test('resolve from local directory when the requested version is not found in th const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '100.0.0' }, { projectDir: '/home/istvan/src/foo', @@ -1864,7 +1864,7 @@ test('workspace protocol: resolve from local directory even when it does not mat const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'workspace:^3.0.0' }, { projectDir: '/home/istvan/src', @@ -1894,7 +1894,7 @@ test('workspace protocol: resolve from local directory even when it does not mat }) test('workspace protocol: resolve from local package that has a pre-release version', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1902,7 +1902,7 @@ test('workspace protocol: resolve from local package that has a pre-release vers const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'workspace:*' }, { projectDir: '/home/istvan/src', @@ -1932,7 +1932,7 @@ test('workspace protocol: resolve from local package that has a pre-release vers }) test("workspace protocol: don't resolve from local package that has a pre-release version that don't satisfy the range", async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -1940,7 +1940,7 @@ test("workspace protocol: don't resolve from local package that has a pre-releas const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '2' }, { projectDir: '/home/istvan/src', @@ -1970,7 +1970,7 @@ test('workspace protocol: resolution fails if there is no matching local package const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const projectDir = '/home/istvan/src' @@ -1994,7 +1994,7 @@ test('workspace protocol: resolution fails if there is no matching local package const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const projectDir = '/home/istvan/src' @@ -2028,7 +2028,7 @@ test('workspace protocol: resolution fails if there are no local packages', asyn const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) let err!: Error @@ -2048,7 +2048,7 @@ test('throws error when package name has "/" but not starts with @scope', async const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: 'regenerator/runtime' }, {})).rejects .toThrow( @@ -2057,7 +2057,7 @@ test('throws error when package name has "/" but not starts with @scope', async }) test('resolveFromNpm() should always return the name of the package that is specified in the root of the meta', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveBrokenMeta) @@ -2065,7 +2065,7 @@ test('resolveFromNpm() should always return the name of the package that is spec const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.1.0' }, {}) @@ -2089,7 +2089,7 @@ test('resolveFromNpm() should always return the name of the package that is spec }) test('request to metadata is retried if the received JSON is broken', async () => { - const localRegistries: Registries = { + const localRegistries: RegistriesByScope = { default: 'https://registry1.com/', } const mockPool = getMockAgent().get(localRegistries.default.replace(/\/$/, '')) @@ -2104,7 +2104,7 @@ test('request to metadata is retried if the received JSON is broken', async () = const { resolveFromNpm } = createResolveFromNpm({ retry: { retries: 1 }, storeDir: temporaryDirectory(), - registries: localRegistries, + registriesByScope: localRegistries, cacheDir, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {})! @@ -2113,7 +2113,7 @@ test('request to metadata is retried if the received JSON is broken', async () = }) test('request to a package with unpublished versions', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/code-snippet', method: 'GET' }) .reply(200, loadJsonFileSync(f.find('unpublished.json')) as object) @@ -2121,7 +2121,7 @@ test('request to a package with unpublished versions', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: 'code-snippet' }, {})).rejects @@ -2131,7 +2131,7 @@ test('request to a package with unpublished versions', async () => { }) test('request to a package with no versions', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/code-snippet', method: 'GET' }) .reply(200, { name: 'code-snippet' }) @@ -2139,7 +2139,7 @@ test('request to a package with no versions', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) await expect(resolveFromNpm({ alias: 'code-snippet' }, {})).rejects @@ -2150,7 +2150,7 @@ test('request to a package with no versions', async () => { test('request to a package with no dist-tags', async () => { const isPositiveMeta = omit(['dist-tags'], loadJsonFileSync(f.find('is-positive.json'))) // eslint-disable-line - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -2158,7 +2158,7 @@ test('request to a package with no dist-tags', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) let thrown: any // eslint-disable-line @@ -2176,7 +2176,7 @@ test('request to a package with no dist-tags', async () => { }) test('resolveFromNpm() does not fail if the meta file contains no integrity information', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, brokenIntegrity) @@ -2184,7 +2184,7 @@ test('resolveFromNpm() does not fail if the meta file contains no integrity info const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '2.0.0' }, {}) @@ -2201,7 +2201,7 @@ test('resolveFromNpm() does not fail if the meta file contains no integrity info }) test('resolveFromNpm() fails if the meta file contains invalid shasum', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, brokenIntegrity) @@ -2209,7 +2209,7 @@ test('resolveFromNpm() fails if the meta file contains invalid shasum', async () const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) await expect( resolveFromNpm({ alias: 'is-positive', bareSpecifier: '1.0.0' }, {}) @@ -2225,7 +2225,7 @@ test('resolveFromNpm() should normalize the registry', async () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: path.join(cacheDir, 'store'), cacheDir, - registries: { + registriesByScope: { default: 'https://reg.com/owner', }, }) @@ -2244,7 +2244,7 @@ test('resolveFromNpm() should normalize the registry', async () => { }) test('pick lowest version by * when there are only prerelease versions', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, { versions: { @@ -2265,7 +2265,7 @@ test('pick lowest version by * when there are only prerelease versions', async ( const { resolveFromNpm } = createResolveFromNpm({ storeDir: path.join(cacheDir, 'store'), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '*' }, { pickLowestVersion: true, @@ -2278,7 +2278,7 @@ test('pick lowest version by * when there are only prerelease versions', async ( }) test('throws an error with the available workspace versions when workspace package version does not match and package is not found in the registry', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(404, {}) @@ -2286,7 +2286,7 @@ test('throws an error with the available workspace versions when workspace packa const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const projectDir = '/home/istvan/src' @@ -2317,7 +2317,7 @@ test('throws an error with the available workspace versions when workspace packa }) test('throws an error with the available workspace versions when workspace package version does not match and registry has no matching version', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -2325,7 +2325,7 @@ test('throws an error with the available workspace versions when workspace packa const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const projectDir = '/home/istvan/src' @@ -2356,7 +2356,7 @@ test('throws an error with the available workspace versions when workspace packa }) test('non-404 registry errors are not masked when the workspace package version does not match', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(500, {}) @@ -2364,7 +2364,7 @@ test('non-404 registry errors are not masked when the workspace package version const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, retry: { retries: 0 }, }) @@ -2394,7 +2394,7 @@ test('non-404 registry errors are not masked when the workspace package version }) test('resolve from registry when workspace package version does not match the requested version', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -2402,7 +2402,7 @@ test('resolve from registry when workspace package version does not match the re const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.1.0' }, { projectDir: '/home/istvan/src', diff --git a/pnpm11/resolving/npm-resolver/test/metaCache.test.ts b/pnpm11/resolving/npm-resolver/test/metaCache.test.ts index f225636fe6..63428cd539 100644 --- a/pnpm11/resolving/npm-resolver/test/metaCache.test.ts +++ b/pnpm11/resolving/npm-resolver/test/metaCache.test.ts @@ -65,7 +65,7 @@ test('getPkgMetaCacheKey canonicalizes the registry so trailing-slash variants s expect(getPkgMetaCacheKey('https://reg.example.com', 'foo', false, false)) .toBe(getPkgMetaCacheKey('https://reg.example.com/', 'foo', false, false)) - // Registries that genuinely differ by path are never collapsed. + // RegistriesByScope that genuinely differ by path are never collapsed. expect(getPkgMetaCacheKey('https://reg.example.com/team-a/', 'foo', false, false)) .not.toBe(getPkgMetaCacheKey('https://reg.example.com/team-b/', 'foo', false, false)) diff --git a/pnpm11/resolving/npm-resolver/test/normalizeAbbreviated.test.ts b/pnpm11/resolving/npm-resolver/test/normalizeAbbreviated.test.ts index f51a9829af..ae2469f2fd 100644 --- a/pnpm11/resolving/npm-resolver/test/normalizeAbbreviated.test.ts +++ b/pnpm11/resolving/npm-resolver/test/normalizeAbbreviated.test.ts @@ -4,12 +4,12 @@ import { afterEach, beforeEach, expect, test } from '@jest/globals' import { ABBREVIATED_META_DIR } from '@pnpm/constants' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { temporaryDirectory } from 'tempy' import { getMockAgent, retryLoadJsonFile, setupMockAgent, teardownMockAgent } from './utils/index.js' -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } @@ -58,7 +58,7 @@ beforeEach(async () => { test('a full document served for an abbreviated request is normalized before caching (registry ignored the Accept header)', async () => { const cacheDir = temporaryDirectory() - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/foo', method: 'GET' }) // application/json (not the abbreviated content type) signals that the // registry ignored our abbreviated Accept header and served the full doc. @@ -67,7 +67,7 @@ test('a full document served for an abbreviated request is normalized before cac const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const res = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^1.0.0' }, {}) expect(res!.id).toBe('foo@1.0.0') @@ -111,7 +111,7 @@ test('a document served with the abbreviated content type is cached verbatim (re }, } - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/foo', method: 'GET' }) // Media types are case-insensitive and may carry parameters; both must // still be recognized as the abbreviated content type. @@ -120,7 +120,7 @@ test('a document served with the abbreviated content type is cached verbatim (re const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const res = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^1.0.0' }, {}) expect(res!.id).toBe('foo@1.0.0') diff --git a/pnpm11/resolving/npm-resolver/test/optionalDependencies.test.ts b/pnpm11/resolving/npm-resolver/test/optionalDependencies.test.ts index 83e38d9fbe..4bfeee144d 100644 --- a/pnpm11/resolving/npm-resolver/test/optionalDependencies.test.ts +++ b/pnpm11/resolving/npm-resolver/test/optionalDependencies.test.ts @@ -2,14 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from '@jest/globals' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { temporaryDirectory } from 'tempy' import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js' -const registries = { +const registriesByScope = { default: 'https://registry.npmjs.org/', -} satisfies Registries +} satisfies RegistriesByScope const fetch = createFetchFromRegistry({}) const getAuthHeader = () => undefined @@ -46,14 +46,14 @@ describe('optional dependencies', () => { } // Mock the full metadata request for optional dependency - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/platform-pkg', method: 'GET' }) .reply(200, packageMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const result = await resolveFromNpm( @@ -104,7 +104,7 @@ describe('optional dependencies', () => { }, } - const mockPool = getMockAgent().get(registries.default.replace(/\/$/, '')) + const mockPool = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) // First request: abbreviated metadata for regular dependency (accept header prefers abbreviated) mockPool.intercept({ path: '/cache-test', @@ -123,7 +123,7 @@ describe('optional dependencies', () => { const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) // Resolve as regular dependency - should get abbreviated metadata diff --git a/pnpm11/resolving/npm-resolver/test/publishedBy.test.ts b/pnpm11/resolving/npm-resolver/test/publishedBy.test.ts index 4b390459cd..a9f20e263d 100644 --- a/pnpm11/resolving/npm-resolver/test/publishedBy.test.ts +++ b/pnpm11/resolving/npm-resolver/test/publishedBy.test.ts @@ -6,7 +6,7 @@ import { ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR } from '@pnpm/constants' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' import { fixtures } from '@pnpm/test-fixtures' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { loadJsonFileSync } from 'load-json-file' import semver from 'semver' import { temporaryDirectory } from 'tempy' @@ -15,7 +15,7 @@ import { getMockAgent, retryLoadJsonFile, setupMockAgent, teardownMockAgent } fr const f = fixtures(import.meta.dirname) -const registries: Registries = { +const registriesByScope: RegistriesByScope = { default: 'https://registry.npmjs.org/', } @@ -38,7 +38,7 @@ beforeEach(async () => { }) test('fall back to a newer version if there is no version published by the given date', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/bad-dates', method: 'GET' }) .reply(200, badDatesMeta) @@ -48,7 +48,7 @@ test('fall back to a newer version if there is no version published by the given cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'bad-dates', bareSpecifier: '^1.0.0' }, { publishedBy: new Date('2015-08-17T19:26:00.508Z'), @@ -72,7 +72,7 @@ test('request metadata when the one in cache does not have a version satisfying 'utf8' ) - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/bad-dates', method: 'GET' }) .reply(200, badDatesMeta) @@ -81,7 +81,7 @@ test('request metadata when the one in cache does not have a version satisfying cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'bad-dates', bareSpecifier: '^1.0.0' }, { publishedBy: new Date('2015-08-17T19:26:00.508Z'), @@ -117,7 +117,7 @@ test('reports an immature pick via policyViolation even when loaded from cache a 'utf8' ) - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/foo', method: 'GET' }) .reply(200, fooMeta) @@ -129,7 +129,7 @@ test('reports an immature pick via policyViolation even when loaded from cache a cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const result = await resolveFromNpm({ alias: 'foo', bareSpecifier: '1.0.0' }, { publishedBy: new Date('2015-08-17T19:26:00.508Z'), @@ -149,7 +149,7 @@ test('should skip time field validation for excluded packages', async () => { fs.mkdirSync(path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org`), { recursive: true }) fs.writeFileSync(path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org/is-positive.jsonl`), JSON.stringify(metaWithoutTime), 'utf8') - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, metaWithoutTime) @@ -158,7 +158,7 @@ test('should skip time field validation for excluded packages', async () => { cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const publishedByExclude = (pkgName: string) => pkgName === 'is-positive' @@ -175,7 +175,7 @@ test('should skip time field validation for excluded packages', async () => { test('use abbreviated metadata when modified date is older than publishedBy', async () => { // is-positive abbreviated has modified: "2017-08-17T19:26:00.508Z" // publishedBy is set to 2018, so modified < publishedBy → all versions are old enough - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveAbbreviatedMeta) @@ -183,7 +183,7 @@ test('use abbreviated metadata when modified date is older than publishedBy', as const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { publishedBy: new Date('2018-01-01T00:00:00.000Z'), @@ -199,7 +199,7 @@ test('use abbreviated metadata when modified date equals publishedBy (boundary c // shortcut, not throw MISSING_TIME or re-fetch full metadata: `modified` // is an upper bound on every version's publish time, so the boundary // case is mature under the per-version `<=` filter. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveAbbreviatedMeta) @@ -207,7 +207,7 @@ test('use abbreviated metadata when modified date equals publishedBy (boundary c const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { publishedBy: new Date('2017-08-17T19:26:00.508Z'), @@ -224,7 +224,7 @@ test('re-fetch full metadata when abbreviated modified date is recent', async () modified: '2015-06-10T00:00:00.000Z', } - const agent = getMockAgent().get(registries.default.replace(/\/$/, '')) + const agent = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) // First request: abbreviated agent.intercept({ path: '/is-positive', method: 'GET' }) .reply(200, recentAbbreviated) @@ -236,7 +236,7 @@ test('re-fetch full metadata when abbreviated modified date is recent', async () const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) // publishedBy is 2015-06-05, modified is 2015-06-10 → modified >= publishedBy → needs full const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, { @@ -251,7 +251,7 @@ test('re-fetch full metadata when abbreviated modified date is recent', async () test('ignoreMissingTimeField=true skips maturity check when full metadata has no time field', async () => { const { time: _time, ...metaWithoutTime } = isPositiveMeta - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, metaWithoutTime) @@ -261,7 +261,7 @@ test('ignoreMissingTimeField=true skips maturity check when full metadata has no cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, ignoreMissingTimeField: true, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { @@ -285,7 +285,7 @@ test('ignoreMissingTimeField=true still upgrades abbreviated→full when time is modified: '2015-06-10T00:00:00.000Z', } - const agent = getMockAgent().get(registries.default.replace(/\/$/, '')) + const agent = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) agent.intercept({ path: '/is-positive', method: 'GET' }) .reply(200, recentAbbreviated) agent.intercept({ path: '/is-positive', method: 'GET' }) @@ -295,7 +295,7 @@ test('ignoreMissingTimeField=true still upgrades abbreviated→full when time is const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, ignoreMissingTimeField: true, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, { @@ -309,7 +309,7 @@ test('ignoreMissingTimeField=true still upgrades abbreviated→full when time is test('ignoreMissingTimeField=false throws when full metadata has no time field', async () => { const { time: _time, ...metaWithoutTime } = isPositiveMeta - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, metaWithoutTime) @@ -319,7 +319,7 @@ test('ignoreMissingTimeField=false throws when full metadata has no time field', cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, ignoreMissingTimeField: false, }) await expect(resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { @@ -346,7 +346,7 @@ test('ignoreMissingTimeField=true skips maturity check from disk-cached metadata cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, ignoreMissingTimeField: true, offline: true, }) @@ -393,14 +393,14 @@ test('falls through to the registry fetch when cached abbreviated meta lacks tim // Mock the network fetch (the path the fix lets us fall through to). Returns // full metadata with the `time` field present. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, ignoreMissingTimeField: true, }) @@ -434,14 +434,14 @@ test('falls through to the registry fetch even with default ignoreMissingTimeFie 'utf8' ) - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.0.0' }, { @@ -482,7 +482,7 @@ test('upgrades cached abbreviated metadata to full when 304 Not Modified and pub // First fetch: conditional request with the cached etag → 304 Not Modified. // Second fetch: upgrade request with `fullMetadata: true` → 200 with time-bearing full metadata. - const agent = getMockAgent().get(registries.default.replace(/\/$/, '')) + const agent = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) agent.intercept({ path: '/is-positive', method: 'GET', @@ -494,7 +494,7 @@ test('upgrades cached abbreviated metadata to full when 304 Not Modified and pub const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) // publishedBy is 2015-06-05, cached meta `modified` is 2015-06-10 → upgrade triggers. // is-positive@1.0.0 (2015-06-02) is mature; @3.1.0 (2015-08-21) is not. @@ -529,7 +529,7 @@ test('use cached metadata based on file mtime when publishedBy is set', async () const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) // publishedBy in the past relative to file mtime (file was just written = now) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { @@ -567,14 +567,14 @@ test('excluded packages bypass the mtime cache shortcut and refresh stale metada fs.utimesSync(cachePath, forcedMtime, forcedMtime) // Network has fresh metadata with 3.1.0 as latest. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveAbbreviatedMeta) const { resolveFromNpm } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { // Mirror mtime is explicitly set after this cutoff to guarantee the @@ -595,7 +595,7 @@ test('latest is suppressed when publishedBy holds back the registry latest', asy // 2015-10-01 leaves 3.1.0 immature, so the '(X is available)' hint must not // fire: latest only ever names the actual latest tag, and here the policy // itself would refuse to install it. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -605,7 +605,7 @@ test('latest is suppressed when publishedBy holds back the registry latest', asy cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { publishedBy: new Date('2015-10-01T00:00:00.000Z'), @@ -616,7 +616,7 @@ test('latest is suppressed when publishedBy holds back the registry latest', asy }) test('latest is the raw registry tag when it satisfies publishedBy', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -626,7 +626,7 @@ test('latest is the raw registry tag when it satisfies publishedBy', async () => cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.0.0' }, { publishedBy: new Date('2016-02-01T00:00:00.000Z'), @@ -637,7 +637,7 @@ test('latest is the raw registry tag when it satisfies publishedBy', async () => }) test('latest is the raw registry tag when publishedBy is not set', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -647,7 +647,7 @@ test('latest is the raw registry tag when publishedBy is not set', async () => { cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {}) @@ -659,7 +659,7 @@ test('latest is the raw registry tag when publishedByExclude fully excludes the // The exclude policy returns true for is-positive, so the maturity policy // does not apply to it at all: neither the pick nor the latest hint may be // affected by the cutoff. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -669,7 +669,7 @@ test('latest is the raw registry tag when publishedByExclude fully excludes the cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { publishedBy: new Date('2015-10-01T00:00:00.000Z'), @@ -681,7 +681,7 @@ test('latest is the raw registry tag when publishedByExclude fully excludes the }) test('latest is the raw registry tag when publishedByExclude trusts that exact version', async () => { - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -691,7 +691,7 @@ test('latest is the raw registry tag when publishedByExclude trusts that exact v cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, { publishedBy: new Date('2015-10-01T00:00:00.000Z'), @@ -706,7 +706,7 @@ test('latest is suppressed when all versions are immature (fallback case)', asyn // publishedBy 2015-06-01 is before every is-positive version → the pick // falls back to the lowest version; latest stays undefined because the raw // tag (3.1.0) is immature too. - getMockAgent().get(registries.default.replace(/\/$/, '')) + getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) .intercept({ path: '/is-positive', method: 'GET' }) .reply(200, isPositiveMeta) @@ -716,7 +716,7 @@ test('latest is suppressed when all versions are immature (fallback case)', asyn cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, { publishedBy: new Date('2015-06-01T00:00:00.000Z'), diff --git a/pnpm11/resolving/npm-resolver/test/resolveJsr.test.ts b/pnpm11/resolving/npm-resolver/test/resolveJsr.test.ts index 953a4b8808..097bfe57ae 100644 --- a/pnpm11/resolving/npm-resolver/test/resolveJsr.test.ts +++ b/pnpm11/resolving/npm-resolver/test/resolveJsr.test.ts @@ -5,7 +5,7 @@ import { ABBREVIATED_META_DIR } from '@pnpm/constants' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' import { fixtures } from '@pnpm/test-fixtures' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { loadJsonFileSync } from 'load-json-file' import { temporaryDirectory } from 'tempy' @@ -17,10 +17,10 @@ const jsrRusGreetMeta = loadJsonFileSync(f.find('jsr-rus-greet.json')) const jsrLucaCasesMeta = loadJsonFileSync(f.find('jsr-luca-cases.json')) /* eslint-enable @typescript-eslint/no-explicit-any */ -const registries = { +const registriesByScope = { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', -} satisfies Registries +} satisfies RegistriesByScope const fetch = createFetchFromRegistry({}) const getAuthHeader = () => undefined @@ -36,10 +36,10 @@ beforeEach(async () => { test('resolveFromJsr() on jsr', async () => { const slash = '%2F' - const defaultPool = getMockAgent().get(registries.default.replace(/\/$/, '')) + const defaultPool = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) defaultPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(404, {}) defaultPool.intercept({ path: `/@jsr${slash}luca__cases`, method: 'GET' }).reply(404, {}) - const jsrPool = getMockAgent().get(registries['@jsr'].replace(/\/$/, '')) + const jsrPool = getMockAgent().get(registriesByScope['@jsr'].replace(/\/$/, '')) jsrPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(200, jsrRusGreetMeta) jsrPool.intercept({ path: `/@jsr${slash}luca__cases`, method: 'GET' }).reply(200, jsrLucaCasesMeta) @@ -47,7 +47,7 @@ test('resolveFromJsr() on jsr', async () => { const { resolveFromJsr } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromJsr({ alias: '@rus/greet', bareSpecifier: 'jsr:0.0.3' }, { calcSpecifier: true }) @@ -78,10 +78,10 @@ test('resolveFromJsr() on jsr', async () => { test('resolveFromJsr() on jsr with alias renaming', async () => { const slash = '%2F' - const defaultPool = getMockAgent().get(registries.default.replace(/\/$/, '')) + const defaultPool = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) defaultPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(404, {}) defaultPool.intercept({ path: `/@jsr${slash}luca__cases`, method: 'GET' }).reply(404, {}) - const jsrPool = getMockAgent().get(registries['@jsr'].replace(/\/$/, '')) + const jsrPool = getMockAgent().get(registriesByScope['@jsr'].replace(/\/$/, '')) jsrPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(200, jsrRusGreetMeta) jsrPool.intercept({ path: `/@jsr${slash}luca__cases`, method: 'GET' }).reply(200, jsrLucaCasesMeta) @@ -89,7 +89,7 @@ test('resolveFromJsr() on jsr with alias renaming', async () => { const { resolveFromJsr } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromJsr({ alias: 'greet', bareSpecifier: 'jsr:@rus/greet@0.0.3' }, {}) @@ -122,7 +122,7 @@ test('resolveFromJsr() on jsr with packages without scope', async () => { const { resolveFromJsr } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) await expect(resolveFromJsr({ alias: 'greet', bareSpecifier: 'jsr:0.0.3' }, {})).rejects.toMatchObject({ code: 'ERR_PNPM_MISSING_JSR_PACKAGE_SCOPE', @@ -137,16 +137,16 @@ test('resolveFromJsr() returns the immature pick with policyViolation when publi // decides what to do with it. This is the named-registry / jsr path's // coverage for inline violation reporting. const slash = '%2F' - const defaultPool = getMockAgent().get(registries.default.replace(/\/$/, '')) + const defaultPool = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) defaultPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(404, {}) - const jsrPool = getMockAgent().get(registries['@jsr'].replace(/\/$/, '')) + const jsrPool = getMockAgent().get(registriesByScope['@jsr'].replace(/\/$/, '')) jsrPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(200, jsrRusGreetMeta) const cacheDir = temporaryDirectory() const { resolveFromJsr } = createResolveFromNpm({ storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const result = await resolveFromJsr( { alias: '@rus/greet', bareSpecifier: 'jsr:0.0.3' }, @@ -171,9 +171,9 @@ test('resolveFromJsr() suppresses latest when publishedBy holds back the raw tag // (pickFromSimpleRegistry) must suppress latest rather than surface a tag the // policy would refuse to install. const slash = '%2F' - const defaultPool = getMockAgent().get(registries.default.replace(/\/$/, '')) + const defaultPool = getMockAgent().get(registriesByScope.default.replace(/\/$/, '')) defaultPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(404, {}) - const jsrPool = getMockAgent().get(registries['@jsr'].replace(/\/$/, '')) + const jsrPool = getMockAgent().get(registriesByScope['@jsr'].replace(/\/$/, '')) jsrPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(200, jsrRusGreetMeta) const cacheDir = temporaryDirectory() @@ -182,7 +182,7 @@ test('resolveFromJsr() suppresses latest when publishedBy holds back the raw tag cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const result = await resolveFromJsr( { alias: '@rus/greet', bareSpecifier: 'jsr:0.0.2' }, diff --git a/pnpm11/resolving/npm-resolver/test/resolveNamedRegistry.test.ts b/pnpm11/resolving/npm-resolver/test/resolveNamedRegistry.test.ts index f54b60d7b2..d2b2b4fbbf 100644 --- a/pnpm11/resolving/npm-resolver/test/resolveNamedRegistry.test.ts +++ b/pnpm11/resolving/npm-resolver/test/resolveNamedRegistry.test.ts @@ -1,12 +1,12 @@ import path from 'node:path' import { afterEach, beforeEach, expect, test } from '@jest/globals' -import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries' +import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries' import { ABBREVIATED_META_DIR } from '@pnpm/constants' import { createFetchFromRegistry } from '@pnpm/network.fetch' import { createNpmResolver } from '@pnpm/resolving.npm-resolver' import { fixtures } from '@pnpm/test-fixtures' -import type { Registries } from '@pnpm/types' +import type { RegistriesByScope } from '@pnpm/types' import { loadJsonFileSync } from 'load-json-file' import { temporaryDirectory } from 'tempy' @@ -23,10 +23,10 @@ const ENTERPRISE_REGISTRY = 'https://npm.enterprise.example.com/' // The `@github` scope is no longer defaulted to GitHub Packages — so public // `@github/*` npm installs are not hijacked. The `gh:` prefix resolves via // the built-in `gh` named-registry alias instead. -const registries = { +const registriesByScope = { default: 'https://registry.npmjs.org/', '@jsr': 'https://npm.jsr.io/', -} satisfies Registries +} satisfies RegistriesByScope const fetch = createFetchFromRegistry({}) @@ -51,7 +51,7 @@ test('resolveFromNamedRegistry() resolves a scoped package published to GitHub P const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir, - registries, + registriesByScope, }) const resolveResult = await resolveFromNamedRegistry( @@ -97,10 +97,10 @@ test('resolveFromNamedRegistry() reaches the public registry through the built-i const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries: { + registriesByScope: { default: ENTERPRISE_REGISTRY, '@jsr': 'https://npm.jsr.io/', - } satisfies Registries, + } satisfies RegistriesByScope, }) const resolveResult = await resolveFromNamedRegistry( @@ -121,10 +121,10 @@ test('resolveFromNamedRegistry() lets a proxying org override the built-in npmjs const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, // Same escape hatch GHES users have for `gh`: an org that mirrors npmjs // points `npmjs` at the mirror so nothing reaches the public host. - namedRegistries: normalizeNamedRegistries({ + registriesByPrefix: normalizeRegistriesByPrefix({ npmjs: ENTERPRISE_REGISTRY, }), }) @@ -147,7 +147,7 @@ test('resolveFromNamedRegistry() preserves the scoped package name when the alia const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNamedRegistry( @@ -183,7 +183,7 @@ test('resolveFromNamedRegistry() looks up the auth header by the named registry { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, } ) @@ -209,14 +209,14 @@ test('resolveFromNamedRegistry() honours a user-defined named registry from conf const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: ENTERPRISE_REGISTRY, }), }) // `work:` is a user-defined alias — parsing and the URL lookup come from - // the resolver's merged named-registries map, not the scope registries. + // the resolver's merged named-registriesByScope map, not the scope registriesByScope. const resolveResult = await resolveFromNamedRegistry( { alias: '@acme/private', bareSpecifier: 'work:^2.0.0' }, { calcSpecifier: true } @@ -236,9 +236,9 @@ test('resolveFromNamedRegistry() allows user config to override the built-in gh const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, // A GHES user points `gh` at their enterprise host; the built-in default is shadowed. - namedRegistries: normalizeNamedRegistries({ + registriesByPrefix: normalizeRegistriesByPrefix({ gh: ENTERPRISE_REGISTRY, }), }) @@ -260,15 +260,15 @@ test('creating the resolver throws when a user-defined registry URL is malformed expect(() => createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ work: 'npm.work.example.com' }), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: 'npm.work.example.com' }), })).toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_URL' })) expect(() => createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ work: 'ftp://npm.work.example.com/' }), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: 'ftp://npm.work.example.com/' }), })).toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_URL' })) }) @@ -276,7 +276,7 @@ test('resolveFromNamedRegistry() returns null for specifiers whose prefix is not const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) // No fetch mock is registered — the test would fail if the resolver tried to hit the network. @@ -291,7 +291,7 @@ test('resolveFromNamedRegistry() does not claim the github: git shortcut scheme' const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) // `github:` belongs to the git resolver (npm-package-arg spec); GitHub Packages uses the `gh:` alias. @@ -304,7 +304,7 @@ test('resolveFromNamedRegistry() returns null when no alias is provided for a ba const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) // Without any package alias, `gh:` cannot map to a package name. @@ -315,7 +315,7 @@ test('resolveFromNamedRegistry() throws when the specifier names an invalid scop const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) // Scope without a package name is always a bug — refuse with a specific error code. @@ -327,8 +327,8 @@ test('resolveFromNamedRegistry() throws when the specifier names an invalid scop }) }) -test('the same package name served by two registries does not collide in the in-memory metadata cache', async () => { - // Both registries serve `@acme/private`, but point at different tarballs. +test('the same package name served by two registriesByScope does not collide in the in-memory metadata cache', async () => { + // Both registriesByScope serve `@acme/private`, but point at different tarballs. interceptGhAcmePrivate(GH_REGISTRY) /* eslint-disable @typescript-eslint/no-explicit-any */ const enterpriseMeta = JSON.parse(JSON.stringify(ghAcmePrivateMeta)) @@ -344,8 +344,8 @@ test('the same package name served by two registries does not collide in the in- const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ work: ENTERPRISE_REGISTRY }), + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ work: ENTERPRISE_REGISTRY }), }) // Resolving from the gh registry first populates the shared in-memory cache. @@ -371,7 +371,7 @@ test('the same package name served by two registries does not collide in the in- }) test('resolveFromNamedRegistry() preserves vulnerability-avoidance range selectors even when updateRequested is true', async () => { - // Security regression: the simple-registry picker (jsr + named registries) + // Security regression: the simple-registry picker (jsr + named registriesByScope) // must use the same `stripLockfileVersionPins` helper as the npm picker, // so a targeted update drops only the target's lockfile pins and keeps // range penalties (e.g. `pnpm audit --fix` vulnerability avoidance). @@ -382,7 +382,7 @@ test('resolveFromNamedRegistry() preserves vulnerability-avoidance range selecto const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNamedRegistry( @@ -418,7 +418,7 @@ test('resolveFromNamedRegistry() suppresses latest when publishedBy holds back t cacheDir, filterMetadata: true, fullMetadata: true, - registries, + registriesByScope, }) const resolveResult = await resolveFromNamedRegistry( @@ -439,7 +439,7 @@ test('resolveFromNamedRegistry() qualifies the id with the registry alias', asyn const { resolveFromNamedRegistry } = createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, + registriesByScope, }) const resolveResult = await resolveFromNamedRegistry( @@ -462,8 +462,8 @@ test('creating the resolver throws when a named registry alias is a reserved spe expect(() => createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ file: ENTERPRISE_REGISTRY, }), })).toThrow(expect.objectContaining({ code: 'ERR_PNPM_RESERVED_NAMED_REGISTRY_NAME' })) @@ -473,8 +473,8 @@ test('creating the resolver throws when a named registry alias is malformed', () expect(() => createNpmResolver(fetch, () => undefined, { storeDir: temporaryDirectory(), cacheDir: temporaryDirectory(), - registries, - namedRegistries: normalizeNamedRegistries({ + registriesByScope, + registriesByPrefix: normalizeRegistriesByPrefix({ 'bad alias!': ENTERPRISE_REGISTRY, }), })).toThrow(expect.objectContaining({ code: 'ERR_PNPM_RESERVED_NAMED_REGISTRY_NAME' })) diff --git a/pnpm11/resolving/tarball-url/package.json b/pnpm11/resolving/tarball-url/package.json index 94ec17c78e..f168128e78 100644 --- a/pnpm11/resolving/tarball-url/package.json +++ b/pnpm11/resolving/tarball-url/package.json @@ -32,6 +32,9 @@ "compile": "tsgo --build && pn lint --fix", ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest" }, + "dependencies": { + "@pnpm/types": "workspace:*" + }, "devDependencies": { "@jest/globals": "catalog:", "@pnpm/resolving.tarball-url": "workspace:*" diff --git a/pnpm11/resolving/tarball-url/src/index.ts b/pnpm11/resolving/tarball-url/src/index.ts index f864e5dee8..3e89a1739e 100644 --- a/pnpm11/resolving/tarball-url/src/index.ts +++ b/pnpm11/resolving/tarball-url/src/index.ts @@ -1,21 +1,46 @@ +import type { RegistryServerType } from '@pnpm/types' + const PUBLIC_NPM_REGISTRY = 'https://registry.npmjs.org/' +/** + * registry.npmjs.org is the one registry whose layout pnpm knows without being + * told, so it is a row of data rather than a hostname comparison. A declared + * `serverType` wins over it. + */ +const DEFAULT_REGISTRY_SERVER_TYPES: Record = { + [PUBLIC_NPM_REGISTRY]: 'npm', +} + +export interface TarballUrlOptions { + registry?: string + /** + * Undeclared by default, which is the strict reading: only the exact + * canonical URL is reconstructible. See {@link RegistryServerType}. + */ + serverType?: RegistryServerType +} + /** * Build the canonical tarball URL of an npm package — i.e. the URL pnpm derives * from a package's name, version, and registry. Vendored from the * `get-npm-tarball-url` package so the logic and its inverse * ({@link isCanonicalRegistryTarballUrl}) live together in the monorepo. + * + * This is the single source of the URL shape: the lockfile writer drops a + * tarball URL only when this function rebuilds it, and the lockfile reader + * rebuilds it with this function. Both sides therefore agree by construction, + * under every {@link RegistryServerType}. */ export function getNpmTarballUrl ( pkgName: string, pkgVersion: string, - opts?: { - registry?: string - } + opts?: TarballUrlOptions ): string { const registry = normalizeRegistry(opts?.registry) - const scopelessName = getScopelessName(pkgName) - return `${registry}${pkgName}/-/${scopelessName}-${removeBuildMetadataFromVersion(pkgVersion)}.tgz` + // Artifactory keeps the scope in the filename of a scoped package's tarball + // (`@acme/widget/-/@acme/widget-1.0.0.tgz`); the npm layout strips it. + const filenameName = opts?.serverType === 'artifactory' ? pkgName : getScopelessName(pkgName) + return `${registry}${pkgName}/-/${filenameName}-${removeBuildMetadataFromVersion(pkgVersion)}.tgz` } /** @@ -30,23 +55,28 @@ export function getNpmTarballUrl ( * path (e.g. an ephemeral `localhost:`) can rewrite the resolved tarball * to `getNpmTarballUrl(name, version, { registry })` so nothing host-specific * is persisted to `pnpm-lock.yaml`. + * + * A `serverType` the user declared (via `getRegistryServerType` in + * `@pnpm/config.normalize-registries`) wins; otherwise the built-in layout of + * a known registry applies, and an unknown registry is read strictly. */ export function isCanonicalRegistryTarballUrl ( tarball: string, pkg: { name: string, version: string }, - registry: string + opts: TarballUrlOptions ): boolean { - const expectedTarball = removeProtocol(getNpmTarballUrl(pkg.name, pkg.version, { registry })) + const expectedTarball = removeProtocol(getNpmTarballUrl(pkg.name, pkg.version, opts)) const actualTarball = removeProtocol(tarball) if (expectedTarball === actualTarball) return true - // registry.npmjs.org serves a scoped package from both the encoded and the - // unencoded path; elsewhere only the encoded one may work. - // See https://github.com/pnpm/pnpm/issues/13534. - return isPublicNpmRegistry(registry) && expectedTarball === actualTarball.replace(/%2f/gi, '/') + // A registry behaving like registry.npmjs.org serves a scoped package from + // both the encoded and the unencoded path. A registry that has not been + // declared to behave like it may serve only the encoded one, so its URL is + // kept. See https://github.com/pnpm/pnpm/issues/13534. + return effectiveServerType(opts) === 'npm' && expectedTarball === actualTarball.replace(/%2f/gi, '/') } -function isPublicNpmRegistry (registry: string): boolean { - return removeProtocol(normalizeRegistry(registry)) === removeProtocol(PUBLIC_NPM_REGISTRY) +function effectiveServerType (opts: TarballUrlOptions): RegistryServerType | undefined { + return opts.serverType ?? DEFAULT_REGISTRY_SERVER_TYPES[normalizeRegistry(opts.registry)] } function normalizeRegistry (registry?: string): string { diff --git a/pnpm11/resolving/tarball-url/test/index.test.ts b/pnpm11/resolving/tarball-url/test/index.test.ts index 3cbf5b29df..3fea72f992 100644 --- a/pnpm11/resolving/tarball-url/test/index.test.ts +++ b/pnpm11/resolving/tarball-url/test/index.test.ts @@ -33,45 +33,114 @@ describe('isCanonicalRegistryTarballUrl', () => { test('is true for the URL derived from name, version, and registry', () => { const tarball = getNpmTarballUrl('lodash', '4.17.21', { registry }) - expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, registry)).toBe(true) + expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, { registry })).toBe(true) }) test('is true for a scoped package using unencoded slash', () => { const tarball = getNpmTarballUrl('@babel/core', '7.0.0', { registry }) - expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, registry)).toBe(true) + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, { registry })).toBe(true) }) test.each([ 'https://registry.npmjs.org/@babel%2fcore/-/core-7.0.0.tgz', 'https://registry.npmjs.org/@babel%2Fcore/-/core-7.0.0.tgz', ])('is true on the public registry, which also serves the encoded path: %s', (tarball) => { - expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, registry)).toBe(true) + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, { registry })).toBe(true) }) test.each([ 'https://npm.example.com/@babel%2fcore/-/core-7.0.0.tgz', 'https://npm.example.com/@babel%2Fcore/-/core-7.0.0.tgz', ])('is false on any other registry, which may serve only the encoded path: %s', (tarball) => { - expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, 'https://npm.example.com/')).toBe(false) + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, { registry: 'https://npm.example.com/' })).toBe(false) }) test('ignores the protocol', () => { const tarball = getNpmTarballUrl('lodash', '4.17.21', { registry }).replace('https://', 'http://') - expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, registry)).toBe(true) + expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, { registry })).toBe(true) }) test('is false for a proxy URL on a non-canonical path', () => { const tarball = 'http://localhost:54321/tarballs/npm/lodash/4.17.21/abc' - expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, registry)).toBe(false) + expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, { registry })).toBe(false) }) test('is false when a second :// follows the canonical URL', () => { const tarball = `${getNpmTarballUrl('lodash', '4.17.21', { registry })}://suffix` - expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, registry)).toBe(false) + expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, { registry })).toBe(false) }) test('is false when the version differs', () => { const tarball = getNpmTarballUrl('lodash', '4.17.20', { registry }) - expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, registry)).toBe(false) + expect(isCanonicalRegistryTarballUrl(tarball, { name: 'lodash', version: '4.17.21' }, { registry })).toBe(false) }) }) + +describe('an Artifactory registry, which keeps the scope in the tarball filename', () => { + const registry = 'https://artifactory.example/artifactory/api/npm/npm-virtual/' + const serverType = 'artifactory' + + test('builds the scoped filename for a scoped package', () => { + expect(getNpmTarballUrl('@acme/widget', '1.2.3', { registry, serverType })) + .toBe(`${registry}@acme/widget/-/@acme/widget-1.2.3.tgz`) + }) + + test('builds the same URL as the npm layout for an unscoped package', () => { + expect(getNpmTarballUrl('widget', '1.2.3', { registry, serverType })) + .toBe(getNpmTarballUrl('widget', '1.2.3', { registry })) + }) + + test('drops the build metadata from the version, like the npm layout', () => { + expect(getNpmTarballUrl('@acme/widget', '1.2.3+build.4', { registry, serverType })) + .toBe(`${registry}@acme/widget/-/@acme/widget-1.2.3.tgz`) + }) + + test.each([ + ['1.2.3'], + ['1.2.3-beta.1'], + ])('recognizes its own URL for version %s as reconstructible', (version) => { + const tarball = getNpmTarballUrl('@acme/widget', version, { registry, serverType }) + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@acme/widget', version }, { registry, serverType })).toBe(true) + }) + + test('does not recognize the npm-layout URL, which it does not serve', () => { + const tarball = getNpmTarballUrl('@acme/widget', '1.2.3', { registry }) + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@acme/widget', version: '1.2.3' }, { registry, serverType })).toBe(false) + }) + + test('is not recognized by a registry left on the npm layout', () => { + const tarball = getNpmTarballUrl('@acme/widget', '1.2.3', { registry, serverType }) + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@acme/widget', version: '1.2.3' }, { registry })).toBe(false) + }) + + test('does not treat the encoded path as reconstructible', () => { + const encodedName = '@acme/widget'.split('/').join('%2f') + const tarball = `${registry}${encodedName}/-/${encodedName}-1.2.3.tgz` + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@acme/widget', version: '1.2.3' }, { registry, serverType })).toBe(false) + }) +}) + +describe('a registry declared to behave like the npm registry', () => { + const registry = 'https://npm.example.com/' + const tarball = 'https://npm.example.com/@babel%2Fcore/-/core-7.0.0.tgz' + const pkg = { name: '@babel/core', version: '7.0.0' } + + test('is true for the encoded scoped path, which it serves like npmjs does', () => { + expect(isCanonicalRegistryTarballUrl(tarball, pkg, { registry, serverType: 'npm' })).toBe(true) + }) + + test('is false for the same URL while undeclared, which may serve only the encoded path', () => { + expect(isCanonicalRegistryTarballUrl(tarball, pkg, { registry })).toBe(false) + }) + + test('builds the same URL as an undeclared registry', () => { + expect(getNpmTarballUrl('@babel/core', '7.0.0', { registry, serverType: 'npm' })) + .toBe(getNpmTarballUrl('@babel/core', '7.0.0', { registry })) + }) +}) + +test('the public npm registry keeps its encoded-path leniency without being declared', () => { + const registry = 'https://registry.npmjs.org/' + const tarball = 'https://registry.npmjs.org/@babel%2Fcore/-/core-7.0.0.tgz' + expect(isCanonicalRegistryTarballUrl(tarball, { name: '@babel/core', version: '7.0.0' }, { registry })).toBe(true) +}) diff --git a/pnpm11/resolving/tarball-url/tsconfig.json b/pnpm11/resolving/tarball-url/tsconfig.json index c6f0399f60..9a3fb59805 100644 --- a/pnpm11/resolving/tarball-url/tsconfig.json +++ b/pnpm11/resolving/tarball-url/tsconfig.json @@ -8,5 +8,9 @@ "src/**/*.ts", "../../__typings__/**/*.d.ts" ], - "references": [] + "references": [ + { + "path": "../../core/types" + } + ] } diff --git a/pnpm11/store/commands/src/inspecting/catIndex.ts b/pnpm11/store/commands/src/inspecting/catIndex.ts index e53ddd252c..cbc32c7a1d 100644 --- a/pnpm11/store/commands/src/inspecting/catIndex.ts +++ b/pnpm11/store/commands/src/inspecting/catIndex.ts @@ -37,7 +37,7 @@ export type CatIndexCommandOptions = Pick< | 'storeDir' | 'lockfileDir' | 'dir' -| 'registries' +| 'registriesByScope' | 'cacheDir' > diff --git a/pnpm11/store/commands/src/store/store.ts b/pnpm11/store/commands/src/store/store.ts index f7a6cb76b6..894df67a61 100644 --- a/pnpm11/store/commands/src/store/store.ts +++ b/pnpm11/store/commands/src/store/store.ts @@ -75,7 +75,7 @@ class StoreStatusError extends PnpmError { } } -export type StoreCommandOptions = Pick & Partial> & CreateStoreControllerOptions & { +export type StoreCommandOptions = Pick & Partial> & CreateStoreControllerOptions & { reporter?: (logObj: LogBase) => void } diff --git a/pnpm11/store/commands/src/store/storeStatus/extendStoreStatusOptions.ts b/pnpm11/store/commands/src/store/storeStatus/extendStoreStatusOptions.ts index 47fd8d8b10..d7851de9b9 100644 --- a/pnpm11/store/commands/src/store/storeStatus/extendStoreStatusOptions.ts +++ b/pnpm11/store/commands/src/store/storeStatus/extendStoreStatusOptions.ts @@ -1,7 +1,7 @@ import path from 'node:path' -import { DEFAULT_REGISTRIES, normalizeRegistries } from '@pnpm/config.normalize-registries' -import type { Registries } from '@pnpm/types' +import { DEFAULT_REGISTRIES_BY_SCOPE, normalizeRegistriesByScope } from '@pnpm/config.normalize-registries' +import type { RegistriesByScope } from '@pnpm/types' import type { ReporterFunction } from '../types.js' @@ -14,7 +14,7 @@ export interface StrictStoreStatusOptions { force: boolean nodeLinker: 'isolated' | 'hoisted' | 'pnp' useLockfile: boolean - registries: Registries + registriesByScope: RegistriesByScope shamefullyHoist: boolean reporter: ReporterFunction @@ -38,7 +38,7 @@ const defaults = async (opts: StoreStatusOptions): Promise { const createCacheKey = (...packages: string[]): string => dlx.createCacheKey({ packages, - registries: { default: 'https://registry.npmjs.com/' }, + registriesByScope: { default: 'https://registry.npmjs.com/' }, }) function createSampleDlxCacheLinkTarget (dirPath: string): void { diff --git a/pnpm11/store/commands/test/store/storeAdd.ts b/pnpm11/store/commands/test/store/storeAdd.ts index 153cecac1d..93a269ba63 100644 --- a/pnpm11/store/commands/test/store/storeAdd.ts +++ b/pnpm11/store/commands/test/store/storeAdd.ts @@ -19,7 +19,7 @@ test('pnpm store add express@4.16.3', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: `http://localhost:${REGISTRY_MOCK_PORT}/` }, + registriesByScope: { default: `http://localhost:${REGISTRY_MOCK_PORT}/` }, storeDir, dlxCacheMaxAge: 0, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -40,7 +40,7 @@ test('pnpm store add scoped package that uses not the standard registry', async dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { + registriesByScope: { '@foo': `http://localhost:${REGISTRY_MOCK_PORT}/`, default: 'https://registry.npmjs.org/', }, @@ -67,7 +67,7 @@ test('should fail if some packages can not be added', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { + registriesByScope: { '@foo': `http://localhost:${REGISTRY_MOCK_PORT}/`, default: 'https://registry.npmjs.org/', }, diff --git a/pnpm11/store/commands/test/store/storePath.ts b/pnpm11/store/commands/test/store/storePath.ts index 820f494198..ade387516a 100644 --- a/pnpm11/store/commands/test/store/storePath.ts +++ b/pnpm11/store/commands/test/store/storePath.ts @@ -17,7 +17,7 @@ test('CLI prints the current store path', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir: '/home/example/.pnpm-store', dlxCacheMaxAge: 0, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -43,7 +43,7 @@ test('CLI prints the current store path when storeDir is relative', async () => workspaceDir, pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir: relativeStoreDir, dlxCacheMaxAge: 0, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, diff --git a/pnpm11/store/commands/test/store/storePrune.ts b/pnpm11/store/commands/test/store/storePrune.ts index ccb20905cc..87f767baa0 100644 --- a/pnpm11/store/commands/test/store/storePrune.ts +++ b/pnpm11/store/commands/test/store/storePrune.ts @@ -16,7 +16,7 @@ const pnpmBin = path.join(import.meta.dirname, '../../../../pnpm/bin/pnpm.mjs') const createCacheKey = (...packages: string[]): string => dlx.createCacheKey({ packages, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, }) test('remove unreferenced packages', async () => { @@ -48,7 +48,7 @@ test('remove unreferenced packages', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir, dlxCacheMaxAge: Infinity, @@ -70,7 +70,7 @@ test('remove unreferenced packages', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir, dlxCacheMaxAge: Infinity, @@ -117,7 +117,7 @@ test('prune outputs total size of removed files', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir, dlxCacheMaxAge: Infinity, @@ -152,7 +152,7 @@ test('remove packages that are used by project that no longer exist', async () = dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir, dlxCacheMaxAge: Infinity, @@ -197,7 +197,7 @@ test('keep dependencies used by others', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir, dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -220,7 +220,7 @@ test('keep dependency used by package', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir, dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -241,7 +241,7 @@ test('prune will skip scanning non-directory in storeDir', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir, dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -265,7 +265,7 @@ test('prune does not fail if the store contains an unexpected directory', async dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir, dlxCacheMaxAge: Infinity, @@ -300,7 +300,7 @@ test('prune removes alien files from the store if the --force flag is used', asy dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir, force: true, @@ -328,7 +328,7 @@ describe('prune when store directory is not properly configured', () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter, storeDir: nonExistentStoreDir, dlxCacheMaxAge: Infinity, @@ -361,7 +361,7 @@ describe('prune when store directory is not properly configured', () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter: jest.fn(), storeDir: fileInPlaceOfStoreDir, dlxCacheMaxAge: Infinity, @@ -419,7 +419,7 @@ test('prune removes cache directories that outlives dlx-cache-max-age', async () dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, reporter () {}, storeDir, dlxCacheMaxAge: 7, @@ -481,7 +481,7 @@ describe('global virtual store prune', () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir: path.join(storeDir, STORE_VERSION), dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -546,7 +546,7 @@ describe('global virtual store prune', () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir: path.join(storeDir, STORE_VERSION), dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -614,7 +614,7 @@ describe('global virtual store prune', () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir: path.join(storeDir, STORE_VERSION), dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -695,7 +695,7 @@ describe('global virtual store prune', () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir: path.join(storeDir, STORE_VERSION), dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -739,7 +739,7 @@ test('prune keeps the lockfile verification log', async () => { dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: { default: REGISTRY }, + registriesByScope: { default: REGISTRY }, storeDir, dlxCacheMaxAge: Infinity, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, diff --git a/pnpm11/store/commands/test/store/storeStatus.ts b/pnpm11/store/commands/test/store/storeStatus.ts index 0dd9a1629c..a03284dc25 100644 --- a/pnpm11/store/commands/test/store/storeStatus.ts +++ b/pnpm11/store/commands/test/store/storeStatus.ts @@ -19,7 +19,7 @@ const cleanConfigDir = temporaryDirectory() const execaOpts = { env: { XDG_CONFIG_HOME: cleanConfigDir } } test('CLI fails when store status finds modified packages', async () => { - const project = prepare() + prepare() const tmp = temporaryDirectory() const cacheDir = path.join(tmp, 'cache') const storeDir = path.join(tmp, 'store') @@ -36,14 +36,13 @@ test('CLI fails when store status finds modified packages', async () => { rimrafSync('node_modules/.pnpm/is-positive@3.1.0/node_modules/is-positive/index.js') let err!: PnpmError & { modified: string[] } - const modulesState = project.readModulesManifest() try { await store.handler({ cacheDir, dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: modulesState!.registries!, + registriesByScope: { default: REGISTRY }, storeDir, dlxCacheMaxAge: 0, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -57,7 +56,7 @@ test('CLI fails when store status finds modified packages', async () => { }) test('CLI does not fail when store status does not find modified packages', async () => { - const project = prepare() + prepare() fs.writeFileSync('pnpm-workspace.yaml', 'allowBuilds: { "es5-ext": false, "fsevents": true }', 'utf8') const tmp = temporaryDirectory() const cacheDir = path.join(tmp, 'cache') @@ -88,13 +87,12 @@ test('CLI does not fail when store status does not find modified packages', asyn '--verify-store-integrity', ], execaOpts) - const modulesState = project.readModulesManifest() await store.handler({ cacheDir, dir: process.cwd(), pnpmHomeDir: '', configByUri: {}, - registries: modulesState!.registries!, + registriesByScope: { default: REGISTRY }, storeDir, dlxCacheMaxAge: 0, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, @@ -102,7 +100,7 @@ test('CLI does not fail when store status does not find modified packages', asyn }) test('CLI does not fail when storeDir is relative', async () => { - const project = prepare() + prepare() const cacheDir = path.resolve('cache') const workspaceDir = process.cwd() const subpackageDir = path.join(workspaceDir, 'packages', 'foo') @@ -128,7 +126,6 @@ storeDir: "${relativeStoreDir}" '--verify-store-integrity', ], execaOpts) - const modulesState = project.readModulesManifest() // relativeStoreDir should resolve from workspaceDir, not dir await store.handler({ @@ -137,7 +134,7 @@ storeDir: "${relativeStoreDir}" workspaceDir, pnpmHomeDir: '', configByUri: {}, - registries: modulesState!.registries!, + registriesByScope: { default: REGISTRY }, storeDir: relativeStoreDir, dlxCacheMaxAge: 0, virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, diff --git a/pnpm11/store/connection-manager/src/createNewStoreController.ts b/pnpm11/store/connection-manager/src/createNewStoreController.ts index dd54ec1df2..857911af53 100644 --- a/pnpm11/store/connection-manager/src/createNewStoreController.ts +++ b/pnpm11/store/connection-manager/src/createNewStoreController.ts @@ -45,8 +45,8 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick { cacheDir: path.join(tmp, 'cache'), storeDir: path.join(tmp, 'store'), storeIndex, - registries: { + registriesByScope: { default: registry, }, }) @@ -64,7 +64,7 @@ describe('store.importPackage()', () => { cacheDir: path.join(tmp, 'cache'), storeDir: path.join(tmp, 'store'), storeIndex, - registries: { + registriesByScope: { default: registry, }, }) diff --git a/pnpm11/testing/command-defaults/src/index.ts b/pnpm11/testing/command-defaults/src/index.ts index 299e0c52c1..b80b8201b1 100644 --- a/pnpm11/testing/command-defaults/src/index.ts +++ b/pnpm11/testing/command-defaults/src/index.ts @@ -44,7 +44,7 @@ export const DEFAULT_OPTS = { pnpmHomeDir: '', preferWorkspacePackages: true, proxy: undefined, - registries: { default: REGISTRY_URL }, + registriesByScope: { default: REGISTRY_URL }, registry: REGISTRY_URL, rootProjectManifestDir: '', sort: true, diff --git a/pnpm11/testing/temp-store/src/index.ts b/pnpm11/testing/temp-store/src/index.ts index b4737b29fa..8967bc5769 100644 --- a/pnpm11/testing/temp-store/src/index.ts +++ b/pnpm11/testing/temp-store/src/index.ts @@ -37,7 +37,7 @@ export function createTempStore (opts?: { cacheDir, storeDir, storeIndex, - registries: { + registriesByScope: { default: registry, }, ...opts?.clientOptions, diff --git a/pnpr/client/src/resolveViaPnprServer.ts b/pnpr/client/src/resolveViaPnprServer.ts index d9b7c5d177..734187ab70 100644 --- a/pnpr/client/src/resolveViaPnprServer.ts +++ b/pnpr/client/src/resolveViaPnprServer.ts @@ -6,7 +6,7 @@ import { gunzip } from 'node:zlib' import type { Catalogs } from '@pnpm/catalogs.types' import { convertToLockfileObject } from '@pnpm/lockfile.fs' import type { LockfileFile, LockfileObject } from '@pnpm/lockfile.types' -import type { TrustPolicy } from '@pnpm/types' +import type { RegistryDeclaration, TrustPolicy } from '@pnpm/types' import type { ResponseMetadata } from './protocol.js' @@ -37,11 +37,15 @@ export interface ResolveViaPnprServerOptions { projects?: PnprProject[] /** * The client's default registry. The server resolves against this - * (and `namedRegistries`) rather than its own configuration. + * (and the prefix-addressed registries) rather than its own configuration. */ registry?: string - /** The client's named-registry aliases (`namedRegistries`). */ - namedRegistries?: Record + /** + * The registries the client declares, keyed by URL, in the shape of the + * `registries` setting. The default registry is not among them: it travels + * as {@link ResolveViaPnprServerOptions.registry}. + */ + registries?: Record /** * `Authorization` for the pnpr server's own URL (`undefined` if none): * identifies the caller to pnpr's gate. The client never forwards its @@ -141,7 +145,7 @@ export async function resolveViaPnprServer ( const requestBody = JSON.stringify({ projects, registry: opts.registry, - namedRegistries: opts.namedRegistries, + registries: opts.registries, overrides: opts.overrides, catalogs: opts.catalogs, nodeVersion: opts.nodeVersion ?? process.version.slice(1), diff --git a/pnpr/crates/pnpr/src/resolver.rs b/pnpr/crates/pnpr/src/resolver.rs index aec816064f..9335568e6b 100644 --- a/pnpr/crates/pnpr/src/resolver.rs +++ b/pnpr/crates/pnpr/src/resolver.rs @@ -208,7 +208,7 @@ impl Resolver { /// Resolve (or build + intern) the `&'static Config` for a request's /// registry configuration. Pacquet's install path resolves against - /// `config.registry` / `named_registries` / `overrides`, so a request + /// `config.registry` / `registries_by_prefix` / `overrides`, so a request /// from a client with a different registry setup gets its own Config. /// /// `None` once [`MAX_INTERNED_CONFIGS`] distinct configurations have @@ -330,7 +330,7 @@ fn intern_config( let key = serde_json::json!({ "registry": registry, "lockfileSettings": lockfile_settings, - "namedRegistries": request.named_registries, + "registries": &request.registries, "overrides": overrides_key, "minimumReleaseAge": request.minimum_release_age, "minimumReleaseAgeExclude": request.minimum_release_age_exclude, @@ -356,7 +356,18 @@ fn intern_config( config.store_dir = store_dir.clone(); config.cache_dir = cache_dir.to_path_buf(); config.registry = registry; - config.named_registries = request.named_registries.clone(); + // The client's declarations go through the same inversion the config + // reader runs on the `registries` setting, so the server routes scopes + // and prefixes exactly as the client would. + let lookups = pnpm_config::registries::declarations_into_lookups(request.registries.clone()); + if request.registry.is_none() + && let Some(default_registry) = lookups.default_registry + { + config.registry = default_registry; + } + config.registries_by_scope = lookups.registries_by_scope; + config.registries_by_prefix = lookups.registries_by_prefix; + config.registry_options_by_url = lookups.registry_options_by_url; config.overrides = overrides; config.modules_dir = PathBuf::from("node_modules"); config.lockfile = true; @@ -407,6 +418,9 @@ pub(crate) async fn handle_resolve( Err(err) => return json_error(StatusCode::BAD_REQUEST, &err.to_string()), }; + if let Some(response) = reject_invalid_registries(&request) { + return response; + } if let Some(response) = reject_inline_url_auth(&request) { return response; } @@ -572,6 +586,9 @@ pub(crate) async fn handle_verify_lockfile( Err(err) => return json_error(StatusCode::BAD_REQUEST, &err.to_string()), }; + if let Some(response) = reject_invalid_registries(&request) { + return response; + } if let Some(response) = reject_inline_url_auth(&request) { return response; } @@ -781,7 +798,7 @@ fn resolution_cache_key(config: &PacquetConfig, request: &ResolveRequest) -> Opt .collect(); let input = serde_json::json!({ "registry": &config.registry, - "namedRegistries": &request.named_registries, + "registries": &request.registries, "overrides": &request.overrides, "catalogs": &request.catalogs, "projects": projects, @@ -1429,12 +1446,15 @@ fn reject_off_allowlist_fetches( request: &ResolveRequest, context: &RouteContext, ) -> Option { - // Registries are fetch targets whatever their scheme. + // Registries are fetch targets whatever their scheme. The registries the + // request *declares* are deliberately not checked here: a client describes + // its whole configuration, including scopes this resolve never reaches, and + // one of those is refused at the fetch instead (`RouteHook::allows_fetch`) + // so configuring a registry pnpr does not serve is not itself an error. let mut registries: Vec<&str> = Vec::new(); if let Some(registry) = request.registry.as_deref() { registries.push(registry); } - registries.extend(request.named_registries.values().map(String::as_str)); if let Some(off) = registries.into_iter().find(|registry| !context.allows_registry(registry)) { return Some(forbidden_off_allowlist(off)); } @@ -1548,6 +1568,18 @@ fn forbidden_off_allowlist(target: &str) -> Response { ) } +/// Reject a `registries` map the client could not have loaded itself. +/// +/// The same validation the config reader runs on the setting, so a request +/// cannot route one scope to two registries, or reach the resolver with a +/// declaration pnpm would have refused on disk. The message is the reader's, +/// with its registry URLs already redacted. +fn reject_invalid_registries(request: &ResolveRequest) -> Option { + pnpm_config::registries::validate_declarations(request.registries.iter()) + .err() + .map(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string())) +} + /// Reject a request whose client-supplied URLs carry inline /// `user:pass@host` credentials, before any fetch or cache write. Covers /// the default and named registries, every dependency spec, catalog value, @@ -1559,7 +1591,7 @@ fn reject_inline_url_auth(request: &ResolveRequest) -> Option { if let Some(registry) = request.registry.as_deref() { specs.push(registry); } - specs.extend(request.named_registries.values().map(String::as_str)); + specs.extend(request.registries.keys().map(String::as_str)); let projects = request.projects_normalized(); for project in &projects { for map in diff --git a/pnpr/crates/pnpr/src/resolver/protocol.rs b/pnpr/crates/pnpr/src/resolver/protocol.rs index 2ec8445c10..89423813bb 100644 --- a/pnpr/crates/pnpr/src/resolver/protocol.rs +++ b/pnpr/crates/pnpr/src/resolver/protocol.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use pnpm_catalogs_types::Catalogs; +use pnpm_config::RegistryDeclaration; use pnpm_network::AuthHeadersByScope; use serde::Deserialize; @@ -53,10 +54,17 @@ pub struct ResolveRequest { /// The client's default registry. Falls back to npmjs when absent. #[serde(default)] pub registry: Option, - /// The client's named-registry aliases (`pnpm-workspace.yaml` - /// `namedRegistries`). + /// The registries the client declares, keyed by URL, in the shape of + /// its `registries` setting: the scopes routed to each, the + /// bare-specifier prefix each answers to, and each one's tarball + /// layout. The default registry is not among them — it arrives as + /// [`Self::registry`]. + /// + /// Declarations only, never the setting's older `: ` shape: + /// a key is always a URL, which is what lets the boundary checks read one + /// as a fetch target. A request in the older shape fails to parse. #[serde(default)] - pub named_registries: BTreeMap, + pub registries: BTreeMap, /// The caller's forwarded upstream credentials so the server resolves /// and fetches private content as the caller. Keyed as /// `auth_headers[registry_uri][scope]`; the `@` scope stores diff --git a/pnpr/crates/pnpr/src/resolver/tests.rs b/pnpr/crates/pnpr/src/resolver/tests.rs index 7019d521da..c3935adb31 100644 --- a/pnpr/crates/pnpr/src/resolver/tests.rs +++ b/pnpr/crates/pnpr/src/resolver/tests.rs @@ -1,5 +1,5 @@ use axum::http::StatusCode; -use pnpm_config::Config as PacquetConfig; +use pnpm_config::{Config as PacquetConfig, RegistryDeclaration}; use pnpm_lockfile::Lockfile; use pnpm_resolving_resolver_base::{ PackageVersionGuard, PackageVersionGuardDecision, PackageVersionGuardFuture, @@ -186,6 +186,24 @@ fn resolution_cache_key_normalizes_single_project_requests() { ); } +/// The `registries` map is keyed by URL and carries declarations only. The +/// setting's older `: ` shape puts the URL in the *value*, where +/// the boundary checks — which read a key as a fetch target — would not see +/// it, so a request in that shape must not parse at all. +#[test] +fn a_scope_routed_registries_map_does_not_parse() { + let declarations = serde_json::from_value::(serde_json::json!({ + "registries": { "https://npm.corp.example/": { "scopes": ["@acme"] } } + })) + .expect("a declaration map parses"); + assert_eq!(declarations.registries.len(), 1); + + let scope_routed = serde_json::from_value::(serde_json::json!({ + "registries": { "@acme": "http://169.254.169.254/" } + })); + assert!(scope_routed.is_err(), "a scope-routed map must not parse"); +} + #[test] fn resolution_cache_key_changes_with_catalogs() { let request = |version: &str| { @@ -651,16 +669,21 @@ fn reject_off_allowlist_fetches_blocks_unconfigured_hosts() { }; assert!(reject_off_allowlist_fetches(&ssrf, &context).is_some()); - // A named registry off the allowlist is rejected too. - let named = ResolveRequest { + // A registry the request merely declares is not a fetch target: the client + // describes its whole configuration, including scopes this resolve never + // reaches. `RouteHook::allows_fetch` refuses the ones it does reach. + let declared = ResolveRequest { registry: Some("https://registry.npmjs.org/".to_string()), - named_registries: BTreeMap::from([( - "@acme".to_string(), + registries: BTreeMap::from([( "http://169.254.169.254/".to_string(), + RegistryDeclaration { + scopes: Some(vec!["@acme".to_string()]), + ..RegistryDeclaration::default() + }, )]), ..ResolveRequest::default() }; - assert!(reject_off_allowlist_fetches(&named, &context).is_some()); + assert!(reject_off_allowlist_fetches(&declared, &context).is_none()); // A semver-range dependency never hits the network, so it is ignored. let ranges = ResolveRequest { diff --git a/pnpr/crates/pnpr/src/route.rs b/pnpr/crates/pnpr/src/route.rs index 662f229f2d..48106f74c2 100644 --- a/pnpr/crates/pnpr/src/route.rs +++ b/pnpr/crates/pnpr/src/route.rs @@ -767,6 +767,10 @@ impl UpstreamRouteHook for RouteHook { } } + fn allows_fetch(&self, url: &str) -> bool { + self.context.allows_registry(url) + } + fn metadata_scope(&self, url: &str, package: Option<&str>) -> MetadataCacheScope { // Read-only classification — this must not record into the // footprint (`authorize` already does, at the real fetch point).