diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 28ebfb5cc3..cc50f73f01 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -1,67 +1,510 @@ +use std::{collections::HashMap, env, ffi::OsString, fs, path::PathBuf}; + +use cow_utils::CowUtils; +use reqwest::{RequestBuilder, Url}; use vp_shared::EnvConfig; +use vt_path::AbsolutePath; +use vt_workspace::find_workspace_root; -/// Get the configured NPM registry URL. -#[must_use] -pub fn npm_registry() -> String { - EnvConfig::get().npm_registry.clone() +const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmjs.org"; + +/// npm configuration used while bootstrapping a package manager. +/// Authentication values stay private and are only applied to matching URLs. +#[derive(Clone)] +pub(crate) struct NpmConfig { + pub(crate) values: HashMap, } -/// Get the tgz url of a npm package -#[must_use] -pub(crate) fn get_npm_package_tgz_url(name: &str, version: &str) -> vt_str::Str { - let registry = npm_registry(); - // convert `@scope/name` to `name` - let filename = name.split('/').next_back().unwrap_or(name); - vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") +impl NpmConfig { + pub(crate) fn load() -> Self { + vt_path::current_dir() + .ok() + .map_or_else(|| Self::load_for_project(None), |cwd| Self::load_for_cwd(&cwd)) + } + + pub(crate) fn load_for_cwd(cwd: &AbsolutePath) -> Self { + find_workspace_root(cwd).map_or_else( + |_| Self::load_for_project(None), + |(root, _)| Self::load_for_project_root(&root.path), + ) + } + + pub(crate) fn load_for_project_root(project_root: &AbsolutePath) -> Self { + Self::load_for_project(Some(project_root.as_path().to_path_buf())) + } + + fn load_for_project(project_root: Option) -> Self { + let mut values = HashMap::new(); + + // A default global npmrc cannot be located reliably before npm exists. + // Honor an explicitly configured one, then layer user and project config. + if let Some(path) = env_value("globalconfig") { + load_npmrc(PathBuf::from(path), &mut values); + } + let user_config = env_value("userconfig") + .map(PathBuf::from) + .unwrap_or_else(|| EnvConfig::get().user_home.join(".npmrc").into_path_buf()); + load_npmrc(user_config, &mut values); + if let Some(root) = project_root { + load_npmrc(root.join(".npmrc"), &mut values); + } + + // npm_config_* is the highest-precedence npm config source available to vp. + for (key, value) in npm_config_env() { + let raw_key = &key["npm_config_".len()..]; + if value.is_empty() { + continue; + } + // npm preserves registry-scoped ("nerf-darted") keys verbatim. + let key = if raw_key.starts_with("//") { + normalize_key(raw_key) + } else { + raw_key.cow_replace('_', "-").cow_to_ascii_lowercase().into_owned() + }; + values.insert(key, value); + } + Self { values } + } + + pub(crate) fn registry_for_package(&self, package: &str) -> String { + let scoped = package + .strip_prefix('@') + .and_then(|rest| rest.split_once('/')) + .and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str())) + .filter(|value| !value.is_empty()); + scoped + .or_else(|| self.values.get("registry").filter(|value| !value.is_empty())) + .map_or_else( + || DEFAULT_NPM_REGISTRY.to_string(), + |value| value.trim_end_matches('/').to_string(), + ) + } + + pub(crate) fn package_tgz_url(&self, name: &str, version: &str) -> vt_str::Str { + let registry = self.registry_for_package(name); + let filename = name.split('/').next_back().unwrap_or(name); + vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") + } + + pub(crate) fn package_version_url(&self, name: &str, version_or_tag: &str) -> vt_str::Str { + let registry = self.registry_for_package(name); + vt_str::format!("{registry}/{name}/{version_or_tag}") + } + + pub(crate) fn package_metadata_url(&self, name: &str) -> vt_str::Str { + let registry = self.registry_for_package(name); + vt_str::format!("{registry}/{name}") + } + + pub(crate) fn apply_auth(&self, request: RequestBuilder, url: &str) -> RequestBuilder { + let Ok(url) = Url::parse(url) else { return request }; + let Some(host) = url.host_str() else { return request }; + let authority = url + .port() + .map_or_else(|| host.to_string(), |port| vt_str::format!("{host}:{port}").to_string()); + let segments: Vec<_> = url + .path_segments() + .into_iter() + .flatten() + .filter(|segment| !segment.is_empty()) + .collect(); + + // Match npm-registry-fetch: the most specific URL path wins. + for length in (0..=segments.len()).rev() { + let path = if length == 0 { + "/".to_string() + } else { + vt_str::format!("/{}/", segments[..length].join("/")).to_string() + }; + let prefix = vt_str::format!("//{}{path}", authority.cow_to_ascii_lowercase()); + for prefix in [prefix.as_str(), prefix.trim_end_matches('/')] { + if let Some(token) = + self.values.get(vt_str::format!("{prefix}:_authtoken").as_str()) + && !token.is_empty() + { + return request.bearer_auth(token); + } + if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) + && !auth.is_empty() + { + return request.header( + reqwest::header::AUTHORIZATION, + vt_str::format!("Basic {auth}").as_str(), + ); + } + let username = self.values.get(vt_str::format!("{prefix}:username").as_str()); + let password = self.values.get(vt_str::format!("{prefix}:_password").as_str()); + if let (Some(username), Some(password)) = (username, password) + && !username.is_empty() + && !password.is_empty() + && let Ok(decoded) = base64_simd::STANDARD.decode_to_vec(password) + { + return request + .basic_auth(username, Some(String::from_utf8_lossy(&decoded).as_ref())); + } + } + } + request + } } -#[must_use] -pub(crate) fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> vt_str::Str { - let registry = npm_registry(); - vt_str::format!("{registry}/{name}/{version_or_tag}") +fn env_value(name: &str) -> Option { + npm_config_env().find_map(|(key, value)| { + key["npm_config_".len()..] + .eq_ignore_ascii_case(name) + .then_some(value) + .filter(|value| !value.is_empty()) + }) +} + +fn npm_config_env() -> impl Iterator { + npm_config_env_from(env::vars_os()) +} + +fn npm_config_env_from( + vars: impl Iterator, +) -> impl Iterator { + vars.filter_map(|(key, value)| { + let key = key.into_string().ok()?; + let value = value.into_string().ok()?; + key.get(.."npm_config_".len())?.eq_ignore_ascii_case("npm_config_").then_some((key, value)) + }) +} + +fn normalize_key(key: &str) -> String { + let key = key.trim(); + let Some((registry, setting)) = key.rsplit_once(':').filter(|_| key.starts_with("//")) else { + return key.cow_to_ascii_lowercase().into_owned(); + }; + let authority_end = registry[2..].find('/').map_or(registry.len(), |index| index + 2); + vt_str::format!( + "{}{}:{}", + registry[..authority_end].cow_to_ascii_lowercase(), + ®istry[authority_end..], + setting.cow_to_ascii_lowercase() + ) + .to_string() +} + +fn expand_value(raw: &str) -> String { + let mut value = raw.trim(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value = &value[1..value.len() - 1]; + } + + let mut expanded = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find("${") { + expanded.push_str(&rest[..start]); + let Some(end) = rest[start + 2..].find('}') else { + expanded.push_str(&rest[start..]); + return expanded; + }; + let expression = &rest[start + 2..start + 2 + end]; + let (name, empty_if_missing) = + expression.strip_suffix('?').map_or((expression, false), |name| (name, true)); + match env::var(name) { + Ok(value) => expanded.push_str(&value), + Err(_) if !empty_if_missing => expanded.push_str(&rest[start..start + 3 + end]), + Err(_) => {} + } + rest = &rest[start + 3 + end..]; + } + expanded.push_str(rest); + expanded } -/// Get the metadata url of a npm package (lists all published versions) +fn load_npmrc(path: PathBuf, values: &mut HashMap) { + let Ok(contents) = fs::read_to_string(path) else { return }; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { continue }; + let key = normalize_key(key); + if !key.is_empty() { + values.insert(key, expand_value(&parse_npmrc_value(value))); + } + } +} + +fn parse_npmrc_value(value: &str) -> String { + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + return serde_json::from_str(value) + .unwrap_or_else(|_| value[1..value.len() - 1].to_string()); + } + if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { + return value[1..value.len() - 1].to_string(); + } + + let mut parsed = String::with_capacity(value.len()); + let mut escaped = false; + for character in value.chars() { + if escaped { + if !matches!(character, '\\' | '#' | ';') { + parsed.push('\\'); + } + parsed.push(character); + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if matches!(character, '#' | ';') { + break; + } + parsed.push(character); + } + if escaped { + parsed.push('\\'); + } + parsed.trim_end().to_string() +} + +/// Get the configured default NPM registry URL. #[must_use] -pub(crate) fn get_npm_package_metadata_url(name: &str) -> vt_str::Str { - let registry = npm_registry(); - vt_str::format!("{registry}/{name}") +pub fn npm_registry() -> String { + NpmConfig::load().registry_for_package("") } #[cfg(test)] mod tests { + use tempfile::TempDir; use vp_shared::env_vars; use super::*; + fn project_with_npmrc(contents: &str) -> TempDir { + let project = TempDir::new().unwrap(); + fs::write(project.path().join("package.json"), "{}").unwrap(); + fs::write(project.path().join(".npmrc"), contents).unwrap(); + project + } + + fn http_client() -> reqwest::Client { + vp_shared::ensure_tls_provider(); + reqwest::Client::new() + } + #[test] - fn test_npm_registry_default() { - vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { - assert_eq!(npm_registry(), "https://registry.npmjs.org"); + fn reads_project_registry_and_scoped_registry() { + let project = project_with_npmrc( + "registry=https://default.example/\n@yarnpkg:registry=https://yarn.example/\n", + ); + EnvConfig::with_vars(std::iter::empty::<(&'static str, &'static str)>(), |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package(""), "https://default.example"); + assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://yarn.example"); }); } #[test] - fn test_npm_registry_custom() { - EnvConfig::with_vars( - [(env_vars::NPM_CONFIG_REGISTRY, "https://registry.npmmirror.com")], - |_| { - assert_eq!(npm_registry(), "https://registry.npmmirror.com"); - }, + fn loads_registry_from_caller_provided_workspace() { + let project = project_with_npmrc("registry=https://target.example\n"); + let cwd = AbsolutePath::new(project.path()).unwrap(); + EnvConfig::with_vars(std::iter::empty::<(&str, &str)>(), |_| { + let config = NpmConfig::load_for_cwd(cwd); + assert_eq!(config.registry_for_package("pnpm"), "https://target.example"); + }); + } + + #[test] + fn empty_registry_values_fall_back() { + let config = NpmConfig { + values: HashMap::from([ + ("@yarnpkg:registry".to_string(), String::new()), + ("registry".to_string(), "https://default.example/".to_string()), + ]), + }; + assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://default.example"); + + let config = NpmConfig { values: HashMap::from([("registry".to_string(), String::new())]) }; + assert_eq!(config.registry_for_package("pnpm"), DEFAULT_NPM_REGISTRY); + } + + #[test] + fn empty_userconfig_environment_value_is_ignored() { + EnvConfig::with_vars([("NPM_CONFIG_USERCONFIG", "")], |_| { + assert_eq!(env_value("userconfig"), None); + }); + } + + #[test] + fn npm_config_environment_prefix_is_case_insensitive() { + let values = npm_config_env_from( + [(OsString::from("Npm_Config_Registry"), OsString::from("https://example.test"))] + .into_iter(), + ) + .collect::>(); + assert_eq!( + values, + vec![("Npm_Config_Registry".to_string(), "https://example.test".to_string())] ); } + #[cfg(unix)] + #[test] + fn non_unicode_environment_entries_are_skipped() { + use std::os::unix::ffi::OsStringExt; + + let values = npm_config_env_from( + [ + (OsString::from_vec(vec![0xff]), OsString::from("ignored")), + (OsString::from("NPM_CONFIG_REGISTRY"), OsString::from_vec(vec![0xff])), + (OsString::from("NPM_CONFIG_REGISTRY"), OsString::from("https://example.test")), + ] + .into_iter(), + ) + .collect::>(); + assert_eq!(values.len(), 1); + assert_eq!(values[0].1, "https://example.test"); + } + + #[test] + fn environment_registry_overrides_project() { + let project = project_with_npmrc("registry=https://project.example\n"); + EnvConfig::with_vars([(env_vars::NPM_CONFIG_REGISTRY, "https://env.example")], |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package(""), "https://env.example") + }); + } + #[test] - fn test_npm_tgz_url() { - vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + fn expands_auth_token_and_matches_longest_url_path() { + let project = project_with_npmrc( + "//registry.example/:_authToken=HOST\n//registry.example/team/:_authToken=${TEST_NPM_TOKEN}\n", + ); + vp_shared::EnvConfig::with_vars([("TEST_NPM_TOKEN", "TEAM")], |_| { + let request = NpmConfig::load_for_project(Some(project.path().to_path_buf())) + .apply_auth( + http_client().get("https://registry.example/team/pkg"), + "https://registry.example/team/pkg", + ) + .build() + .unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer TEAM"); + }); + } + + #[test] + fn empty_credentials_fall_back_to_parent_auth_path() { + let config = NpmConfig { + values: HashMap::from([ + ("//registry.example/team/:_authtoken".to_string(), String::new()), + ("//registry.example/:_authtoken".to_string(), "HOST".to_string()), + ]), + }; + let request = config + .apply_auth( + http_client().get("https://registry.example/team/pkg"), + "https://registry.example/team/pkg", + ) + .build() + .unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer HOST"); + } + + #[test] + fn parses_inline_comments_and_escapes_in_npmrc_values() { + let project = project_with_npmrc( + "registry=https://registry.example/ ; mirror\n\ + //registry.example/:_authToken=SECRET # CI\n\ + quoted=\"value # retained\"\n\ + fragment=https://example.test/\\#retained\n\ + semicolon=left\\;right ; removed\n", + ); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package("pnpm"), "https://registry.example"); + assert_eq!(config.values["//registry.example/:_authtoken"], "SECRET"); + assert_eq!(config.values["quoted"], "value # retained"); + assert_eq!(config.values["fragment"], "https://example.test/#retained"); + assert_eq!(config.values["semicolon"], "left;right"); + } + + #[test] + fn does_not_send_auth_to_another_host() { + let config = NpmConfig { + values: HashMap::from([( + "//registry.example/:_authtoken".to_string(), + "SECRET".to_string(), + )]), + }; + let request = config + .apply_auth(http_client().get("https://other.example/pkg"), "https://other.example/pkg") + .build() + .unwrap(); + assert!(!request.headers().contains_key(reqwest::header::AUTHORIZATION)); + } + + #[test] + fn supports_encoded_and_username_password_basic_auth() { + let encoded = base64_simd::STANDARD.encode_to_string("user:secret"); + let config = NpmConfig { + values: HashMap::from([ + ("//encoded.example/:_auth".to_string(), encoded.clone()), + ("//split.example/:username".to_string(), "user".to_string()), + ( + "//split.example/:_password".to_string(), + base64_simd::STANDARD.encode_to_string("secret"), + ), + ]), + }; + for host in ["encoded.example", "split.example"] { + let url = vt_str::format!("https://{host}/pkg"); + let request = + config.apply_auth(http_client().get(url.as_str()), url.as_str()).build().unwrap(); assert_eq!( - get_npm_package_tgz_url("vite", "7.1.3"), - "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz" + request.headers()[reqwest::header::AUTHORIZATION], + vt_str::format!("Basic {encoded}").as_str() ); + } + } + + #[test] + fn accepts_auth_paths_with_or_without_a_trailing_slash() { + let project = project_with_npmrc( + "//registry.example/team:_authToken=NO_SLASH\n//registry.example/other/:_authToken=SLASH\n", + ); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + for (path, token) in [("team/pkg", "NO_SLASH"), ("other/pkg", "SLASH")] { + let url = vt_str::format!("https://registry.example/{path}"); + let request = + config.apply_auth(http_client().get(url.as_str()), url.as_str()).build().unwrap(); assert_eq!( - get_npm_package_tgz_url("@vitejs/release-scripts", "1.6.0"), - "https://registry.npmjs.org/@vitejs/release-scripts/-/release-scripts-1.6.0.tgz" + request.headers()[reqwest::header::AUTHORIZATION], + vt_str::format!("Bearer {token}").as_str() ); - }); + } + } + + #[test] + fn registry_auth_paths_remain_case_sensitive() { + let project = project_with_npmrc("//registry.example/Team/:_authToken=SECRET\n"); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + + let matching = config + .apply_auth( + http_client().get("https://registry.example/Team/pkg"), + "https://registry.example/Team/pkg", + ) + .build() + .unwrap(); + assert_eq!(matching.headers()[reqwest::header::AUTHORIZATION], "Bearer SECRET"); + + let different_case = config + .apply_auth( + http_client().get("https://registry.example/team/pkg"), + "https://registry.example/team/pkg", + ) + .build() + .unwrap(); + assert!(!different_case.headers().contains_key(reqwest::header::AUTHORIZATION)); } } diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 2b3ac95e9e..c83a087dbd 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -18,6 +18,7 @@ use crossterm::{ }; use semver::Version; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::fs::remove_dir_all; use vp_error::Error; use vp_shared::OnFail; @@ -28,8 +29,8 @@ use vt_workspace::find_package_root; use vt_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; use crate::{ - config::{get_npm_package_metadata_url, get_npm_package_tgz_url, get_npm_package_version_url}, - request::{HttpClient, download_and_extract_tgz_with_hash, verify_file_hash}, + config::NpmConfig, + request::{HttpClient, download_and_extract_tgz_with_hash_and_config, verify_file_hash}, shim, }; @@ -230,13 +231,18 @@ impl PackageManagerBuilder { /// Detect the package manager from the current working directory. pub async fn build(&self) -> Result { let (workspace_root, _) = find_workspace_root(&self.cwd)?; + let npm_config = NpmConfig::load_for_project_root(&workspace_root.path); let (package_manager_type, version_or_req, hash, _) = get_package_manager_type_and_version(&workspace_root, self.client_override)?; // only download the package manager if it's not already downloaded - let (install_dir, _package_name, version) = - download_package_manager(package_manager_type, &version_or_req, hash.as_deref()) - .await?; + let (install_dir, _package_name, version) = download_package_manager_with_config( + package_manager_type, + &version_or_req, + hash.as_deref(), + &npm_config, + ) + .await?; Ok(PackageManager { client: package_manager_type, @@ -560,6 +566,7 @@ pub async fn resolve_environment_package_manager( default_spec: Option<(PackageManagerType, &str, Option<&str>)>, expected: Option, ) -> Result, Error> { + let cwd = cwd.as_ref(); let mut resolution = resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?; if let Some(expected) = expected @@ -572,9 +579,13 @@ pub async fn resolve_environment_package_manager( let Some(mut resolution) = resolution else { return Ok(None); }; - resolution.version = - resolve_package_manager_version(resolution.package_manager_type, &resolution.version) - .await?; + let npm_config = NpmConfig::load_for_cwd(cwd); + resolution.version = resolve_package_manager_version_with_config( + resolution.package_manager_type, + &resolution.version, + &npm_config, + ) + .await?; Ok(Some(resolution)) } @@ -888,12 +899,15 @@ const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(3600); fn latest_version_cache_path( package_manager_type: PackageManagerType, + registry: &str, ) -> io::Result { + let registry_key = hex::encode(Sha256::digest(registry.as_bytes())); Ok(vp_shared::EnvConfig::get() .dirs .cache .join("package_manager_latest") - .join(package_manager_type.to_string())) + .join(package_manager_type.to_string()) + .join(registry_key)) } fn read_latest_version_cache(path: &AbsolutePath) -> Option<(Str, bool)> { @@ -915,21 +929,27 @@ fn write_latest_version_cache(path: &AbsolutePath, version: &str) -> io::Result< fs::write(path, version) } -async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { - let cache_path = latest_version_cache_path(package_manager_type)?; - let cached = read_latest_version_cache(&cache_path); - if let Some((version, true)) = &cached { - return Ok(version.clone()); - } - +async fn get_latest_version_with_config( + package_manager_type: PackageManagerType, + npm_config: &NpmConfig, +) -> Result { let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name "@yarnpkg/cli-dist".to_string() } else { package_manager_type.to_string() }; - let url = get_npm_package_version_url(&package_name, "latest"); - match HttpClient::new().get_json::(&url).await { + let registry = npm_config.registry_for_package(&package_name); + let cache_path = latest_version_cache_path(package_manager_type, ®istry)?; + let cached = read_latest_version_cache(&cache_path); + if let Some((version, true)) = &cached { + return Ok(version.clone()); + } + let url = npm_config.package_version_url(&package_name, "latest"); + match HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json::(&url) + .await + { Ok(package_json) => { let _ = write_latest_version_cache(&cache_path, &package_json.version); Ok(package_json.version) @@ -949,13 +969,22 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< pub async fn resolve_package_manager_version( package_manager_type: PackageManagerType, version: &str, +) -> Result { + resolve_package_manager_version_with_config(package_manager_type, version, &NpmConfig::load()) + .await +} + +async fn resolve_package_manager_version_with_config( + package_manager_type: PackageManagerType, + version: &str, + npm_config: &NpmConfig, ) -> Result { if version == "latest" { - get_latest_version(package_manager_type).await + get_latest_version_with_config(package_manager_type, npm_config).await } else if Version::parse(version).is_ok() { Ok(version.into()) } else { - resolve_package_manager_range(package_manager_type, version).await + resolve_package_manager_range(package_manager_type, version, npm_config).await } } @@ -973,10 +1002,14 @@ struct RegistryPackument { /// smaller than the full packument (KBs instead of MBs for popular packages). const NPM_ABBREVIATED_METADATA_ACCEPT: &str = "application/vnd.npm.install-v1+json"; -async fn fetch_registry_versions(package_name: &str) -> Result, Error> { - let url = get_npm_package_metadata_url(package_name); - let packument: RegistryPackument = - HttpClient::new().get_json_with_accept(&url, NPM_ABBREVIATED_METADATA_ACCEPT).await?; +async fn fetch_registry_versions( + package_name: &str, + npm_config: &NpmConfig, +) -> Result, Error> { + let url = npm_config.package_metadata_url(package_name); + let packument: RegistryPackument = HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json_with_accept(&url, NPM_ABBREVIATED_METADATA_ACCEPT) + .await?; Ok(packument .versions .keys() @@ -988,9 +1021,11 @@ async fn fetch_registry_versions(package_name: &str) -> Result Result, Error> { - let mut versions = fetch_registry_versions(&package_manager_type.to_string()).await?; + let npm_config = NpmConfig::load(); + let mut versions = + fetch_registry_versions(&package_manager_type.to_string(), &npm_config).await?; if matches!(package_manager_type, PackageManagerType::Yarn) { - versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist", &npm_config).await?); } versions.sort(); versions.dedup(); @@ -1017,12 +1052,13 @@ async fn resolve_latest_satisfying_version( package_manager_type: PackageManagerType, range: &node_semver::Range, version_req: &str, + npm_config: &NpmConfig, ) -> Result { let package_name = package_manager_type.to_string(); - let mut versions = fetch_registry_versions(&package_name).await?; + let mut versions = fetch_registry_versions(&package_name, npm_config).await?; // yarn >= 2.0.0 is published as `@yarnpkg/cli-dist`; merge both version lists if matches!(package_manager_type, PackageManagerType::Yarn) { - versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist", npm_config).await?); } let best = versions @@ -1043,7 +1079,7 @@ async fn resolve_latest_satisfying_version( Error::PackageManagerVersionNotFound { name: package_name.clone().into(), version: version_req.into(), - url: get_npm_package_metadata_url(&package_name).into(), + url: npm_config.package_metadata_url(&package_name).into(), } }) } @@ -1095,6 +1131,7 @@ fn find_cached_package_manager_version( async fn resolve_package_manager_range( package_manager_type: PackageManagerType, version_req: &str, + npm_config: &NpmConfig, ) -> Result { let range = node_semver::Range::parse(version_req).map_err(|_| { Error::InvalidArgument( @@ -1113,10 +1150,10 @@ async fn resolve_package_manager_range( // `*` (any version) resolves to the registry's latest stable if version_req == "*" { - return get_latest_version(package_manager_type).await; + return get_latest_version_with_config(package_manager_type, npm_config).await; } - resolve_latest_satisfying_version(package_manager_type, &range, version_req).await + resolve_latest_satisfying_version(package_manager_type, &range, version_req, npm_config).await } /// Download the package manager and extract it to the vite-plus home directory. @@ -1126,7 +1163,27 @@ pub async fn download_package_manager( version_or_latest: &str, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; + download_package_manager_with_config( + package_manager_type, + version_or_latest, + expected_hash, + &NpmConfig::load(), + ) + .await +} + +async fn download_package_manager_with_config( + package_manager_type: PackageManagerType, + version_or_latest: &str, + expected_hash: Option<&str>, + npm_config: &NpmConfig, +) -> Result<(AbsolutePathBuf, Str, Str), Error> { + let version = resolve_package_manager_version_with_config( + package_manager_type, + version_or_latest, + npm_config, + ) + .await?; // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into @@ -1159,7 +1216,7 @@ pub async fn download_package_manager( // not the platform-specific binary, so we don't pass it through; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, data_dir).await; + return download_bun_package_manager(&version, data_dir, npm_config).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -1167,10 +1224,11 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, data_dir, expected_hash).await; + return download_pnpm_native_package_manager(&version, data_dir, expected_hash, npm_config) + .await; } - let tgz_url = get_npm_package_tgz_url(&package_name, &version); + let tgz_url = npm_config.package_tgz_url(&package_name, &version); // /package_manager/pnpm/10.0.0 let target_dir = data_dir.join("package_manager").join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); @@ -1201,12 +1259,13 @@ pub async fn download_package_manager( // A Corepack Yarn 2+ pin covers only the CLI. The rest of the archive stays // unauthenticated, so vp never writes it to disk. let archive_file = is_modern_yarn.then(|| PathBuf::from(format!("package/{YARN_CLI_ENTRY}"))); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &tgz_url, &target_dir_tmp, archive_file.as_deref(), expected_hash, Some(&download_message), + npm_config, ) .await .map_err(|err| { @@ -1408,6 +1467,7 @@ fn bun_requires_baseline() -> bool { async fn download_bun_package_manager( version: &Str, home_dir: &AbsolutePath, + npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); @@ -1425,24 +1485,26 @@ async fn download_bun_package_manager( let platform_package_name = get_bun_platform_package_name()?; // The declared hash never covers the platform tarball, so verify it // against the registry's `dist.integrity` for the platform package. - let platform_hash = fetch_platform_integrity("bun", &platform_package_name, version).await?; + let platform_hash = + fetch_platform_integrity("bun", &platform_package_name, version, npm_config).await?; let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; // Download the platform-specific package directly - let platform_tgz_url = get_npm_package_tgz_url(&platform_package_name, version); + let platform_tgz_url = npm_config.package_tgz_url(&platform_package_name, version); // Keep the TempDir guard alive so a failure path cleans up the temp dir. let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading bun v{version}..."); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &platform_tgz_url, &target_dir_tmp, None, platform_hash.as_deref(), Some(&download_message), + npm_config, ) .await .map_err(|err| { @@ -1558,10 +1620,13 @@ async fn fetch_platform_integrity( bin_name: &str, platform_package_name: &str, version: &Str, + npm_config: &NpmConfig, ) -> Result, Error> { - let metadata_url = get_npm_package_version_url(platform_package_name, version); - let metadata: RegistryVersionMetadata = - HttpClient::new().get_json(&metadata_url).await.map_err(|err| { + let metadata_url = npm_config.package_version_url(platform_package_name, version); + let metadata: RegistryVersionMetadata = HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json(&metadata_url) + .await + .map_err(|err| { if let Error::Reqwest(e) = &err && let Some(status) = e.status() && status == reqwest::StatusCode::NOT_FOUND @@ -1591,6 +1656,7 @@ async fn download_pnpm_native_package_manager( version: &Str, home_dir: &AbsolutePath, expected_hash: Option<&str>, + npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; @@ -1609,15 +1675,16 @@ async fn download_pnpm_native_package_manager( // platform package: verify it against the artifact it names so a bad pin // still fails, matching pnpm <= 11. if let Some(expected_hash) = expected_hash { - let main_tgz_url = get_npm_package_tgz_url("pnpm", version); + let main_tgz_url = npm_config.package_tgz_url("pnpm", version); let verify_dir = tempfile::tempdir()?; let verify_message = format!("Verifying pnpm v{version}..."); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &main_tgz_url, verify_dir.path(), None, Some(expected_hash), Some(&verify_message), + npm_config, ) .await .map_err(|error| name_hashed_artifact(error, PackageManagerType::Pnpm, version))?; @@ -1625,24 +1692,26 @@ async fn download_pnpm_native_package_manager( // The declared hash never covers the platform tarball, so verify it // against the registry's `dist.integrity` for the platform package. - let platform_hash = fetch_platform_integrity("pnpm", platform_package_name, version).await?; + let platform_hash = + fetch_platform_integrity("pnpm", platform_package_name, version, npm_config).await?; let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; // Download the platform-specific package directly - let platform_tgz_url = get_npm_package_tgz_url(platform_package_name, version); + let platform_tgz_url = npm_config.package_tgz_url(platform_package_name, version); // Keep the TempDir guard alive so a failure path cleans up the temp dir. let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading pnpm v{version}..."); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &platform_tgz_url, &target_dir_tmp, None, platform_hash.as_deref(), Some(&download_message), + npm_config, ) .await .map_err(|err| { @@ -2237,6 +2306,44 @@ mod tests { assert_eq!(resolution.source, "default"); } + #[tokio::test] + async fn environment_resolution_uses_target_workspace_registry_and_auth() { + use httpmock::prelude::*; + + let project = create_temp_dir(); + let cwd = AbsolutePathBuf::new(project.path().to_path_buf()).unwrap(); + create_package_json(&cwd, "{}"); + + let server = MockServer::start(); + let registry = server.base_url(); + let authority = registry.strip_prefix("http:").unwrap(); + fs::write( + cwd.join(".npmrc"), + format!("registry={registry}\n{authority}/:_authToken=SECRET\n"), + ) + .unwrap(); + let metadata = server.mock(|when, then| { + when.method(GET).path("/pnpm/latest").header("authorization", "Bearer SECRET"); + then.status(200).json_body(serde_json::json!({ "version": "10.0.0" })); + }); + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + let resolution = resolve_environment_package_manager( + &cwd, + Some((PackageManagerType::Pnpm, "latest", None)), + None, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(resolution.version, "10.0.0"); + metadata.assert(); + }) + .await; + } + #[test] fn environment_spec_keeps_declared_version_range() { let temp_dir = create_temp_dir(); @@ -2344,7 +2451,8 @@ mod tests { ); first.assert_hits(1); - let cache_path = latest_version_cache_path(PackageManagerType::Bun).unwrap(); + let cache_path = + latest_version_cache_path(PackageManagerType::Bun, ®istry).unwrap(); let expire_cache = || { fs::File::options() .write(true) @@ -2381,6 +2489,53 @@ mod tests { .await; } + #[tokio::test(flavor = "current_thread")] + async fn latest_version_cache_is_isolated_by_registry() { + use httpmock::prelude::*; + + let first_registry = MockServer::start(); + let second_registry = MockServer::start(); + let first_request = first_registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(200).json_body(serde_json::json!({ "version": "10.1.0" })); + }); + let second_request = second_registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(200).json_body(serde_json::json!({ "version": "10.2.0" })); + }); + let first_config = NpmConfig { + values: HashMap::from([("registry".to_string(), first_registry.base_url())]), + }; + let second_config = NpmConfig { + values: HashMap::from([("registry".to_string(), second_registry.base_url())]), + }; + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &first_config) + .await + .unwrap(), + "10.1.0" + ); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &second_config) + .await + .unwrap(), + "10.2.0" + ); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &first_config) + .await + .unwrap(), + "10.1.0" + ); + first_request.assert_hits(1); + second_request.assert_hits(1); + }) + .await; + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { @@ -4173,7 +4328,9 @@ mod tests { vp_shared::EnvConfig::with_vars_async( [(env_vars::VP_HOME, vp_home.as_os_str())], |_| async move { - let result = get_latest_version(PackageManagerType::Yarn).await; + let result = + get_latest_version_with_config(PackageManagerType::Yarn, &NpmConfig::load()) + .await; assert!(result.is_ok()); let version = result.unwrap(); // println!("version: {:?}", version); @@ -4532,7 +4689,7 @@ mod tests { .join("bun/bin/bun.native"); fs::write(&native_bin, "existing bun").unwrap(); - download_bun_package_manager(&version, &vp_home).await.unwrap(); + download_bun_package_manager(&version, &vp_home, &NpmConfig::load()).await.unwrap(); assert_eq!(fs::read_to_string(native_bin).unwrap(), "existing bun"); }, diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index f4c29bc835..6d54877dde 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -15,11 +15,14 @@ use tar::Archive; use tokio::{fs, io::AsyncWriteExt}; use vp_error::Error; +use crate::config::NpmConfig; + /// HTTP client with built-in retry support #[derive(Clone)] pub struct HttpClient { max_times: usize, min_delay: u64, + npm_config: NpmConfig, } impl Default for HttpClient { @@ -31,7 +34,7 @@ impl Default for HttpClient { impl HttpClient { /// Create a new HTTP client with default settings (3 retries, 500ms min delay) #[must_use] - pub const fn new() -> Self { + pub fn new() -> Self { Self::with_config(3, 500) } @@ -42,8 +45,12 @@ impl HttpClient { /// * `max_times` - Maximum number of retry attempts /// * `min_delay` - Minimum delay in milliseconds for exponential backoff #[must_use] - pub(crate) const fn with_config(max_times: usize, min_delay: u64) -> Self { - Self { max_times, min_delay } + pub(crate) fn with_config(max_times: usize, min_delay: u64) -> Self { + Self { max_times, min_delay, npm_config: NpmConfig::load() } + } + + pub(crate) fn with_npm_config(max_times: usize, min_delay: u64, npm_config: NpmConfig) -> Self { + Self { max_times, min_delay, npm_config } } /// Get raw bytes from a URL @@ -64,7 +71,12 @@ impl HttpClient { // Read the body inside the retry so a mid-body connection drop gets // retried instead of failing outright, like `download_file`. let bytes = (|| async { - let response = client.get(url).send().await?.error_for_status()?; + let response = self + .npm_config + .apply_auth(client.get(url), url) + .send() + .await? + .error_for_status()?; Ok::<_, Error>(response.bytes().await?) }) .retry( @@ -126,6 +138,7 @@ impl HttpClient { if let Some(accept) = accept { request = request.header(reqwest::header::ACCEPT, accept); } + request = self.npm_config.apply_auth(request, url); let response = request.send().await?.error_for_status()?; Ok::(response.json::().await?) }) @@ -199,7 +212,12 @@ impl HttpClient { // a slow-but-steady transfer must be allowed to finish. let timeout = vp_shared::download_timeout(); let result = (|| async { - let response = client.get(url).timeout(timeout).send().await?.error_for_status()?; + let response = self + .npm_config + .apply_auth(client.get(url).timeout(timeout), url) + .send() + .await? + .error_for_status()?; if let Some(ref pb) = progress { pb.set_position(0); if let Some(size) = response.content_length() { @@ -360,12 +378,32 @@ fn extract_tgz_file( /// # Returns /// * `Ok(())` - If the tgz file is downloaded, verified (if hash provided) and extracted successfully. /// * `Err(e)` - If the tgz file is not downloaded, verified or extracted successfully. +#[cfg(test)] pub(crate) async fn download_and_extract_tgz_with_hash( url: &str, target_dir: impl AsRef, archive_file: Option<&Path>, expected_hash: Option<&str>, message: Option<&str>, +) -> Result<(), Error> { + download_and_extract_tgz_with_hash_and_config( + url, + target_dir, + archive_file, + expected_hash, + message, + &NpmConfig::load(), + ) + .await +} + +pub(crate) async fn download_and_extract_tgz_with_hash_and_config( + url: &str, + target_dir: impl AsRef, + archive_file: Option<&Path>, + expected_hash: Option<&str>, + message: Option<&str>, + npm_config: &NpmConfig, ) -> Result<(), Error> { if let Some(archive_file) = archive_file && (archive_file.as_os_str().is_empty() @@ -389,7 +427,15 @@ pub(crate) async fn download_and_extract_tgz_with_hash( // and propagate unchanged so the caller in `package_manager.rs` can map a // 404 to `PackageManagerVersionNotFound`. (|| async { - download_and_extract_tgz_once(url, &target_dir, archive_file, expected_hash, message).await + download_and_extract_tgz_once( + url, + &target_dir, + archive_file, + expected_hash, + message, + npm_config, + ) + .await }) .retry( ExponentialBuilder::default() @@ -411,6 +457,7 @@ async fn download_and_extract_tgz_once( archive_file: Option<&Path>, expected_hash: Option<&str>, message: Option<&str>, + npm_config: &NpmConfig, ) -> Result<(), Error> { // Reset target directory so a partial prior attempt can't interfere. if fs::try_exists(target_dir).await.unwrap_or(false) { @@ -423,7 +470,7 @@ async fn download_and_extract_tgz_once( // letting `download_file` retry here too would nest two retry layers and // multiply attempts (up to N×M downloads) for a persistent failure. let tgz_file = target_dir.join("package.tgz"); - let client = HttpClient::with_config(0, 0); + let client = HttpClient::with_npm_config(0, 0, npm_config.clone()); client.download_file(url, &tgz_file, message).await?; if let Some(archive_file) = archive_file { @@ -772,6 +819,50 @@ mod tests { assert_eq!(package_info.description, "A test package"); } + #[tokio::test] + async fn npm_auth_is_sent_on_the_first_registry_requests() { + let server = MockServer::start(); + let registry_url = server.base_url(); + let registry_key = registry_url.trim_start_matches("http:"); + let client = HttpClient { + max_times: 0, + min_delay: 0, + npm_config: NpmConfig { + values: std::collections::HashMap::from([( + vt_str::format!("{registry_key}/:_authtoken").to_string(), + "SECRET".to_string(), + )]), + }, + }; + + let authenticated = server.mock(|when, then| { + when.method(GET).path("/package").header("authorization", "Bearer SECRET"); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ "value": true })); + }); + let authenticated_download = server.mock(|when, then| { + when.method(GET).path("/package.tgz").header("authorization", "Bearer SECRET"); + then.status(200).body("archive"); + }); + let result: serde_json::Value = + client.get_json(&vt_str::format!("{}/package", server.base_url())).await.unwrap(); + let target = TempDir::new().unwrap(); + client + .download_file( + &vt_str::format!("{}/package.tgz", server.base_url()), + target.path().join("package.tgz"), + None, + ) + .await + .unwrap(); + + assert_eq!(result, serde_json::json!({ "value": true })); + authenticated.assert_hits(1); + authenticated_download.assert_hits(1); + assert_eq!(fs::read(target.path().join("package.tgz")).unwrap(), b"archive"); + } + #[tokio::test] async fn test_http_client_download_file() { let server = MockServer::start(); diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index 802af64636..49ee9c2228 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -74,6 +74,17 @@ These variables control the installer scripts and the standalone Windows install - **Purpose**: Custom npm registry URL - **Default**: `https://registry.npmjs.org` - **CLI equivalent**: `--registry` +- **Managed package managers**: When Vite+ downloads a pinned npm, pnpm, + Yarn, or Bun version, it also reads the workspace-root and user `.npmrc`. + Package-scoped registries and registry-scoped `_authToken`, `_auth`, or + `username`/`_password` credentials are honored. Keep secrets in environment + variables and reference them from `.npmrc`, for example: + + ```ini + registry=https://npm.corp.example/repository/npm/ + //npm.corp.example/repository/npm/:_authToken=${NPM_TOKEN} + ``` + - **Example**: ```bash curl -fsSL https://vite.plus | NPM_CONFIG_REGISTRY=https://registry.npmmirror.com bash