diff --git a/Cargo.lock b/Cargo.lock index f8021be3..5ce8c70b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,27 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -280,6 +301,12 @@ dependencies = [ "regex", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" version = "0.11.8" @@ -299,6 +326,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -708,6 +751,22 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -794,6 +853,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "npmrc-config-rs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b23d3d410e3729146ace7f01954bd00ec13559a8078c28d1c6a79faef8dae96" +dependencies = [ + "base64", + "dirs", + "regex", + "thiserror 2.0.18", + "url", + "which", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -806,6 +879,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -965,6 +1044,17 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -1052,6 +1142,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -1251,11 +1354,13 @@ dependencies = [ "lazy_static", "log", "node-semver", + "npmrc-config-rs", "regex", "reqwest", "serde", "serde_json", "serde_yaml", + "tempfile", "thiserror 2.0.18", "tokio", ] @@ -1271,6 +1376,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1614,6 +1732,17 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1776,6 +1905,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 53346622..bdc44334 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ include = ["src/**/*.rs"] [dev-dependencies] ctor = "0.6.3" +tempfile = "3" [dependencies] async-trait = "0.1.89" @@ -27,6 +28,7 @@ itertools = "0.14.0" lazy_static = "1.5.0" log = "0.4.29" node-semver = "2.2.0" +npmrc-config-rs = "0.1.1" regex = { version = "1.12.3", default-features = false, features = ["std"] } reqwest = { version = "0.12", features = [ "json", diff --git a/src/commands/update.rs b/src/commands/update.rs index 6a034b7a..4486f3d9 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -1,10 +1,6 @@ use { - crate::{ - commands::{ui, ui::LINE_ENDING}, - context::Context, - version_group::VersionGroupVariant, - }, - log::{error, warn}, + crate::{commands::ui, context::Context, version_group::VersionGroupVariant}, + log::error, }; pub fn run(ctx: Context) -> i32 { @@ -50,9 +46,6 @@ pub fn run(ctx: Context) -> i32 { ctx.failed_updates.iter().for_each(|name| { error!("Failed to fetch {name}"); }); - warn!( - "Syncpack does not yet support custom npm registries{LINE_ENDING} Subscribe to https://github.com/JamieMason/syncpack/issues/220" - ); } else if !was_outdated { ui::util::print_no_issues_found(); } diff --git a/src/dependency.rs b/src/dependency.rs index c17ac85c..d414cd73 100644 --- a/src/dependency.rs +++ b/src/dependency.rs @@ -23,14 +23,14 @@ use { #[path = "dependency_test.rs"] mod dependency_test; -/// URL information for fetching package metadata from npm registry. +/// Information for fetching package metadata from npm registry. /// Used by the update command to fetch available versions. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct UpdateUrl { - /// The name of the dependency + /// The name of the dependency as used in package.json pub internal_name: String, - /// Registry URL, e.g., "https://registry.npmjs.org/react" - pub url: String, + /// The actual npm package name (may differ from internal_name for aliases) + pub package_name: String, } /// All instances of a single dependency name within a version group. diff --git a/src/instance.rs b/src/instance.rs index 00f6b18b..38dd279f 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -301,15 +301,10 @@ impl Instance { Specifier::Alias(alias) => { let aliased_name = &alias.name; if !aliased_name.is_empty() { - if aliased_name.starts_with("@jsr/") { + if aliased_name.starts_with("@jsr/") || aliased_name == actual_name { Some(UpdateUrl { internal_name: internal_name.clone(), - url: format!("https://npm.jsr.io/{aliased_name}"), - }) - } else if aliased_name == actual_name { - Some(UpdateUrl { - internal_name: internal_name.clone(), - url: format!("https://registry.npmjs.org/{actual_name}"), + package_name: aliased_name.clone(), }) } else { debug!("'{aliased_name}' in '{raw}' does not equal the instance name '{actual_name}', skipping update as this might create mismatches"); @@ -319,19 +314,10 @@ impl Instance { None } } - Specifier::Exact(_) | Specifier::Range(_) | Specifier::Major(_) | Specifier::Minor(_) | Specifier::Latest(_) => { - if actual_name.starts_with("@jsr/") { - Some(UpdateUrl { - internal_name: internal_name.clone(), - url: format!("https://npm.jsr.io/{actual_name}"), - }) - } else { - Some(UpdateUrl { - internal_name: internal_name.clone(), - url: format!("https://registry.npmjs.org/{actual_name}"), - }) - } - } + Specifier::Exact(_) | Specifier::Range(_) | Specifier::Major(_) | Specifier::Minor(_) | Specifier::Latest(_) => Some(UpdateUrl { + internal_name: internal_name.clone(), + package_name: actual_name.clone(), + }), _ => None, } } else { diff --git a/src/instance_test.rs b/src/instance_test.rs index 6521ad34..4f6527c5 100644 --- a/src/instance_test.rs +++ b/src/instance_test.rs @@ -36,47 +36,57 @@ fn returns_correct_registry_update_url() { .get_update_url() }; + // Local packages should not have update URLs assert_eq!(get_update_url_by_name("local-package"), None); + + // Direct JSR package assert_eq!( get_update_url_by_name("@jsr/luca__cases"), Some(UpdateUrl { internal_name: "@jsr/luca__cases".to_string(), - url: "https://npm.jsr.io/@jsr/luca__cases".to_string() + package_name: "@jsr/luca__cases".to_string() }) ); + + // npm package with alias (aliased name matches actual name) assert_eq!( get_update_url_by_name("@lit-labs/ssr"), Some(UpdateUrl { internal_name: "@lit-labs/ssr".to_string(), - url: "https://registry.npmjs.org/@lit-labs/ssr".to_string() + package_name: "@lit-labs/ssr".to_string() }) ); + + // Aliased JSR package - uses the JSR aliased name assert_eq!( get_update_url_by_name("@luca/cases"), Some(UpdateUrl { internal_name: "@luca/cases".to_string(), - url: "https://npm.jsr.io/@jsr/luca__cases".to_string() + package_name: "@jsr/luca__cases".to_string() }) ); + assert_eq!( get_update_url_by_name("@std/fmt"), Some(UpdateUrl { internal_name: "@std/fmt".to_string(), - url: "https://npm.jsr.io/@jsr/std__fmt".to_string() + package_name: "@jsr/std__fmt".to_string() }) ); assert_eq!( get_update_url_by_name("@std/yaml"), Some(UpdateUrl { internal_name: "@std/yaml".to_string(), - url: "https://npm.jsr.io/@jsr/std__yaml".to_string() + package_name: "@jsr/std__yaml".to_string() }) ); + + // Regular npm package with alias assert_eq!( get_update_url_by_name("lit"), Some(UpdateUrl { internal_name: "lit".to_string(), - url: "https://registry.npmjs.org/lit".to_string() + package_name: "lit".to_string() }) ); } diff --git a/src/main.rs b/src/main.rs index b465496e..df076d1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use { config::Config, context::Context, packages::Packages, - registry_client::LiveRegistryClient, + registry_client::{LiveRegistryClient, RegistryClient}, visit_formatting::visit_formatting, visit_packages::visit_packages, }, @@ -28,6 +28,8 @@ mod group_selector; mod instance; mod instance_state; mod logger; +#[cfg(test)] +mod npmrc_integration_test; mod package_json; mod packages; #[cfg(test)] @@ -70,16 +72,17 @@ async fn main() { len => debug!("Found {len} package.json files"), } - let ctx = Context::create( - config, - packages, - if is_update_command { - Some(Arc::new(LiveRegistryClient::new())) - } else { - None - }, - catalogs, - ); + let registry_client = if is_update_command { + let npmrc = npmrc_config_rs::NpmrcConfig::load().unwrap_or_else(|e| { + error!("Failed to load .npmrc config: {e}"); + exit(1); + }); + Some(Arc::new(LiveRegistryClient::new(npmrc)) as Arc) + } else { + None + }; + + let ctx = Context::create(config, packages, registry_client, catalogs); let _exit_code = match ctx.config.cli.subcommand { Subcommand::Fix => { diff --git a/src/npmrc_integration_test.rs b/src/npmrc_integration_test.rs new file mode 100644 index 00000000..c2b51947 --- /dev/null +++ b/src/npmrc_integration_test.rs @@ -0,0 +1,351 @@ +use { + crate::registry_client::LiveRegistryClient, + npmrc_config_rs::{Credentials, LoadOptions, NpmrcConfig}, + std::{collections::HashMap, fs, path::Path}, + tempfile::TempDir, +}; + +fn setup_config(npmrc_content: &str) -> (TempDir, NpmrcConfig) { + let dir = TempDir::new().expect("create temp dir"); + fs::write(dir.path().join("package.json"), "{}").expect("write package.json"); + fs::write(dir.path().join(".npmrc"), npmrc_content).expect("write .npmrc"); + let config = NpmrcConfig::load_with_options(LoadOptions { + cwd: Some(dir.path().to_path_buf()), + skip_user: true, + skip_global: true, + ..Default::default() + }) + .expect("load npmrc config"); + (dir, config) +} + +fn make_client(npmrc_content: &str) -> (TempDir, LiveRegistryClient) { + let (dir, config) = setup_config(npmrc_content); + (dir, LiveRegistryClient::new(config)) +} + +/// Returns (global_dir, user_dir, project_dir, config). +fn setup_multi_layer(global_npmrc: &str, user_npmrc: &str, project_npmrc: &str) -> (TempDir, TempDir, TempDir, NpmrcConfig) { + let global_dir = TempDir::new().expect("global temp dir"); + let user_dir = TempDir::new().expect("user temp dir"); + let project_dir = TempDir::new().expect("project temp dir"); + + let etc = global_dir.path().join("etc"); + fs::create_dir_all(&etc).expect("create etc dir"); + fs::write(etc.join("npmrc"), global_npmrc).expect("write global npmrc"); + fs::write(user_dir.path().join(".npmrc"), user_npmrc).expect("write user npmrc"); + fs::write(project_dir.path().join("package.json"), "{}").expect("write package.json"); + fs::write(project_dir.path().join(".npmrc"), project_npmrc).expect("write project .npmrc"); + + let config = NpmrcConfig::load_with_options(LoadOptions { + cwd: Some(project_dir.path().to_path_buf()), + global_prefix: Some(global_dir.path().to_path_buf()), + user_config: Some(user_dir.path().join(".npmrc")), + skip_user: false, + skip_global: false, + skip_project: false, + }) + .expect("load multi-layer config"); + + (global_dir, user_dir, project_dir, config) +} + +#[test] +fn resolve_url_default_registry_when_empty_npmrc() { + let (_dir, client) = make_client(""); + let (url, base) = client.resolve_url("react").unwrap(); + assert_eq!(base.host_str().unwrap(), "registry.npmjs.org"); + assert!(url.as_str().ends_with("/react")); +} + +#[test] +fn resolve_url_custom_default_registry() { + let (_dir, client) = make_client("registry=https://custom.example.com/"); + let (url, base) = client.resolve_url("lodash").unwrap(); + assert_eq!(base.host_str().unwrap(), "custom.example.com"); + assert!(url.as_str().ends_with("/lodash")); +} + +#[test] +fn resolve_url_scoped_package_uses_scoped_registry() { + let (_dir, client) = make_client("@myorg:registry=https://myorg.npm.dev/"); + let (url, base) = client.resolve_url("@myorg/utils").unwrap(); + assert_eq!(base.host_str().unwrap(), "myorg.npm.dev"); + assert!(url.as_str().contains("@myorg/utils")); +} + +#[test] +fn resolve_url_unscoped_ignores_scoped_registry() { + let (_dir, client) = make_client("@myorg:registry=https://myorg.npm.dev/"); + let (_url, base) = client.resolve_url("react").unwrap(); + assert_eq!(base.host_str().unwrap(), "registry.npmjs.org"); +} + +#[test] +fn resolve_url_jsr_fallback_no_explicit_registry() { + let (_dir, client) = make_client(""); + let (url, base) = client.resolve_url("@jsr/luca__cases").unwrap(); + assert_eq!(base.host_str().unwrap(), "npm.jsr.io"); + assert!(url.as_str().contains("@jsr/luca__cases")); +} + +#[test] +fn resolve_url_jsr_uses_explicit_registry_when_set() { + let (_dir, client) = make_client("@jsr:registry=https://custom.jsr.io/"); + let (_url, base) = client.resolve_url("@jsr/luca__cases").unwrap(); + assert_eq!(base.host_str().unwrap(), "custom.jsr.io"); +} + +#[test] +fn resolve_url_multiple_scopes_independent() { + let npmrc = "@a:registry=https://a.example.com/\n@b:registry=https://b.example.com/"; + let (_dir, client) = make_client(npmrc); + let (_, base_a) = client.resolve_url("@a/pkg").unwrap(); + let (_, base_b) = client.resolve_url("@b/pkg").unwrap(); + assert_eq!(base_a.host_str().unwrap(), "a.example.com"); + assert_eq!(base_b.host_str().unwrap(), "b.example.com"); +} + +#[test] +fn credentials_token_for_default_registry() { + let npmrc = "registry=https://custom.example.com/\n//custom.example.com/:_authToken=abc123"; + let (_dir, config) = setup_config(npmrc); + let reg = config.default_registry(); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::Token { token, .. } => assert_eq!(token, "abc123"), + other => panic!("expected Token, got: {other:?}"), + } +} + +#[test] +fn credentials_basic_auth_for_scoped_registry() { + let npmrc = "\ +@myorg:registry=https://myorg.npm.dev/ +//myorg.npm.dev/:username=myuser +//myorg.npm.dev/:_password=cDRzc3dvcmQ="; + let (_dir, config) = setup_config(npmrc); + let reg = config.registry_for("@myorg/utils"); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::BasicAuth { username, .. } => assert_eq!(username, "myuser"), + other => panic!("expected BasicAuth, got: {other:?}"), + } +} + +#[test] +fn credentials_legacy_auth() { + let npmrc = "registry=https://legacy.example.com/\n//legacy.example.com/:_auth=dXNlcjpwYXNz"; + let (_dir, config) = setup_config(npmrc); + let reg = config.default_registry(); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::LegacyAuth { username, .. } => assert_eq!(username, "user"), + other => panic!("expected LegacyAuth, got: {other:?}"), + } +} + +#[test] +fn credentials_none_for_unconfigured_registry() { + let (_dir, config) = setup_config(""); + let reg = config.default_registry(); + assert!(config.credentials_for(®).is_none()); +} + +#[test] +fn credentials_token_with_client_cert() { + let npmrc = "\ +registry=https://secure.example.com/ +//secure.example.com/:_authToken=tok +//secure.example.com/:certfile=/tmp/cert.pem +//secure.example.com/:keyfile=/tmp/key.pem"; + let (_dir, config) = setup_config(npmrc); + let reg = config.default_registry(); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::Token { token, cert } => { + assert_eq!(token, "tok"); + assert!(cert.is_some(), "cert should be present"); + } + other => panic!("expected Token with cert, got: {other:?}"), + } +} + +#[test] +fn credentials_client_cert_only() { + let npmrc = "\ +registry=https://mtls.example.com/ +//mtls.example.com/:certfile=/tmp/cert.pem +//mtls.example.com/:keyfile=/tmp/key.pem"; + let (_dir, config) = setup_config(npmrc); + let reg = config.default_registry(); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::ClientCertOnly(cert) => { + assert_eq!(cert.certfile, Path::new("/tmp/cert.pem")); + assert_eq!(cert.keyfile, Path::new("/tmp/key.pem")); + } + other => panic!("expected ClientCertOnly, got: {other:?}"), + } +} + +#[test] +fn credentials_token_priority_over_basic() { + let npmrc = "\ +registry=https://both.example.com/ +//both.example.com/:_authToken=winner +//both.example.com/:username=loser +//both.example.com/:_password=cDRzc3dvcmQ="; + let (_dir, config) = setup_config(npmrc); + let reg = config.default_registry(); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::Token { token, .. } => assert_eq!(token, "winner"), + other => panic!("expected Token to win over BasicAuth, got: {other:?}"), + } +} + +#[test] +fn credentials_per_registry_isolation() { + let npmrc = "\ +@a:registry=https://a.example.com/ +//a.example.com/:_authToken=token_a +@b:registry=https://b.example.com/ +//b.example.com/:_authToken=token_b"; + let (_dir, config) = setup_config(npmrc); + let reg_a = config.registry_for("@a/pkg"); + let reg_b = config.registry_for("@b/pkg"); + let creds_a = config.credentials_for(®_a).expect("creds for @a"); + let creds_b = config.credentials_for(®_b).expect("creds for @b"); + match (&creds_a, &creds_b) { + (Credentials::Token { token: ta, .. }, Credentials::Token { token: tb, .. }) => { + assert_eq!(ta, "token_a"); + assert_eq!(tb, "token_b"); + } + _ => panic!("expected both Token, got: {creds_a:?} / {creds_b:?}"), + } +} + +#[test] +fn config_project_overrides_user_registry() { + let (_g, _u, _p, config) = setup_multi_layer("", "registry=https://user.example.com/", "registry=https://project.example.com/"); + assert_eq!(config.default_registry().host_str().unwrap(), "project.example.com"); +} + +#[test] +fn config_user_overrides_global_token() { + let (_g, _u, _p, config) = setup_multi_layer( + "registry=https://corp.example.com/\n//corp.example.com/:_authToken=global_tok", + "//corp.example.com/:_authToken=user_tok", + "registry=https://corp.example.com/", + ); + let reg = config.default_registry(); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::Token { token, .. } => assert_eq!(token, "user_tok"), + other => panic!("expected Token, got: {other:?}"), + } +} + +#[test] +fn config_project_scope_with_user_auth() { + let (_g, _u, _p, config) = setup_multi_layer( + "", + "//scoped.example.com/:_authToken=user_secret", + "@org:registry=https://scoped.example.com/", + ); + let reg = config.registry_for("@org/pkg"); + assert_eq!(reg.host_str().unwrap(), "scoped.example.com"); + let creds = config.credentials_for(®).expect("should have credentials"); + match &creds { + Credentials::Token { token, .. } => assert_eq!(token, "user_secret"), + other => panic!("expected Token, got: {other:?}"), + } +} + +#[test] +fn config_skip_project_ignores_project_npmrc() { + let dir = TempDir::new().expect("temp dir"); + fs::write(dir.path().join("package.json"), "{}").expect("write package.json"); + fs::write(dir.path().join(".npmrc"), "registry=https://project.example.com/").expect("write .npmrc"); + let config = NpmrcConfig::load_with_options(LoadOptions { + cwd: Some(dir.path().to_path_buf()), + skip_user: true, + skip_global: true, + skip_project: true, + ..Default::default() + }) + .expect("load config"); + assert_eq!(config.default_registry().host_str().unwrap(), "registry.npmjs.org"); +} + +#[test] +fn config_graceful_when_no_npmrc() { + let dir = TempDir::new().expect("temp dir"); + fs::write(dir.path().join("package.json"), "{}").expect("write package.json"); + let config = NpmrcConfig::load_with_options(LoadOptions { + cwd: Some(dir.path().to_path_buf()), + skip_user: true, + skip_global: true, + ..Default::default() + }) + .expect("should load OK without .npmrc"); + assert_eq!(config.default_registry().host_str().unwrap(), "registry.npmjs.org"); +} + +#[test] +fn scoped_registries_returns_all() { + let npmrc = "@a:registry=https://a.example.com/\n@b:registry=https://b.example.com/"; + let (_dir, config) = setup_config(npmrc); + let scoped: HashMap = config.scoped_registries(); + assert!(scoped.contains_key("@a"), "missing @a: {scoped:?}"); + assert!(scoped.contains_key("@b"), "missing @b: {scoped:?}"); + assert_eq!(scoped.len(), 2); +} + +#[test] +fn scoped_registries_empty_when_none() { + let (_dir, config) = setup_config(""); + assert!(config.scoped_registries().is_empty()); +} + +#[test] +fn default_registry_fallback() { + let (_dir, config) = setup_config(""); + assert_eq!(config.default_registry().host_str().unwrap(), "registry.npmjs.org"); +} + +#[test] +fn get_raw_value() { + let (_dir, config) = setup_config("strict-ssl=false"); + assert_eq!(config.get("strict-ssl"), Some("false")); +} + +#[test] +fn get_returns_none_for_missing() { + let (_dir, config) = setup_config(""); + assert!(config.get("nope").is_none()); +} + +#[test] +fn resolve_url_unix_paths() { + let (_dir, client) = make_client(""); + let (url, _) = client.resolve_url("react").unwrap(); + assert!(!url.as_str().contains('\\'), "URL should use forward slashes: {url}"); +} + +#[cfg(windows)] +#[test] +fn config_windows_paths() { + let dir = TempDir::new().expect("temp dir"); + fs::write(dir.path().join("package.json"), "{}").expect("write package.json"); + fs::write(dir.path().join(".npmrc"), "registry=https://win.example.com/").expect("write .npmrc"); + let config = NpmrcConfig::load_with_options(LoadOptions { + cwd: Some(dir.path().to_path_buf()), + skip_user: true, + skip_global: true, + ..Default::default() + }) + .expect("load config on Windows"); + assert_eq!(config.default_registry().host_str().unwrap(), "win.example.com"); +} diff --git a/src/registry_client.rs b/src/registry_client.rs index df751f4a..757de7df 100644 --- a/src/registry_client.rs +++ b/src/registry_client.rs @@ -1,7 +1,11 @@ use { crate::dependency::UpdateUrl, log::debug, - reqwest::{header::ACCEPT, Client, StatusCode}, + npmrc_config_rs::{Credentials, NpmrcConfig}, + reqwest::{ + header::{ACCEPT, AUTHORIZATION}, + Client, StatusCode, Url, + }, serde::{Deserialize, Serialize}, serde_json::Value, std::{collections::BTreeMap, time::Duration}, @@ -47,13 +51,31 @@ pub trait RegistryClient: std::fmt::Debug + Send + Sync { #[derive(Debug)] pub struct LiveRegistryClient { pub client: Client, + pub npmrc: NpmrcConfig, } #[async_trait::async_trait] impl RegistryClient for LiveRegistryClient { async fn fetch(&self, update_url: &UpdateUrl) -> Result { - let req = self.client.get(&update_url.url).header(ACCEPT, "application/json"); - debug!("GET {update_url:?}"); + let (full_url, registry_base) = self.resolve_url(&update_url.package_name)?; + let url_str = full_url.to_string(); + + let mut req = self.client.get(full_url).header(ACCEPT, "application/json"); + if let Some(creds) = self.npmrc.credentials_for(®istry_base) { + req = match &creds { + Credentials::Token { token, .. } => req.bearer_auth(token), + Credentials::BasicAuth { .. } | Credentials::LegacyAuth { .. } => { + if let Some(header) = creds.basic_auth_header() { + req.header(AUTHORIZATION, format!("Basic {header}")) + } else { + req + } + } + Credentials::ClientCertOnly(_) => req, + }; + } + + debug!("GET {url_str}"); match req.send().await { Ok(res) => match res.status() { StatusCode::OK => match res.json::().await { @@ -61,10 +83,7 @@ impl RegistryClient for LiveRegistryClient { let versions = package_meta .versions .into_iter() - .filter(|(_, metadata)| { - // Filter out deprecated versions by checking if "deprecated" field exists - metadata.get("deprecated").is_none() - }) + .filter(|(_, metadata)| metadata.get("deprecated").is_none()) .map(|(version, _)| version) .collect(); Ok(AllPackageVersions { @@ -73,17 +92,14 @@ impl RegistryClient for LiveRegistryClient { }) } Err(err) => Err(RegistryError::FetchError { - url: update_url.url.to_string(), + url: url_str, source: Box::new(err), }), }, - status => Err(RegistryError::HttpError { - url: update_url.url.to_string(), - status, - }), + status => Err(RegistryError::HttpError { url: url_str, status }), }, Err(err) => Err(RegistryError::FetchError { - url: update_url.url.to_string(), + url: url_str, source: Box::new(err), }), } @@ -91,13 +107,31 @@ impl RegistryClient for LiveRegistryClient { } impl LiveRegistryClient { - pub fn new() -> Self { - LiveRegistryClient { + pub fn new(npmrc: NpmrcConfig) -> Self { + Self { client: Client::builder() .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(30)) .build() .expect("Failed to build reqwest client"), + npmrc, + } + } + + /// Resolve the full registry URL for a package name. + /// + /// Uses .npmrc scoped registry config, with a fallback to npm.jsr.io + /// for @jsr/* packages that have no explicit registry configured. + /// Returns (full_url, registry_base) so callers can look up credentials. + pub fn resolve_url(&self, package_name: &str) -> Result<(Url, Url), RegistryError> { + let mut registry_base = self.npmrc.registry_for(package_name); + if package_name.starts_with("@jsr/") && registry_base.host_str() == Some("registry.npmjs.org") { + registry_base = Url::parse("https://npm.jsr.io/").unwrap(); } + let full_url = registry_base.join(package_name).map_err(|e| RegistryError::FetchError { + url: package_name.to_string(), + source: Box::new(e), + })?; + Ok((full_url, registry_base)) } } diff --git a/src/registry_client_test.rs b/src/registry_client_test.rs index 66782005..b75f5a1d 100644 --- a/src/registry_client_test.rs +++ b/src/registry_client_test.rs @@ -1,4 +1,10 @@ -use {crate::registry_client::PackageMeta, serde_json::json, std::collections::BTreeMap}; +use { + crate::registry_client::{LiveRegistryClient, PackageMeta}, + npmrc_config_rs::{LoadOptions, NpmrcConfig}, + serde_json::json, + std::{collections::BTreeMap, fs}, + tempfile::TempDir, +}; #[test] fn filters_out_deprecated_versions() { @@ -60,3 +66,36 @@ fn includes_all_versions_when_none_deprecated() { assert!(versions.contains(&"2.0.0".to_string())); assert!(versions.contains(&"3.0.0".to_string())); } + +fn make_client() -> (TempDir, LiveRegistryClient) { + let dir = TempDir::new().expect("create temp dir"); + fs::write(dir.path().join("package.json"), "{}").expect("write package.json"); + fs::write(dir.path().join(".npmrc"), "").expect("write .npmrc"); + let npmrc = NpmrcConfig::load_with_options(LoadOptions { + cwd: Some(dir.path().to_path_buf()), + skip_user: true, + skip_global: true, + ..Default::default() + }) + .expect("load isolated npmrc config"); + (dir, LiveRegistryClient::new(npmrc)) +} + +#[test] +fn resolve_url_returns_default_for_regular_packages() { + let (_dir, client) = make_client(); + let (url, registry_base) = client.resolve_url("react").unwrap(); + assert!(url.as_str().ends_with("/react"), "URL should end with package name, got: {url}"); + assert_eq!(registry_base.host_str().unwrap(), "registry.npmjs.org"); +} + +#[test] +fn resolve_url_uses_jsr_fallback_for_jsr_packages() { + let (_dir, client) = make_client(); + let (url, _) = client.resolve_url("@jsr/luca__cases").unwrap(); + assert_ne!( + url.host_str().unwrap(), + "registry.npmjs.org", + "Expected JSR package to NOT use registry.npmjs.org, got: {url}", + ); +}