From 8de94e0577e3e4a49cf15402fb7d48355003f909 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 12 Feb 2026 00:11:17 +0800 Subject: [PATCH 1/7] fix(pm): pass cwd explicitly in run command and add ping command - Fix run_script to accept cwd parameter instead of using std::env::current_dir() - Add ping command to check npm registry connectivity Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/pm/src/cmd/mod.rs | 1 + crates/pm/src/cmd/ping.rs | 39 ++++++++++++++++++++++++++++++ crates/pm/src/cmd/run.rs | 49 ++++++++++++++++---------------------- crates/pm/src/constants.rs | 4 ++++ crates/pm/src/main.rs | 13 +++++++--- 6 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 crates/pm/src/cmd/ping.rs diff --git a/Cargo.lock b/Cargo.lock index d6278c391d..b1d2059abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8857,6 +8857,7 @@ dependencies = [ "serde_json", "shrink-to-fit", "smallvec", + "thiserror 1.0.69", "tokio", "tokio-util", "tracing", diff --git a/crates/pm/src/cmd/mod.rs b/crates/pm/src/cmd/mod.rs index 39fd6b6c64..e23c480e1b 100644 --- a/crates/pm/src/cmd/mod.rs +++ b/crates/pm/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod init; pub mod install; pub mod link; pub mod list; +pub mod ping; pub mod rebuild; pub mod run; pub mod update; diff --git a/crates/pm/src/cmd/ping.rs b/crates/pm/src/cmd/ping.rs new file mode 100644 index 0000000000..4cb69ea147 --- /dev/null +++ b/crates/pm/src/cmd/ping.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use colored::Colorize; + +use crate::util::config::get_registry; + +pub async fn ping() -> Result<()> { + let registry = get_registry(); + + println!("{} {}", "PING".green(), registry.cyan()); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + let ping_url = format!("{}/-/ping", registry); + let start = std::time::Instant::now(); + + match client.get(&ping_url).send().await { + Ok(resp) => { + let latency = start.elapsed().as_millis(); + if resp.status().is_success() { + println!("{} {}ms", "PONG".green(), latency.to_string().cyan()); + } else { + println!( + "{} HTTP {} ({}ms)", + "FAIL".red(), + resp.status(), + latency + ); + } + } + Err(e) => { + let latency = start.elapsed().as_millis(); + println!("{} {} ({}ms)", "FAIL".red(), e, latency); + } + } + + Ok(()) +} diff --git a/crates/pm/src/cmd/run.rs b/crates/pm/src/cmd/run.rs index 6eb09c964a..8a822e1aa3 100644 --- a/crates/pm/src/cmd/run.rs +++ b/crates/pm/src/cmd/run.rs @@ -51,6 +51,7 @@ pub async fn run( .as_ref() .map(|args| args.iter().map(|s| s.as_str()).collect::>()); run_script( + &updated_cwd, &script_name, selected_workspace.as_deref(), script_args_refs, @@ -61,19 +62,16 @@ pub async fn run( } pub async fn run_script( + cwd: &Path, script_name: &str, workspace: Option<&str>, script_args: Option>, ) -> Result<()> { - let cwd = std::env::current_dir().context("Failed to get current directory")?; - let updated_cwd = update_cwd_to_project(&cwd).await?; + let updated_cwd = update_cwd_to_project(cwd).await?; let pkg = if let Some(workspace_name) = &workspace { - let workspace_dir = find_workspace_path( - &std::env::current_dir().context("Failed to get current directory")?, - workspace_name, - ) - .await - .context("Failed to find workspace path")?; + let workspace_dir = find_workspace_path(&updated_cwd, workspace_name) + .await + .context("Failed to find workspace path")?; tracing::debug!( "Using workspace: {} at path: {}", workspace_name, @@ -89,14 +87,11 @@ pub async fn run_script( let package = PackageInfo { path: if let Some(workspace_name) = workspace { - find_workspace_path( - &std::env::current_dir().context("Failed to get current directory")?, - workspace_name, - ) - .await - .context("Failed to find workspace path")? + find_workspace_path(&updated_cwd, workspace_name) + .await + .context("Failed to find workspace path")? } else { - std::env::current_dir().context("Failed to get current directory")? + updated_cwd.to_path_buf() }, bin_files: Default::default(), scripts: Scripts::default(), @@ -248,6 +243,7 @@ pub async fn run_script_in_all_workspaces( for workspace_name in workspaces_to_run { let script_name = script_name.to_string(); let script_args = script_args.clone(); + let cwd = updated_cwd.clone(); // Spawn concurrent task for each workspace in the layer join_set.spawn(async move { @@ -258,7 +254,7 @@ pub async fn run_script_in_all_workspaces( let script_args_refs = script_args .as_ref() .map(|args| args.iter().map(|s| s.as_str()).collect::>()); - match run_script(&script_name, Some(&workspace_name), script_args_refs).await { + match run_script(&cwd, &script_name, Some(&workspace_name), script_args_refs).await { Ok(()) => { tracing::debug!( "Successfully completed script '{script_name}' in workspace '{workspace_name}'" @@ -329,7 +325,7 @@ mod tests { #[tokio::test] async fn test_run_script_not_found() { - let _dir = tempdir().unwrap(); + let dir = tempdir().unwrap(); let package_json = r#" { "name": "@test/package", @@ -339,10 +335,9 @@ mod tests { } }"#; - fs::write(_dir.path().join("package.json"), package_json).unwrap(); - std::env::set_current_dir(_dir.path()).unwrap(); + fs::write(dir.path().join("package.json"), package_json).unwrap(); - let result = run_script("nonexistent", None, None).await; + let result = run_script(dir.path(), "nonexistent", None, None).await; assert!(result.is_err()); assert!( @@ -355,20 +350,19 @@ mod tests { #[tokio::test] async fn test_run_script_invalid_json() { - let _dir = tempdir().unwrap(); + let dir = tempdir().unwrap(); let invalid_json = r#"{ "name": "test", "scripts": { "test": 123 } }"#; - fs::write(_dir.path().join("package.json"), invalid_json).unwrap(); - std::env::set_current_dir(_dir.path()).unwrap(); + fs::write(dir.path().join("package.json"), invalid_json).unwrap(); - let result = run_script("test", None, None).await; + let result = run_script(dir.path(), "test", None, None).await; assert!(result.is_err()); } #[tokio::test] async fn test_get_topo_without_workspaces() { - let _dir = tempdir().unwrap(); + let dir = tempdir().unwrap(); let package_json = r#" { "name": "test-project", @@ -378,10 +372,9 @@ mod tests { } }"#; - fs::write(_dir.path().join("package.json"), package_json).unwrap(); - std::env::set_current_dir(_dir.path()).unwrap(); + fs::write(dir.path().join("package.json"), package_json).unwrap(); - let result = WorkspaceService::get_workspace_topology(_dir.path()).await; + let result = WorkspaceService::get_workspace_topology(dir.path()).await; assert!(result.is_ok()); let topology = result.unwrap(); // Should return empty topology for non-workspace projects diff --git a/crates/pm/src/constants.rs b/crates/pm/src/constants.rs index 91ba39870b..3e5f9391d4 100644 --- a/crates/pm/src/constants.rs +++ b/crates/pm/src/constants.rs @@ -57,4 +57,8 @@ pub mod cmd { pub const INIT_NAME: &str = "init"; pub const INIT_ALIAS: &str = "create"; pub const INIT_ABOUT: &str = "Create a package.json file"; + + pub const PING_NAME: &str = "ping"; + pub const PING_ALIAS: &str = "pg"; + pub const PING_ABOUT: &str = "Ping npm registry"; } diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 2b055376de..92f2b32685 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -33,9 +33,10 @@ use crate::constants::cmd::{ CLEAN_ABOUT, CLEAN_ALIAS, CLEAN_NAME, CONFIG_ABOUT, CONFIG_ALIAS, CONFIG_NAME, DEPS_ABOUT, DEPS_ALIAS, DEPS_NAME, EXECUTE_ABOUT, EXECUTE_ALIAS, EXECUTE_NAME, INIT_ABOUT, INIT_ALIAS, INIT_NAME, INSTALL_ABOUT, INSTALL_ALIAS, INSTALL_NAME, LINK_ABOUT, LINK_ALIAS, LINK_NAME, - LIST_ALIAS, LIST_NAME, REBUILD_ABOUT, REBUILD_ALIAS, REBUILD_NAME, RUN_ALIAS, RUN_NAME, - UNINSTALL_ABOUT, UNINSTALL_ALIAS, UNINSTALL_NAME, UPDATE_ABOUT, UPDATE_ALIAS, UPDATE_NAME, - VIEW_ABOUT, VIEW_ALIAS, VIEW_ALIAS_INFO, VIEW_ALIAS_SHOW, VIEW_NAME, + LIST_ALIAS, LIST_NAME, PING_ABOUT, PING_ALIAS, PING_NAME, REBUILD_ABOUT, REBUILD_ALIAS, + REBUILD_NAME, RUN_ALIAS, RUN_NAME, UNINSTALL_ABOUT, UNINSTALL_ALIAS, UNINSTALL_NAME, + UPDATE_ABOUT, UPDATE_ALIAS, UPDATE_NAME, VIEW_ABOUT, VIEW_ALIAS, VIEW_ALIAS_INFO, + VIEW_ALIAS_SHOW, VIEW_NAME, }; use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION}; use crate::helper::workspace::update_cwd_to_root; @@ -244,6 +245,9 @@ enum Commands { prefix: Option, }, + #[command(name = PING_NAME, alias = PING_ALIAS, about = PING_ABOUT)] + Ping, + #[command(name = CONFIG_NAME, alias = CONFIG_ALIAS, about = CONFIG_ABOUT)] Config { #[command(subcommand)] @@ -491,6 +495,9 @@ async fn async_main() -> Result<()> { init(yes, None).await?; log_time_end("package.json created"); } + Some(Commands::Ping) => { + cmd::ping::ping().await?; + } Some(Commands::Config { command }) => match command { ConfigCommands::Set { key, value, global } => { handle_config_set(key, value, global).await?; From 4a674a8c3931aaa5331e1074d81addb1bbfd1548 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 12 Feb 2026 16:27:07 +0800 Subject: [PATCH 2/7] feat: utoo ping --- crates/pm/src/cmd/config.rs | 3 + crates/pm/src/cmd/ping.rs | 14 ++- crates/pm/src/helper/ruborist_context.rs | 14 ++- crates/pm/src/util/config.rs | 116 +++++++++++++++++++++++ crates/ruborist/src/service/api.rs | 15 ++- crates/ruborist/src/service/registry.rs | 42 +++++++- crates/utoo-wasm/src/deps.rs | 1 + 7 files changed, 195 insertions(+), 10 deletions(-) diff --git a/crates/pm/src/cmd/config.rs b/crates/pm/src/cmd/config.rs index 1b132bdd4e..7c6b104618 100644 --- a/crates/pm/src/cmd/config.rs +++ b/crates/pm/src/cmd/config.rs @@ -56,5 +56,8 @@ pub async fn handle_config_list(global: bool) -> Result<()> { for (key, value) in config.list()? { println!("{key} = {value}"); } + for (key, values) in config.list_arrays() { + println!("{key} = {:?}", values); + } Ok(()) } diff --git a/crates/pm/src/cmd/ping.rs b/crates/pm/src/cmd/ping.rs index 4cb69ea147..92a7929546 100644 --- a/crates/pm/src/cmd/ping.rs +++ b/crates/pm/src/cmd/ping.rs @@ -1,7 +1,7 @@ use anyhow::Result; use colored::Colorize; -use crate::util::config::get_registry; +use crate::util::config::{get_registry, get_supports_semver}; pub async fn ping() -> Result<()> { let registry = get_registry(); @@ -19,7 +19,17 @@ pub async fn ping() -> Result<()> { Ok(resp) => { let latency = start.elapsed().as_millis(); if resp.status().is_success() { - println!("{} {}ms", "PONG".green(), latency.to_string().cyan()); + let semver_info = match get_supports_semver() { + Some(true) => "supports-semver: yes".green(), + Some(false) => "supports-semver: no".yellow(), + None => "supports-semver: unknown".dimmed(), + }; + println!( + "{} {}ms ({})", + "PONG".green(), + latency.to_string().cyan(), + semver_info + ); } else { println!( "{} HTTP {} ({}ms)", diff --git a/crates/pm/src/helper/ruborist_context.rs b/crates/pm/src/helper/ruborist_context.rs index ab7c369d78..da8acb92b2 100644 --- a/crates/pm/src/helper/ruborist_context.rs +++ b/crates/pm/src/helper/ruborist_context.rs @@ -5,7 +5,9 @@ use utoo_ruborist::service::{BuildDepsOptions, Glob, UnifiedRegistry}; use crate::service::pipeline::{PipelineChannels, PipelineReceiver}; use crate::util::cache::get_cache_dir; -use crate::util::config::{get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry}; +use crate::util::config::{ + get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry, get_supports_semver, +}; use crate::util::logger::ProgressReceiver; /// Tokio-based glob implementation. @@ -46,6 +48,7 @@ impl Context { legacy_peer_deps: get_legacy_peer_deps().await, glob: TokioGlob, receiver, + supports_semver: get_supports_semver(), } } @@ -70,10 +73,13 @@ impl Context { /// Create a UnifiedRegistry with standard configuration. pub fn registry() -> Registry { - UnifiedRegistry::builder() + let mut builder = UnifiedRegistry::builder() .registry(get_registry()) - .cache_dir(get_cache_dir()) - .build() + .cache_dir(get_cache_dir()); + if let Some(semver) = get_supports_semver() { + builder = builder.supports_semver(semver); + } + builder.build() } /// Get the glob instance. diff --git a/crates/pm/src/util/config.rs b/crates/pm/src/util/config.rs index dc99b56e4a..70ac171328 100644 --- a/crates/pm/src/util/config.rs +++ b/crates/pm/src/util/config.rs @@ -14,6 +14,8 @@ pub type ConfigResult = Result; #[derive(Debug, Serialize, Deserialize, Default)] pub struct Config { values: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + arrays: HashMap>, } // global config path is ~/.utoo/config.toml @@ -29,6 +31,7 @@ impl Config { if crate::fs::try_exists(&local_path).await? { let local_config = Self::load_from_path(&local_path).await?; config.values.extend(local_config.values); + config.arrays.extend(local_config.arrays); } Ok(config) } @@ -42,6 +45,15 @@ impl Config { Ok(self.values.get(key).cloned()) } + pub fn get_array(&self, key: &str) -> Option<&Vec> { + self.arrays.get(key) + } + + pub fn set_array(&mut self, key: &str, value: Vec, global: bool) -> ConfigResult<()> { + self.arrays.insert(key.to_string(), value); + self.save(global) + } + async fn load_from_path(path: &Path) -> ConfigResult { if !crate::fs::try_exists(path).await? { return Ok(Config::default()); @@ -92,6 +104,10 @@ impl Config { pub fn list(&self) -> ConfigResult> { Ok(self.values.iter()) } + + pub fn list_arrays(&self) -> impl Iterator)> { + self.arrays.iter() + } } // Configuration value parser for different types @@ -215,6 +231,11 @@ pub async fn set_registry(registry: Option) { }; REGISTRY.set(final_registry); + + // Auto-detect semver support for the selected registry + let registry_url = get_registry(); + let supports = detect_supports_semver(®istry_url).await; + let _ = SUPPORTS_SEMVER.set(supports); } pub fn get_registry() -> String { @@ -281,6 +302,101 @@ pub fn get_cache_dir() -> PathBuf { PathBuf::from(CACHE_DIR.get_sync()) } +// Semver support detection and caching +// +// Config key `supports-semver` is a TOML array of probe results. Each entry +// is the registry URL (positive → supports semver) or prefixed with "!" +// (negative → does NOT support): +// +// [arrays] +// supports-semver = [ +// "https://registry.npmmirror.com", +// "!https://some-private.registry", +// ] +// +// Multiple registries are cached simultaneously, so switching between them +// (e.g. `ut install --registry=xxx`) won't trigger a re-probe. +static SUPPORTS_SEMVER: OnceLock = OnceLock::new(); + +/// Probe a registry to detect whether it supports semver resolution. +/// +/// 1. `is_npm_registry(url)` → return `false` immediately (known not to support) +/// 2. Search `arrays.supports-semver` for `"{url}"` (true) or `"!{url}"` (false) +/// 3. On cache miss, probe `GET $REGISTRY/utoo/%5E1` (5 s timeout) +/// 4. Append the result to the array for future runs +pub async fn detect_supports_semver(registry_url: &str) -> bool { + use utoo_ruborist::registry::is_npm_registry; + + // Known: npm registry does not support semver queries + if is_npm_registry(registry_url) { + tracing::debug!("npm registry detected, skipping semver probe"); + return false; + } + + // Check config cache (TOML array of "url" / "!url" entries) + let mut config = Config::load(true).await.unwrap_or_default(); + let cached = config.get_array("supports-semver"); + + if let Some(entries) = cached { + for entry in entries { + if entry == registry_url { + tracing::debug!("Cache hit: supports-semver=true for {}", registry_url); + return true; + } + if entry.strip_prefix('!') == Some(registry_url) { + tracing::debug!("Cache hit: supports-semver=false for {}", registry_url); + return false; + } + } + } + + // Probe the registry + let probe_url = format!("{}/utoo/%5E1", registry_url.trim_end_matches('/')); + tracing::debug!("Probing semver support: {}", probe_url); + + let supports = async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + let resp = client + .get(&probe_url) + .header("Accept", "application/vnd.npm.install-v1+json") + .send() + .await?; + tracing::debug!("Semver probe response: {} for {}", resp.status(), probe_url); + Ok::(resp.status().is_success()) + } + .await + .unwrap_or_else(|e: reqwest::Error| { + tracing::debug!("Semver probe failed: {}", e); + false + }); + + // Append result to the cached array + let new_entry = if supports { + registry_url.to_string() + } else { + format!("!{}", registry_url) + }; + let mut entries = config + .get_array("supports-semver") + .cloned() + .unwrap_or_default(); + entries.push(new_entry); + let _ = config.set_array("supports-semver", entries, true); + + tracing::debug!( + "Detected supports-semver={} for {}", + supports, + registry_url + ); + supports +} + +pub fn get_supports_semver() -> Option { + SUPPORTS_SEMVER.get().copied() +} + #[cfg(test)] mod cache_dir_tests { use super::*; diff --git a/crates/ruborist/src/service/api.rs b/crates/ruborist/src/service/api.rs index 7c6bc335d3..2856e3f1b4 100644 --- a/crates/ruborist/src/service/api.rs +++ b/crates/ruborist/src/service/api.rs @@ -59,6 +59,8 @@ pub struct BuildDepsOptions { pub glob: G, /// Progress event receiver pub receiver: R, + /// Explicit semver support override (None = auto-detect from registry URL) + pub supports_semver: Option, } impl BuildDepsOptions { @@ -76,6 +78,7 @@ impl BuildDepsOptions { legacy_peer_deps: true, glob, receiver, + supports_semver: None, } } } @@ -121,6 +124,7 @@ where legacy_peer_deps, glob, receiver, + supports_semver, } = options; // 1. Find root path (workspace root if applicable) @@ -236,10 +240,13 @@ where } // 9. Create registry client with shared cache - let registry = UnifiedRegistry::builder() + let mut builder = UnifiedRegistry::builder() .registry(®istry_url) - .cache(package_cache) - .build(); + .cache(package_cache); + if let Some(semver) = supports_semver { + builder = builder.supports_semver(semver); + } + let registry = builder.build(); tracing::debug!( "Using registry: {} (semver: {}, disk cache: {})", @@ -317,10 +324,12 @@ mod tests { legacy_peer_deps: true, glob: NoopGlob, receiver: NoopReceiver, + supports_semver: None, }; assert_eq!(options.concurrency, 20); assert!(options.legacy_peer_deps); + assert!(options.supports_semver.is_none()); } #[test] diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 4c2ac8d53d..5b29751d59 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -75,6 +75,7 @@ pub struct UnifiedRegistryBuilder { registry_url: Option, cache: Option>, cache_dir: Option, + supports_semver: Option, } impl UnifiedRegistryBuilder { @@ -84,6 +85,7 @@ impl UnifiedRegistryBuilder { registry_url: None, cache: None, cache_dir: None, + supports_semver: None, } } @@ -105,12 +107,22 @@ impl UnifiedRegistryBuilder { self } + /// Explicitly set whether the registry supports semver resolution. + /// + /// If not set, defaults to `!is_npm_registry(url)`. + pub fn supports_semver(mut self, value: bool) -> Self { + self.supports_semver = Some(value); + self + } + /// Build the registry client. pub fn build(self) -> UnifiedRegistry { let registry_url = self .registry_url .unwrap_or_else(|| "https://registry.npmmirror.com".to_string()); - let supports_semver = !is_npm_registry(®istry_url); + let supports_semver = self + .supports_semver + .unwrap_or_else(|| !is_npm_registry(®istry_url)); // Priority: shared cache > cache_dir > new cache let cache = self.cache.unwrap_or_else(|| { @@ -630,6 +642,34 @@ mod tests { assert_eq!(registry.registry_url(), "https://registry.npmjs.org"); } + #[test] + fn test_unified_registry_builder_explicit_supports_semver() { + // Explicitly override supports_semver for npm registry + let registry = UnifiedRegistry::builder() + .registry("https://registry.npmjs.org") + .supports_semver(true) + .build(); + assert!(registry.supports_semver()); + + // Explicitly override supports_semver for non-npm registry + let registry = UnifiedRegistry::builder() + .registry("https://registry.npmmirror.com") + .supports_semver(false) + .build(); + assert!(!registry.supports_semver()); + + // Without explicit override, auto-detect based on URL + let registry = UnifiedRegistry::builder() + .registry("https://registry.npmjs.org") + .build(); + assert!(!registry.supports_semver()); + + let registry = UnifiedRegistry::builder() + .registry("https://registry.npmmirror.com") + .build(); + assert!(registry.supports_semver()); + } + #[test] fn test_unified_registry_with_cache_dir() { let registry = UnifiedRegistry::builder() diff --git a/crates/utoo-wasm/src/deps.rs b/crates/utoo-wasm/src/deps.rs index e5cefb06da..0665717155 100644 --- a/crates/utoo-wasm/src/deps.rs +++ b/crates/utoo-wasm/src/deps.rs @@ -41,6 +41,7 @@ pub async fn build_deps_from_file( legacy_peer_deps: true, glob: OpfsGlob, receiver: NoopReceiver, + supports_semver: None, }; build_deps(options).await From bae254ad9211459289c67d84a7808aa8c50983b7 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 12 Feb 2026 22:16:08 +0800 Subject: [PATCH 3/7] refactor: setting & config --- crates/pm/src/cmd/install.rs | 2 +- crates/pm/src/cmd/ping.rs | 17 +- crates/pm/src/cmd/view.rs | 2 +- crates/pm/src/helper/auto_update.rs | 2 +- crates/pm/src/helper/lock.rs | 2 +- crates/pm/src/helper/ruborist_context.rs | 2 +- crates/pm/src/helper/tree_builder.rs | 2 +- crates/pm/src/main.rs | 11 +- crates/pm/src/service/binary.rs | 2 +- crates/pm/src/util/cache.rs | 2 +- crates/pm/src/util/config.rs | 294 +---------------------- crates/pm/src/util/downloader.rs | 2 +- crates/pm/src/util/mod.rs | 1 + crates/pm/src/util/settings.rs | 276 +++++++++++++++++++++ next.js | 2 +- 15 files changed, 314 insertions(+), 305 deletions(-) create mode 100644 crates/pm/src/util/settings.rs diff --git a/crates/pm/src/cmd/install.rs b/crates/pm/src/cmd/install.rs index 2220bce65b..c0bc3faf71 100644 --- a/crates/pm/src/cmd/install.rs +++ b/crates/pm/src/cmd/install.rs @@ -2,7 +2,7 @@ use anyhow::Result; use std::path::Path; use crate::service::install::InstallService; -use crate::util::config::get_omit; +use crate::util::settings::get_omit; use crate::util::save_type::{PackageAction, SaveType}; pub async fn update_packages( diff --git a/crates/pm/src/cmd/ping.rs b/crates/pm/src/cmd/ping.rs index 92a7929546..f0694f1b91 100644 --- a/crates/pm/src/cmd/ping.rs +++ b/crates/pm/src/cmd/ping.rs @@ -1,10 +1,12 @@ use anyhow::Result; use colored::Colorize; -use crate::util::config::{get_registry, get_supports_semver}; +use crate::util::settings::{detect_supports_semver, get_registry}; -pub async fn ping() -> Result<()> { - let registry = get_registry(); +pub async fn ping(registry: Option<&str>) -> Result<()> { + let registry = registry + .map(String::from) + .unwrap_or_else(get_registry); println!("{} {}", "PING".green(), registry.cyan()); @@ -19,10 +21,11 @@ pub async fn ping() -> Result<()> { Ok(resp) => { let latency = start.elapsed().as_millis(); if resp.status().is_success() { - let semver_info = match get_supports_semver() { - Some(true) => "supports-semver: yes".green(), - Some(false) => "supports-semver: no".yellow(), - None => "supports-semver: unknown".dimmed(), + let supports = detect_supports_semver(®istry).await; + let semver_info = if supports { + "supports-semver: yes".green() + } else { + "supports-semver: no".yellow() }; println!( "{} {}ms ({})", diff --git a/crates/pm/src/cmd/view.rs b/crates/pm/src/cmd/view.rs index 60e3233dcc..7a0f0e05c1 100644 --- a/crates/pm/src/cmd/view.rs +++ b/crates/pm/src/cmd/view.rs @@ -7,7 +7,7 @@ use utoo_ruborist::service::fetch_full_manifest; use utoo_ruborist::util::parse_package_spec; use crate::helper::ruborist_context::Context; -use crate::util::config::get_registry; +use crate::util::settings::get_registry; use crate::util::format_print::print_grid; /// View package information from registry, similar to npm view diff --git a/crates/pm/src/helper/auto_update.rs b/crates/pm/src/helper/auto_update.rs index 7d9dbfa486..4cdbcb6d06 100644 --- a/crates/pm/src/helper/auto_update.rs +++ b/crates/pm/src/helper/auto_update.rs @@ -1,5 +1,5 @@ use crate::constants::APP_VERSION; -use crate::util::config::get_registry; +use crate::util::settings::get_registry; use anyhow::{Context, Result}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; diff --git a/crates/pm/src/helper/lock.rs b/crates/pm/src/helper/lock.rs index 8dec35cea0..02dc40244a 100644 --- a/crates/pm/src/helper/lock.rs +++ b/crates/pm/src/helper/lock.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use super::ruborist_context::Context; use crate::helper::workspace::find_workspaces; -use crate::util::config::get_legacy_peer_deps; +use crate::util::settings::get_legacy_peer_deps; use crate::util::json::{load_package_json_from_path, load_package_lock_json_from_path}; use crate::util::logger::{finish_progress_bar, start_progress_bar}; use crate::util::save_type::{PackageAction, SaveType}; diff --git a/crates/pm/src/helper/ruborist_context.rs b/crates/pm/src/helper/ruborist_context.rs index da8acb92b2..b4ade8e515 100644 --- a/crates/pm/src/helper/ruborist_context.rs +++ b/crates/pm/src/helper/ruborist_context.rs @@ -5,7 +5,7 @@ use utoo_ruborist::service::{BuildDepsOptions, Glob, UnifiedRegistry}; use crate::service::pipeline::{PipelineChannels, PipelineReceiver}; use crate::util::cache::get_cache_dir; -use crate::util::config::{ +use crate::util::settings::{ get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry, get_supports_semver, }; use crate::util::logger::ProgressReceiver; diff --git a/crates/pm/src/helper/tree_builder.rs b/crates/pm/src/helper/tree_builder.rs index 60703630da..29241f3ca2 100644 --- a/crates/pm/src/helper/tree_builder.rs +++ b/crates/pm/src/helper/tree_builder.rs @@ -13,7 +13,7 @@ use utoo_ruborist::manifest::PackageJson; use crate::helper::install_runtime::install_runtime; use crate::helper::workspace::find_workspaces; -use crate::util::config::get_legacy_peer_deps; +use crate::util::settings::get_legacy_peer_deps; use crate::util::json::load_package_json_from_path; use crate::util::logger::{finish_progress_bar, start_progress_bar}; diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 92f2b32685..4fbd01ffa1 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -15,7 +15,7 @@ use cmd::update::update; use cmd::view::view; use cmd::{clean::clean, deps::build_workspace}; use helper::auto_update::init_auto_update; -use util::config::{ +use util::settings::{ set_cache_dir, set_legacy_peer_deps, set_manifests_concurrency_limit, set_omit, set_registry, }; use util::logger::{get_log_file_path, init_tracing, log_time, log_time_end}; @@ -246,7 +246,10 @@ enum Commands { }, #[command(name = PING_NAME, alias = PING_ALIAS, about = PING_ABOUT)] - Ping, + Ping { + /// Registry URL to ping (defaults to configured registry) + registry: Option, + }, #[command(name = CONFIG_NAME, alias = CONFIG_ALIAS, about = CONFIG_ABOUT)] Config { @@ -495,8 +498,8 @@ async fn async_main() -> Result<()> { init(yes, None).await?; log_time_end("package.json created"); } - Some(Commands::Ping) => { - cmd::ping::ping().await?; + Some(Commands::Ping { registry }) => { + cmd::ping::ping(registry.as_deref()).await?; } Some(Commands::Config { command }) => match command { ConfigCommands::Set { key, value, global } => { diff --git a/crates/pm/src/service/binary.rs b/crates/pm/src/service/binary.rs index 7190647b68..9459db6fd4 100644 --- a/crates/pm/src/service/binary.rs +++ b/crates/pm/src/service/binary.rs @@ -1,5 +1,5 @@ use crate::fs; -use crate::util::config::get_registry; +use crate::util::settings::get_registry; use crate::util::json::load_package_json_from_path; use anyhow::{Context, Result}; use regex::Regex; diff --git a/crates/pm/src/util/cache.rs b/crates/pm/src/util/cache.rs index 97e8153468..265efce9b9 100644 --- a/crates/pm/src/util/cache.rs +++ b/crates/pm/src/util/cache.rs @@ -1,6 +1,6 @@ use crate::fs; -pub use super::config::get_cache_dir; +pub use super::settings::get_cache_dir; pub fn matches_pattern(text: &str, pattern: &str) -> bool { if pattern == "*" { diff --git a/crates/pm/src/util/config.rs b/crates/pm/src/util/config.rs index 70ac171328..a5549f91cf 100644 --- a/crates/pm/src/util/config.rs +++ b/crates/pm/src/util/config.rs @@ -1,13 +1,10 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fmt::Debug; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::{LazyLock, OnceLock}; - -use super::registry::{REGISTRY_NPMMIRROR, select_fastest_registry}; -use super::save_type::OmitType; +use std::sync::OnceLock; pub type ConfigResult = Result; @@ -54,7 +51,7 @@ impl Config { self.save(global) } - async fn load_from_path(path: &Path) -> ConfigResult { + pub(crate) async fn load_from_path(path: &Path) -> ConfigResult { if !crate::fs::try_exists(path).await? { return Ok(Config::default()); } @@ -111,18 +108,18 @@ impl Config { } // Configuration value parser for different types -trait ConfigValueParser { +pub(super) trait ConfigValueParser { fn parse_config_value(&self, value: &str) -> T; } -struct ConfigValue { +pub(super) struct ConfigValue { value: OnceLock, key: &'static str, default: T, } impl ConfigValue { - const fn new(key: &'static str, default: T) -> Self { + pub(super) const fn new(key: &'static str, default: T) -> Self { Self { value: OnceLock::new(), key, @@ -130,14 +127,14 @@ impl ConfigValue { } } - fn set(&self, new_value: Option) { + pub(super) fn set(&self, new_value: Option) { if let Some(value) = new_value { tracing::debug!("set {}: {:?}", self.key, value); let _ = self.value.set(value); } } - async fn get(&self) -> T + pub(super) async fn get(&self) -> T where Self: ConfigValueParser, { @@ -145,7 +142,7 @@ impl ConfigValue { return value.clone(); } - // load from config - refactored for better readability + // load from config let config_result = Config::load(false).await; if let Ok(config) = config_result { let value_result = config.get(self.key); @@ -159,7 +156,7 @@ impl ConfigValue { self.default.clone() } - fn get_sync(&self) -> T + pub(super) fn get_sync(&self) -> T where Self: ConfigValueParser, { @@ -167,7 +164,6 @@ impl ConfigValue { return value.clone(); } - // If not set, return the default value self.default.clone() } } @@ -189,273 +185,3 @@ impl ConfigValueParser for ConfigValue { value.parse().unwrap_or(self.default) } } - -static REGISTRY: LazyLock> = - LazyLock::new(|| ConfigValue::new("registry", REGISTRY_NPMMIRROR.to_string())); - -static LEGACY_PEER_DEPS: LazyLock> = - LazyLock::new(|| ConfigValue::new("legacy-peer-deps", true)); - -static CACHE_DIR: LazyLock> = LazyLock::new(|| { - let default_cache = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".cache/nm") - .to_string_lossy() - .to_string(); - ConfigValue::new("cache-dir", default_cache) -}); - -pub async fn set_registry(registry: Option) { - // Priority: CLI argument > UTOO_REGISTRY env > config > auto-select - let final_registry = if let Some(reg) = registry { - tracing::debug!("Using registry from CLI: {}", reg); - Some(reg) - } else if let Ok(env_reg) = std::env::var("UTOO_REGISTRY") - && !env_reg.is_empty() - { - tracing::debug!("Using registry from env: {}", env_reg); - Some(env_reg) - } else { - let config_registry = Config::load(false) - .await - .ok() - .and_then(|c| c.get("registry").ok().flatten()); - - if let Some(ref reg) = config_registry { - tracing::debug!("Using registry from config: {}", reg); - config_registry - } else { - // No config, auto-select fastest registry - Some(select_fastest_registry().await) - } - }; - - REGISTRY.set(final_registry); - - // Auto-detect semver support for the selected registry - let registry_url = get_registry(); - let supports = detect_supports_semver(®istry_url).await; - let _ = SUPPORTS_SEMVER.set(supports); -} - -pub fn get_registry() -> String { - REGISTRY.get_sync() -} - -pub fn set_legacy_peer_deps(value: Option) { - LEGACY_PEER_DEPS.set(value); -} - -pub async fn get_legacy_peer_deps() -> bool { - LEGACY_PEER_DEPS.get().await -} - -static OMIT: OnceLock> = OnceLock::new(); - -pub fn set_omit(value: HashSet) { - if !value.is_empty() { - tracing::debug!("set omit: {:?}", value); - let _ = OMIT.set(value); - } -} - -pub fn get_omit() -> HashSet { - OMIT.get().cloned().unwrap_or_default() -} - -// Manifest fetch concurrency configuration -static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); - -pub fn set_manifests_concurrency_limit(value: Option) { - MANIFESTS_CONCURRENCY_LIMIT.set(value); -} - -pub async fn get_manifests_concurrency_limit() -> usize { - MANIFESTS_CONCURRENCY_LIMIT.get().await -} - -pub fn get_manifests_concurrency_limit_sync() -> usize { - MANIFESTS_CONCURRENCY_LIMIT.get_sync() -} - -pub async fn set_cache_dir(cache_dir: Option) { - // Priority: CLI argument > UTOO_CACHE_DIR env > config > default - let final_cache_dir = if let Some(dir) = cache_dir { - Some(dir) - } else if let Ok(env_dir) = std::env::var("UTOO_CACHE_DIR") - && !env_dir.is_empty() - { - Some(env_dir) - } else { - // Read from config file if no CLI arg or env var - Config::load(false) - .await - .ok() - .and_then(|config| config.get("cache-dir").ok().flatten()) - }; - - CACHE_DIR.set(final_cache_dir); -} - -pub fn get_cache_dir() -> PathBuf { - PathBuf::from(CACHE_DIR.get_sync()) -} - -// Semver support detection and caching -// -// Config key `supports-semver` is a TOML array of probe results. Each entry -// is the registry URL (positive → supports semver) or prefixed with "!" -// (negative → does NOT support): -// -// [arrays] -// supports-semver = [ -// "https://registry.npmmirror.com", -// "!https://some-private.registry", -// ] -// -// Multiple registries are cached simultaneously, so switching between them -// (e.g. `ut install --registry=xxx`) won't trigger a re-probe. -static SUPPORTS_SEMVER: OnceLock = OnceLock::new(); - -/// Probe a registry to detect whether it supports semver resolution. -/// -/// 1. `is_npm_registry(url)` → return `false` immediately (known not to support) -/// 2. Search `arrays.supports-semver` for `"{url}"` (true) or `"!{url}"` (false) -/// 3. On cache miss, probe `GET $REGISTRY/utoo/%5E1` (5 s timeout) -/// 4. Append the result to the array for future runs -pub async fn detect_supports_semver(registry_url: &str) -> bool { - use utoo_ruborist::registry::is_npm_registry; - - // Known: npm registry does not support semver queries - if is_npm_registry(registry_url) { - tracing::debug!("npm registry detected, skipping semver probe"); - return false; - } - - // Check config cache (TOML array of "url" / "!url" entries) - let mut config = Config::load(true).await.unwrap_or_default(); - let cached = config.get_array("supports-semver"); - - if let Some(entries) = cached { - for entry in entries { - if entry == registry_url { - tracing::debug!("Cache hit: supports-semver=true for {}", registry_url); - return true; - } - if entry.strip_prefix('!') == Some(registry_url) { - tracing::debug!("Cache hit: supports-semver=false for {}", registry_url); - return false; - } - } - } - - // Probe the registry - let probe_url = format!("{}/utoo/%5E1", registry_url.trim_end_matches('/')); - tracing::debug!("Probing semver support: {}", probe_url); - - let supports = async { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build()?; - let resp = client - .get(&probe_url) - .header("Accept", "application/vnd.npm.install-v1+json") - .send() - .await?; - tracing::debug!("Semver probe response: {} for {}", resp.status(), probe_url); - Ok::(resp.status().is_success()) - } - .await - .unwrap_or_else(|e: reqwest::Error| { - tracing::debug!("Semver probe failed: {}", e); - false - }); - - // Append result to the cached array - let new_entry = if supports { - registry_url.to_string() - } else { - format!("!{}", registry_url) - }; - let mut entries = config - .get_array("supports-semver") - .cloned() - .unwrap_or_default(); - entries.push(new_entry); - let _ = config.set_array("supports-semver", entries, true); - - tracing::debug!( - "Detected supports-semver={} for {}", - supports, - registry_url - ); - supports -} - -pub fn get_supports_semver() -> Option { - SUPPORTS_SEMVER.get().copied() -} - -#[cfg(test)] -mod cache_dir_tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_cache_dir_default() { - // Test default cache directory - let default_cache = dirs::home_dir() - .unwrap() - .join(".cache/nm") - .to_string_lossy() - .to_string(); - - // Create a fresh ConfigValue to simulate first access - let cache_config = ConfigValue::new("cache-dir", default_cache.clone()); - let result = cache_config.get_sync(); - - assert_eq!(result, default_cache); - } - - #[test] - fn test_config_value_parser_string() { - let config = ConfigValue::new("test-key", "default-value".to_string()); - let parsed = config.parse_config_value("custom-value"); - assert_eq!(parsed, "custom-value"); - } - - #[test] - fn test_config_value_parser_bool() { - let config = ConfigValue::new("test-bool", false); - assert!(config.parse_config_value("true")); - assert!(config.parse_config_value("TRUE")); - assert!(config.parse_config_value("True")); - assert!(!config.parse_config_value("false")); - assert!(!config.parse_config_value("anything")); - } - - #[tokio::test] - async fn test_cache_dir_from_config_file() -> Result<()> { - let temp_dir = TempDir::new()?; - let config_path = temp_dir.path().join("config.toml"); - - // Create a config file with cache-dir setting - let custom_path = "/tmp/config-cache-test"; - let config_content = format!( - r#" -[values] -cache-dir = "{}" -"#, - custom_path - ); - std::fs::write(&config_path, config_content)?; - - // Load config from file - let config = Config::load_from_path(&config_path).await?; - let cache_dir = config.get("cache-dir")?.unwrap(); - - assert_eq!(cache_dir, custom_path); - Ok(()) - } -} diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index e96216db50..d29b14e67f 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -10,7 +10,7 @@ use tokio::sync::Semaphore; use tokio_retry::RetryIf; use super::cache::get_cache_dir; -use super::config::get_manifests_concurrency_limit_sync; +use super::settings::get_manifests_concurrency_limit_sync; use super::extractor::extract_and_write; use super::oncemap::OnceMap; use super::retry::{RetryableError, build_dns_cached_client, create_retry_strategy}; diff --git a/crates/pm/src/util/mod.rs b/crates/pm/src/util/mod.rs index d512e4b4ff..3ab492c0ac 100644 --- a/crates/pm/src/util/mod.rs +++ b/crates/pm/src/util/mod.rs @@ -4,6 +4,7 @@ pub mod cloner; pub mod config; pub mod downloader; pub mod extractor; +pub mod settings; pub mod format_print; pub mod json; pub mod linker; diff --git a/crates/pm/src/util/settings.rs b/crates/pm/src/util/settings.rs new file mode 100644 index 0000000000..f1018ab518 --- /dev/null +++ b/crates/pm/src/util/settings.rs @@ -0,0 +1,276 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{LazyLock, OnceLock}; + +use super::config::{Config, ConfigValue}; +use super::registry::{REGISTRY_NPMMIRROR, select_fastest_registry}; +use super::save_type::OmitType; + +static REGISTRY: LazyLock> = + LazyLock::new(|| ConfigValue::new("registry", REGISTRY_NPMMIRROR.to_string())); + +static LEGACY_PEER_DEPS: LazyLock> = + LazyLock::new(|| ConfigValue::new("legacy-peer-deps", true)); + +static CACHE_DIR: LazyLock> = LazyLock::new(|| { + let default_cache = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cache/nm") + .to_string_lossy() + .to_string(); + ConfigValue::new("cache-dir", default_cache) +}); + +pub async fn set_registry(registry: Option) { + // Priority: CLI argument > UTOO_REGISTRY env > config > auto-select + let final_registry = if let Some(reg) = registry { + tracing::debug!("Using registry from CLI: {}", reg); + Some(reg) + } else if let Ok(env_reg) = std::env::var("UTOO_REGISTRY") + && !env_reg.is_empty() + { + tracing::debug!("Using registry from env: {}", env_reg); + Some(env_reg) + } else { + let config_registry = Config::load(false) + .await + .ok() + .and_then(|c| c.get("registry").ok().flatten()); + + if let Some(ref reg) = config_registry { + tracing::debug!("Using registry from config: {}", reg); + config_registry + } else { + // No config, auto-select fastest registry + Some(select_fastest_registry().await) + } + }; + + REGISTRY.set(final_registry); + + // Auto-detect semver support for the selected registry + let registry_url = get_registry(); + let supports = detect_supports_semver(®istry_url).await; + let _ = SUPPORTS_SEMVER.set(supports); +} + +pub fn get_registry() -> String { + REGISTRY.get_sync() +} + +pub fn set_legacy_peer_deps(value: Option) { + LEGACY_PEER_DEPS.set(value); +} + +pub async fn get_legacy_peer_deps() -> bool { + LEGACY_PEER_DEPS.get().await +} + +static OMIT: OnceLock> = OnceLock::new(); + +pub fn set_omit(value: HashSet) { + if !value.is_empty() { + tracing::debug!("set omit: {:?}", value); + let _ = OMIT.set(value); + } +} + +pub fn get_omit() -> HashSet { + OMIT.get().cloned().unwrap_or_default() +} + +// Manifest fetch concurrency configuration +static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); + +pub fn set_manifests_concurrency_limit(value: Option) { + MANIFESTS_CONCURRENCY_LIMIT.set(value); +} + +pub async fn get_manifests_concurrency_limit() -> usize { + MANIFESTS_CONCURRENCY_LIMIT.get().await +} + +pub fn get_manifests_concurrency_limit_sync() -> usize { + MANIFESTS_CONCURRENCY_LIMIT.get_sync() +} + +pub async fn set_cache_dir(cache_dir: Option) { + // Priority: CLI argument > UTOO_CACHE_DIR env > config > default + let final_cache_dir = if let Some(dir) = cache_dir { + Some(dir) + } else if let Ok(env_dir) = std::env::var("UTOO_CACHE_DIR") + && !env_dir.is_empty() + { + Some(env_dir) + } else { + // Read from config file if no CLI arg or env var + Config::load(false) + .await + .ok() + .and_then(|config| config.get("cache-dir").ok().flatten()) + }; + + CACHE_DIR.set(final_cache_dir); +} + +pub fn get_cache_dir() -> PathBuf { + PathBuf::from(CACHE_DIR.get_sync()) +} + +// Semver support detection and caching +// +// Config key `supports-semver` is a TOML array of probe results. Each entry +// is the registry URL (positive → supports semver) or prefixed with "!" +// (negative → does NOT support): +// +// [arrays] +// supports-semver = [ +// "https://registry.npmmirror.com", +// "!https://some-private.registry", +// ] +// +// Multiple registries are cached simultaneously, so switching between them +// (e.g. `ut install --registry=xxx`) won't trigger a re-probe. +static SUPPORTS_SEMVER: OnceLock = OnceLock::new(); + +/// Probe a registry to detect whether it supports semver resolution. +/// +/// 1. `is_npm_registry(url)` → return `false` immediately (known not to support) +/// 2. Search `arrays.supports-semver` for `"{url}"` (true) or `"!{url}"` (false) +/// 3. On cache miss, probe `GET $REGISTRY/utoo/%5E1` (5 s timeout) +/// 4. Append the result to the array for future runs +pub async fn detect_supports_semver(registry_url: &str) -> bool { + use utoo_ruborist::registry::is_npm_registry; + + // Known: npm registry does not support semver queries + if is_npm_registry(registry_url) { + tracing::debug!("npm registry detected, skipping semver probe"); + return false; + } + + // Check config cache (TOML array of "url" / "!url" entries) + let mut config = Config::load(true).await.unwrap_or_default(); + let cached = config.get_array("supports-semver"); + + if let Some(entries) = cached { + for entry in entries { + if entry == registry_url { + tracing::debug!("Cache hit: supports-semver=true for {}", registry_url); + return true; + } + if entry.strip_prefix('!') == Some(registry_url) { + tracing::debug!("Cache hit: supports-semver=false for {}", registry_url); + return false; + } + } + } + + // Probe the registry + let probe_url = format!("{}/utoo/%5E1", registry_url.trim_end_matches('/')); + tracing::debug!("Probing semver support: {}", probe_url); + + let supports = async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + let resp = client + .get(&probe_url) + .header("Accept", "application/vnd.npm.install-v1+json") + .send() + .await?; + tracing::debug!("Semver probe response: {} for {}", resp.status(), probe_url); + Ok::(resp.status().is_success()) + } + .await + .unwrap_or_else(|e: reqwest::Error| { + tracing::debug!("Semver probe failed: {}", e); + false + }); + + // Append result to the cached array + let new_entry = if supports { + registry_url.to_string() + } else { + format!("!{}", registry_url) + }; + let mut entries = config + .get_array("supports-semver") + .cloned() + .unwrap_or_default(); + entries.push(new_entry); + let _ = config.set_array("supports-semver", entries, true); + + tracing::debug!( + "Detected supports-semver={} for {}", + supports, + registry_url + ); + supports +} + +pub fn get_supports_semver() -> Option { + SUPPORTS_SEMVER.get().copied() +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use tempfile::TempDir; + + use super::super::config::ConfigValueParser; + + #[test] + fn test_cache_dir_default() { + let default_cache = dirs::home_dir() + .unwrap() + .join(".cache/nm") + .to_string_lossy() + .to_string(); + + let cache_config = ConfigValue::new("cache-dir", default_cache.clone()); + let result = cache_config.get_sync(); + + assert_eq!(result, default_cache); + } + + #[test] + fn test_config_value_parser_string() { + let config = ConfigValue::new("test-key", "default-value".to_string()); + let parsed = config.parse_config_value("custom-value"); + assert_eq!(parsed, "custom-value"); + } + + #[test] + fn test_config_value_parser_bool() { + let config = ConfigValue::new("test-bool", false); + assert!(config.parse_config_value("true")); + assert!(config.parse_config_value("TRUE")); + assert!(config.parse_config_value("True")); + assert!(!config.parse_config_value("false")); + assert!(!config.parse_config_value("anything")); + } + + #[tokio::test] + async fn test_cache_dir_from_config_file() -> Result<()> { + let temp_dir = TempDir::new()?; + let config_path = temp_dir.path().join("config.toml"); + + let custom_path = "/tmp/config-cache-test"; + let config_content = format!( + r#" +[values] +cache-dir = "{}" +"#, + custom_path + ); + std::fs::write(&config_path, config_content)?; + + let config = Config::load_from_path(&config_path).await?; + let cache_dir = config.get("cache-dir")?.unwrap(); + + assert_eq!(cache_dir, custom_path); + Ok(()) + } +} diff --git a/next.js b/next.js index 1e570f0320..73f4291249 160000 --- a/next.js +++ b/next.js @@ -1 +1 @@ -Subproject commit 1e570f0320190f12b0c67c2f7155014fc77d5c1f +Subproject commit 73f42912493c9cdad8968abe6981080c07679729 From 128e2b3847ad9ddc424982dea459c4d77db3f3d9 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 12 Feb 2026 23:06:03 +0800 Subject: [PATCH 4/7] refactor: user config --- crates/pm/src/cmd/config.rs | 2 +- crates/pm/src/cmd/install.rs | 2 +- crates/pm/src/cmd/ping.rs | 58 ++++++++----------- crates/pm/src/cmd/view.rs | 2 +- crates/pm/src/helper/auto_update.rs | 2 +- crates/pm/src/helper/lock.rs | 2 +- crates/pm/src/helper/ruborist_context.rs | 4 +- crates/pm/src/helper/tree_builder.rs | 2 +- crates/pm/src/main.rs | 10 ++-- crates/pm/src/service/binary.rs | 2 +- crates/pm/src/service/config.rs | 2 +- crates/pm/src/util/cache.rs | 2 +- .../pm/src/util/{config.rs => config_file.rs} | 2 +- crates/pm/src/util/downloader.rs | 2 +- crates/pm/src/util/mod.rs | 4 +- crates/pm/src/util/registry.rs | 10 ++-- .../src/util/{settings.rs => user_config.rs} | 14 ++--- 17 files changed, 54 insertions(+), 68 deletions(-) rename crates/pm/src/util/{config.rs => config_file.rs} (99%) rename crates/pm/src/util/{settings.rs => user_config.rs} (96%) diff --git a/crates/pm/src/cmd/config.rs b/crates/pm/src/cmd/config.rs index 7c6b104618..90da4cd8c1 100644 --- a/crates/pm/src/cmd/config.rs +++ b/crates/pm/src/cmd/config.rs @@ -1,4 +1,4 @@ -use crate::util::config::Config; +use crate::util::config_file::Config; use anyhow::{Result, anyhow}; use std::collections::HashMap; diff --git a/crates/pm/src/cmd/install.rs b/crates/pm/src/cmd/install.rs index c0bc3faf71..15cc000256 100644 --- a/crates/pm/src/cmd/install.rs +++ b/crates/pm/src/cmd/install.rs @@ -2,8 +2,8 @@ use anyhow::Result; use std::path::Path; use crate::service::install::InstallService; -use crate::util::settings::get_omit; use crate::util::save_type::{PackageAction, SaveType}; +use crate::util::user_config::get_omit; pub async fn update_packages( action: PackageAction, diff --git a/crates/pm/src/cmd/ping.rs b/crates/pm/src/cmd/ping.rs index f0694f1b91..1dfd2326af 100644 --- a/crates/pm/src/cmd/ping.rs +++ b/crates/pm/src/cmd/ping.rs @@ -1,12 +1,11 @@ use anyhow::Result; use colored::Colorize; -use crate::util::settings::{detect_supports_semver, get_registry}; +use crate::util::registry::ping_registry; +use crate::util::user_config::{detect_supports_semver, get_registry}; pub async fn ping(registry: Option<&str>) -> Result<()> { - let registry = registry - .map(String::from) - .unwrap_or_else(get_registry); + let registry = registry.map(String::from).unwrap_or_else(get_registry); println!("{} {}", "PING".green(), registry.cyan()); @@ -14,38 +13,27 @@ pub async fn ping(registry: Option<&str>) -> Result<()> { .timeout(std::time::Duration::from_secs(10)) .build()?; - let ping_url = format!("{}/-/ping", registry); - let start = std::time::Instant::now(); + let result = ping_registry(&client, ®istry).await; - match client.get(&ping_url).send().await { - Ok(resp) => { - let latency = start.elapsed().as_millis(); - if resp.status().is_success() { - let supports = detect_supports_semver(®istry).await; - let semver_info = if supports { - "supports-semver: yes".green() - } else { - "supports-semver: no".yellow() - }; - println!( - "{} {}ms ({})", - "PONG".green(), - latency.to_string().cyan(), - semver_info - ); - } else { - println!( - "{} HTTP {} ({}ms)", - "FAIL".red(), - resp.status(), - latency - ); - } - } - Err(e) => { - let latency = start.elapsed().as_millis(); - println!("{} {} ({}ms)", "FAIL".red(), e, latency); - } + if result.success { + let supports = detect_supports_semver(®istry).await; + let semver_info = if supports { + "supports-semver: yes".green() + } else { + "supports-semver: no".yellow() + }; + println!( + "{} {}ms ({})", + "PONG".green(), + result.latency_ms.to_string().cyan(), + semver_info + ); + } else { + println!( + "{} registry did not respond ({}ms)", + "FAIL".red(), + result.latency_ms + ); } Ok(()) diff --git a/crates/pm/src/cmd/view.rs b/crates/pm/src/cmd/view.rs index 7a0f0e05c1..d28588aa0d 100644 --- a/crates/pm/src/cmd/view.rs +++ b/crates/pm/src/cmd/view.rs @@ -7,8 +7,8 @@ use utoo_ruborist::service::fetch_full_manifest; use utoo_ruborist::util::parse_package_spec; use crate::helper::ruborist_context::Context; -use crate::util::settings::get_registry; use crate::util::format_print::print_grid; +use crate::util::user_config::get_registry; /// View package information from registry, similar to npm view pub async fn view(package_spec: &str) -> Result<()> { diff --git a/crates/pm/src/helper/auto_update.rs b/crates/pm/src/helper/auto_update.rs index 4cdbcb6d06..a21da1118a 100644 --- a/crates/pm/src/helper/auto_update.rs +++ b/crates/pm/src/helper/auto_update.rs @@ -1,5 +1,5 @@ use crate::constants::APP_VERSION; -use crate::util::settings::get_registry; +use crate::util::user_config::get_registry; use anyhow::{Context, Result}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; diff --git a/crates/pm/src/helper/lock.rs b/crates/pm/src/helper/lock.rs index 02dc40244a..3ac2ea3bbf 100644 --- a/crates/pm/src/helper/lock.rs +++ b/crates/pm/src/helper/lock.rs @@ -5,10 +5,10 @@ use std::path::{Path, PathBuf}; use super::ruborist_context::Context; use crate::helper::workspace::find_workspaces; -use crate::util::settings::get_legacy_peer_deps; use crate::util::json::{load_package_json_from_path, load_package_lock_json_from_path}; use crate::util::logger::{finish_progress_bar, start_progress_bar}; use crate::util::save_type::{PackageAction, SaveType}; +use crate::util::user_config::get_legacy_peer_deps; use crate::util::{cloner::clone_package, downloader::download_to_cache}; use utoo_ruborist::lock::{LockPackage, PackageLock}; use utoo_ruborist::manifest::PackageJson; diff --git a/crates/pm/src/helper/ruborist_context.rs b/crates/pm/src/helper/ruborist_context.rs index b4ade8e515..5099224a36 100644 --- a/crates/pm/src/helper/ruborist_context.rs +++ b/crates/pm/src/helper/ruborist_context.rs @@ -5,10 +5,10 @@ use utoo_ruborist::service::{BuildDepsOptions, Glob, UnifiedRegistry}; use crate::service::pipeline::{PipelineChannels, PipelineReceiver}; use crate::util::cache::get_cache_dir; -use crate::util::settings::{ +use crate::util::logger::ProgressReceiver; +use crate::util::user_config::{ get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry, get_supports_semver, }; -use crate::util::logger::ProgressReceiver; /// Tokio-based glob implementation. #[derive(Debug, Clone, Copy, Default)] diff --git a/crates/pm/src/helper/tree_builder.rs b/crates/pm/src/helper/tree_builder.rs index 29241f3ca2..dbdb140c83 100644 --- a/crates/pm/src/helper/tree_builder.rs +++ b/crates/pm/src/helper/tree_builder.rs @@ -13,9 +13,9 @@ use utoo_ruborist::manifest::PackageJson; use crate::helper::install_runtime::install_runtime; use crate::helper::workspace::find_workspaces; -use crate::util::settings::get_legacy_peer_deps; use crate::util::json::load_package_json_from_path; use crate::util::logger::{finish_progress_bar, start_progress_bar}; +use crate::util::user_config::get_legacy_peer_deps; /// TreeBuilder - builds workspace dependency graph. /// diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 4fbd01ffa1..4c11c54c34 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -15,11 +15,11 @@ use cmd::update::update; use cmd::view::view; use cmd::{clean::clean, deps::build_workspace}; use helper::auto_update::init_auto_update; -use util::settings::{ - set_cache_dir, set_legacy_peer_deps, set_manifests_concurrency_limit, set_omit, set_registry, -}; use util::logger::{get_log_file_path, init_tracing, log_time, log_time_end}; use util::save_type::{OmitType, PackageAction, SaveType, parse_save_type}; +use util::user_config::{ + set_cache_dir, set_legacy_peer_deps, set_manifests_concurrency_limit, set_omit, set_registry, +}; mod cmd; mod constants; @@ -299,7 +299,7 @@ async fn async_main() -> Result<()> { // Check for help flag if args.len() > 1 && (args[1] == "-h" || args[1] == "--help") { - let config = crate::util::config::Config::load(false).await?; + let config = crate::util::config_file::Config::load(false).await?; let config_service = crate::service::config::ConfigService::new(config); config_service.print_help()?; return Ok(()); @@ -520,7 +520,7 @@ async fn async_main() -> Result<()> { // Check if there's a script name provided if let Some(script_name) = &cli.script_name { // First check if there's a custom command configured for this script name - let config = crate::util::config::Config::load(false).await?; + let config = crate::util::config_file::Config::load(false).await?; let config_service = crate::service::config::ConfigService::new(config); // Check if there's a custom command available if let Ok(Some(_)) = config_service.get_available_cmd(script_name) { diff --git a/crates/pm/src/service/binary.rs b/crates/pm/src/service/binary.rs index 9459db6fd4..28b1228e09 100644 --- a/crates/pm/src/service/binary.rs +++ b/crates/pm/src/service/binary.rs @@ -1,6 +1,6 @@ use crate::fs; -use crate::util::settings::get_registry; use crate::util::json::load_package_json_from_path; +use crate::util::user_config::get_registry; use anyhow::{Context, Result}; use regex::Regex; use serde_json::{Map, Value}; diff --git a/crates/pm/src/service/config.rs b/crates/pm/src/service/config.rs index b69d0d90d1..092a896cf8 100644 --- a/crates/pm/src/service/config.rs +++ b/crates/pm/src/service/config.rs @@ -1,5 +1,5 @@ use crate::constants::cmd; -use crate::util::config::{Config, ConfigResult}; +use crate::util::config_file::{Config, ConfigResult}; use anyhow::anyhow; use colored::*; use std::cmp::max; diff --git a/crates/pm/src/util/cache.rs b/crates/pm/src/util/cache.rs index 265efce9b9..7628ab30e1 100644 --- a/crates/pm/src/util/cache.rs +++ b/crates/pm/src/util/cache.rs @@ -1,6 +1,6 @@ use crate::fs; -pub use super::settings::get_cache_dir; +pub use super::user_config::get_cache_dir; pub fn matches_pattern(text: &str, pattern: &str) -> bool { if pattern == "*" { diff --git a/crates/pm/src/util/config.rs b/crates/pm/src/util/config_file.rs similarity index 99% rename from crates/pm/src/util/config.rs rename to crates/pm/src/util/config_file.rs index a5549f91cf..1398aa4011 100644 --- a/crates/pm/src/util/config.rs +++ b/crates/pm/src/util/config_file.rs @@ -16,7 +16,7 @@ pub struct Config { } // global config path is ~/.utoo/config.toml -// local config path is .utoo/config.toml +// local config path is .utoo.toml impl Config { pub async fn load(global: bool) -> ConfigResult { if global { diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index d29b14e67f..5f85b7c066 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -10,10 +10,10 @@ use tokio::sync::Semaphore; use tokio_retry::RetryIf; use super::cache::get_cache_dir; -use super::settings::get_manifests_concurrency_limit_sync; use super::extractor::extract_and_write; use super::oncemap::OnceMap; use super::retry::{RetryableError, build_dns_cached_client, create_retry_strategy}; +use super::user_config::get_manifests_concurrency_limit_sync; // Global downloader client - no pool limit, concurrency controlled by OnceMap static DOWNLOADER_CLIENT: Lazy = Lazy::new(build_dns_cached_client); diff --git a/crates/pm/src/util/mod.rs b/crates/pm/src/util/mod.rs index 3ab492c0ac..ed3325ce72 100644 --- a/crates/pm/src/util/mod.rs +++ b/crates/pm/src/util/mod.rs @@ -1,10 +1,9 @@ pub mod binary_resolver; pub mod cache; pub mod cloner; -pub mod config; +pub mod config_file; pub mod downloader; pub mod extractor; -pub mod settings; pub mod format_print; pub mod json; pub mod linker; @@ -13,3 +12,4 @@ pub mod oncemap; pub mod registry; pub mod retry; pub mod save_type; +pub mod user_config; diff --git a/crates/pm/src/util/registry.rs b/crates/pm/src/util/registry.rs index 682fe8ae5e..21da10527c 100644 --- a/crates/pm/src/util/registry.rs +++ b/crates/pm/src/util/registry.rs @@ -12,14 +12,14 @@ pub const REGISTRY_NPMJS: &str = "https://registry.npmjs.org"; const PING_TIMEOUT_MS: u64 = 3000; /// Ping result structure -struct PingResult { - registry: String, - latency_ms: u64, - success: bool, +pub struct PingResult { + pub registry: String, + pub latency_ms: u64, + pub success: bool, } /// Ping a registry and measure latency -async fn ping_registry(client: &reqwest::Client, registry_url: &str) -> PingResult { +pub async fn ping_registry(client: &reqwest::Client, registry_url: &str) -> PingResult { let ping_url = format!("{}/-/ping", registry_url); let start = std::time::Instant::now(); diff --git a/crates/pm/src/util/settings.rs b/crates/pm/src/util/user_config.rs similarity index 96% rename from crates/pm/src/util/settings.rs rename to crates/pm/src/util/user_config.rs index f1018ab518..0a8775e0a6 100644 --- a/crates/pm/src/util/settings.rs +++ b/crates/pm/src/util/user_config.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::{LazyLock, OnceLock}; -use super::config::{Config, ConfigValue}; +use super::config_file::{Config, ConfigValue}; use super::registry::{REGISTRY_NPMMIRROR, select_fastest_registry}; use super::save_type::OmitType; @@ -199,13 +199,11 @@ pub async fn detect_supports_semver(registry_url: &str) -> bool { .cloned() .unwrap_or_default(); entries.push(new_entry); - let _ = config.set_array("supports-semver", entries, true); + if let Err(e) = config.set_array("supports-semver", entries, true) { + tracing::warn!("Failed to save semver support to config: {}", e); + } - tracing::debug!( - "Detected supports-semver={} for {}", - supports, - registry_url - ); + tracing::debug!("Detected supports-semver={} for {}", supports, registry_url); supports } @@ -219,7 +217,7 @@ mod tests { use anyhow::Result; use tempfile::TempDir; - use super::super::config::ConfigValueParser; + use super::super::config_file::ConfigValueParser; #[test] fn test_cache_dir_default() { From 8815180f71b4688b83e5e0d5fc9beb8c73a42b63 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Mon, 16 Feb 2026 23:03:51 +0800 Subject: [PATCH 5/7] chore: clean lock --- Cargo.lock | 1 - next.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1d2059abe..d6278c391d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8857,7 +8857,6 @@ dependencies = [ "serde_json", "shrink-to-fit", "smallvec", - "thiserror 1.0.69", "tokio", "tokio-util", "tracing", diff --git a/next.js b/next.js index 73f4291249..1e570f0320 160000 --- a/next.js +++ b/next.js @@ -1 +1 @@ -Subproject commit 73f42912493c9cdad8968abe6981080c07679729 +Subproject commit 1e570f0320190f12b0c67c2f7155014fc77d5c1f From c32825f0ecbe99c04b5a04e2b3e05cf260917dec Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 18 Feb 2026 23:22:38 +0800 Subject: [PATCH 6/7] fix: ci --- crates/pm/src/cmd/config.rs | 2 +- crates/pm/src/cmd/ping.rs | 2 +- crates/pm/src/util/user_config.rs | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/pm/src/cmd/config.rs b/crates/pm/src/cmd/config.rs index 90da4cd8c1..7a4e9666c9 100644 --- a/crates/pm/src/cmd/config.rs +++ b/crates/pm/src/cmd/config.rs @@ -57,7 +57,7 @@ pub async fn handle_config_list(global: bool) -> Result<()> { println!("{key} = {value}"); } for (key, values) in config.list_arrays() { - println!("{key} = {:?}", values); + println!("{key} = [{}]", values.join(", ")); } Ok(()) } diff --git a/crates/pm/src/cmd/ping.rs b/crates/pm/src/cmd/ping.rs index 1dfd2326af..b4d62396b7 100644 --- a/crates/pm/src/cmd/ping.rs +++ b/crates/pm/src/cmd/ping.rs @@ -29,7 +29,7 @@ pub async fn ping(registry: Option<&str>) -> Result<()> { semver_info ); } else { - println!( + anyhow::bail!( "{} registry did not respond ({}ms)", "FAIL".red(), result.latency_ms diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 0a8775e0a6..fdc0ebea6e 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -188,7 +188,7 @@ pub async fn detect_supports_semver(registry_url: &str) -> bool { false }); - // Append result to the cached array + // Update (or append) result in the cached array, deduplicating by registry URL let new_entry = if supports { registry_url.to_string() } else { @@ -198,6 +198,11 @@ pub async fn detect_supports_semver(registry_url: &str) -> bool { .get_array("supports-semver") .cloned() .unwrap_or_default(); + // Remove any existing entry for this registry (both "url" and "!url" forms) + entries.retain(|e| { + let url = e.strip_prefix('!').unwrap_or(e); + url != registry_url + }); entries.push(new_entry); if let Err(e) = config.set_array("supports-semver", entries, true) { tracing::warn!("Failed to save semver support to config: {}", e); From 2bc6f813b80d8540588687e3ed917484ca735f2f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 18 Feb 2026 23:43:02 +0800 Subject: [PATCH 7/7] refactor: set_registry => init_registry --- crates/pm/src/cmd/ping.rs | 2 +- crates/pm/src/main.rs | 4 ++-- crates/pm/src/util/user_config.rs | 27 +++++++++++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/pm/src/cmd/ping.rs b/crates/pm/src/cmd/ping.rs index b4d62396b7..c382b437f8 100644 --- a/crates/pm/src/cmd/ping.rs +++ b/crates/pm/src/cmd/ping.rs @@ -16,7 +16,7 @@ pub async fn ping(registry: Option<&str>) -> Result<()> { let result = ping_registry(&client, ®istry).await; if result.success { - let supports = detect_supports_semver(®istry).await; + let supports = detect_supports_semver(®istry, Some(&client)).await; let semver_info = if supports { "supports-semver: yes".green() } else { diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 9e19a0242b..36f41302e7 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -18,7 +18,7 @@ use helper::auto_update::init_auto_update; use util::logger::{get_log_file_path, init_tracing, log_time, log_time_end}; use util::save_type::{OmitType, PackageAction, SaveType, parse_save_type}; use util::user_config::{ - set_cache_dir, set_legacy_peer_deps, set_manifests_concurrency_limit, set_omit, set_registry, + init_registry, set_cache_dir, set_legacy_peer_deps, set_manifests_concurrency_limit, set_omit, }; mod cmd; @@ -367,7 +367,7 @@ async fn async_main() -> Result<()> { ); // global registry - set_registry(cli.registry).await; + init_registry(cli.registry).await; // set cache directory set_cache_dir(cli.cache_dir).await; diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index fdc0ebea6e..61cee5a959 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -21,7 +21,7 @@ static CACHE_DIR: LazyLock> = LazyLock::new(|| { ConfigValue::new("cache-dir", default_cache) }); -pub async fn set_registry(registry: Option) { +pub async fn init_registry(registry: Option) { // Priority: CLI argument > UTOO_REGISTRY env > config > auto-select let final_registry = if let Some(reg) = registry { tracing::debug!("Using registry from CLI: {}", reg); @@ -50,7 +50,7 @@ pub async fn set_registry(registry: Option) { // Auto-detect semver support for the selected registry let registry_url = get_registry(); - let supports = detect_supports_semver(®istry_url).await; + let supports = detect_supports_semver(®istry_url, None).await; let _ = SUPPORTS_SEMVER.set(supports); } @@ -140,7 +140,15 @@ static SUPPORTS_SEMVER: OnceLock = OnceLock::new(); /// 2. Search `arrays.supports-semver` for `"{url}"` (true) or `"!{url}"` (false) /// 3. On cache miss, probe `GET $REGISTRY/utoo/%5E1` (5 s timeout) /// 4. Append the result to the array for future runs -pub async fn detect_supports_semver(registry_url: &str) -> bool { +/// +/// An optional `reqwest::Client` can be provided to reuse an existing client +/// (e.g. from the `ping` command). If `None`, a new client with 5s timeout is +/// created internally. +/// +/// NOTE: Config file writes are not protected by file locking. Concurrent +/// `utoo` processes may race on the global config, potentially losing entries. +/// This is a known limitation — entries will be re-probed on the next run. +pub async fn detect_supports_semver(registry_url: &str, client: Option<&reqwest::Client>) -> bool { use utoo_ruborist::registry::is_npm_registry; // Known: npm registry does not support semver queries @@ -171,9 +179,16 @@ pub async fn detect_supports_semver(registry_url: &str) -> bool { tracing::debug!("Probing semver support: {}", probe_url); let supports = async { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build()?; + let owned_client; + let client = match client { + Some(c) => c, + None => { + owned_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + &owned_client + } + }; let resp = client .get(&probe_url) .header("Accept", "application/vnd.npm.install-v1+json")