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).