diff --git a/crates/pm/src/cmd/config.rs b/crates/pm/src/cmd/config.rs index 1b132bdd4e..7a4e9666c9 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; @@ -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.join(", ")); + } Ok(()) } diff --git a/crates/pm/src/cmd/install.rs b/crates/pm/src/cmd/install.rs index 2220bce65b..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::config::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/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..c382b437f8 --- /dev/null +++ b/crates/pm/src/cmd/ping.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use colored::Colorize; + +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); + + println!("{} {}", "PING".green(), registry.cyan()); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + let result = ping_registry(&client, ®istry).await; + + if result.success { + let supports = detect_supports_semver(®istry, Some(&client)).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 { + anyhow::bail!( + "{} registry did not respond ({}ms)", + "FAIL".red(), + result.latency_ms + ); + } + + 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/cmd/view.rs b/crates/pm/src/cmd/view.rs index 60e3233dcc..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::config::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/constants.rs b/crates/pm/src/constants.rs index 0b2cd4b66f..34cb136e5d 100644 --- a/crates/pm/src/constants.rs +++ b/crates/pm/src/constants.rs @@ -58,6 +58,10 @@ pub mod cmd { 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"; + pub const COMPLETIONS_NAME: &str = "completions"; pub const COMPLETIONS_ABOUT: &str = "Generate shell completion scripts\n\nAdd to your shell config:\n bash: echo 'eval \"$(utoo completions bash)\"' >> ~/.bashrc\n zsh: echo 'eval \"$(utoo completions zsh)\"' >> ~/.zshrc\n fish: utoo completions fish > ~/.config/fish/completions/utoo.fish"; } diff --git a/crates/pm/src/helper/auto_update.rs b/crates/pm/src/helper/auto_update.rs index 7d9dbfa486..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::config::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 8dec35cea0..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::config::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 ab7c369d78..5099224a36 100644 --- a/crates/pm/src/helper/ruborist_context.rs +++ b/crates/pm/src/helper/ruborist_context.rs @@ -5,8 +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::config::{get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry}; use crate::util::logger::ProgressReceiver; +use crate::util::user_config::{ + get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry, get_supports_semver, +}; /// Tokio-based glob implementation. #[derive(Debug, Clone, Copy, Default)] @@ -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/helper/tree_builder.rs b/crates/pm/src/helper/tree_builder.rs index 60703630da..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::config::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 744d9db9ef..36f41302e7 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::config::{ - 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::{ + init_registry, set_cache_dir, set_legacy_peer_deps, set_manifests_concurrency_limit, set_omit, +}; mod cmd; mod constants; @@ -33,10 +33,10 @@ use crate::constants::cmd::{ CLEAN_ABOUT, CLEAN_ALIAS, CLEAN_NAME, COMPLETIONS_ABOUT, COMPLETIONS_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, + LINK_ABOUT, LINK_ALIAS, LINK_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; @@ -259,6 +259,12 @@ enum Commands { prefix: Option, }, + #[command(name = PING_NAME, alias = PING_ALIAS, about = PING_ABOUT)] + Ping { + /// Registry URL to ping (defaults to configured registry) + registry: Option, + }, + #[command(name = CONFIG_NAME, alias = CONFIG_ALIAS, about = CONFIG_ABOUT)] Config { #[command(subcommand)] @@ -315,7 +321,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(()); @@ -361,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; @@ -535,6 +541,9 @@ async fn async_main() -> Result<()> { init(yes, None).await?; log_time_end("package.json created"); } + Some(Commands::Ping { registry }) => { + cmd::ping::ping(registry.as_deref()).await?; + } Some(Commands::Config { command }) => match command { ConfigCommands::Set { key, value, global } => { handle_config_set(key, value, global).await?; @@ -554,7 +563,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 7190647b68..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::config::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 97e8153468..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::config::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_file.rs b/crates/pm/src/util/config_file.rs new file mode 100644 index 0000000000..1398aa4011 --- /dev/null +++ b/crates/pm/src/util/config_file.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::Debug; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +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 +// local config path is .utoo.toml +impl Config { + pub async fn load(global: bool) -> ConfigResult { + if global { + return Self::load_from_path(&Self::global_config_path()?).await; + } + + let mut config = Self::load_from_path(&Self::global_config_path()?).await?; + let local_path = Self::local_config_path()?; + 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) + } + + pub fn set(&mut self, key: &str, value: String, global: bool) -> ConfigResult<()> { + self.values.insert(key.to_string(), value); + self.save(global) + } + + pub fn get(&self, key: &str) -> ConfigResult> { + 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) + } + + pub(crate) async fn load_from_path(path: &Path) -> ConfigResult { + if !crate::fs::try_exists(path).await? { + return Ok(Config::default()); + } + + let content = fs::read_to_string(path)?; + let config = toml::from_str(&content)?; + Ok(config) + } + + pub fn save(&self, global: bool) -> ConfigResult<()> { + let path = if global { + Self::global_config_path()? + } else { + Self::local_config_path()? + }; + + // ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + fs::write(&path, content)?; + Ok(()) + } + + fn global_config_path() -> ConfigResult { + Ok(dirs::home_dir() + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") + })? + .join(".utoo/config.toml")) + } + + fn local_config_path() -> ConfigResult { + Ok(std::env::current_dir()?.join(".utoo.toml")) + } + + pub fn get_global_config_path(&self) -> ConfigResult { + Self::global_config_path() + } + + pub fn get_local_config_path(&self) -> ConfigResult { + Self::local_config_path() + } + + 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 +pub(super) trait ConfigValueParser { + fn parse_config_value(&self, value: &str) -> T; +} + +pub(super) struct ConfigValue { + value: OnceLock, + key: &'static str, + default: T, +} + +impl ConfigValue { + pub(super) const fn new(key: &'static str, default: T) -> Self { + Self { + value: OnceLock::new(), + key, + default, + } + } + + 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); + } + } + + pub(super) async fn get(&self) -> T + where + Self: ConfigValueParser, + { + if let Some(value) = self.value.get() { + return value.clone(); + } + + // load from config + let config_result = Config::load(false).await; + if let Ok(config) = config_result { + let value_result = config.get(self.key); + if let Ok(Some(value)) = value_result { + let parsed_value = self.parse_config_value(&value); + let _ = self.value.set(parsed_value.clone()); + return parsed_value; + } + } + + self.default.clone() + } + + pub(super) fn get_sync(&self) -> T + where + Self: ConfigValueParser, + { + if let Some(value) = self.value.get() { + return value.clone(); + } + + self.default.clone() + } +} + +impl ConfigValueParser for ConfigValue { + fn parse_config_value(&self, value: &str) -> String { + value.to_string() + } +} + +impl ConfigValueParser for ConfigValue { + fn parse_config_value(&self, value: &str) -> bool { + value.to_lowercase() == "true" + } +} + +impl ConfigValueParser for ConfigValue { + fn parse_config_value(&self, value: &str) -> usize { + value.parse().unwrap_or(self.default) + } +} diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index e96216db50..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::config::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 d512e4b4ff..ed3325ce72 100644 --- a/crates/pm/src/util/mod.rs +++ b/crates/pm/src/util/mod.rs @@ -1,7 +1,7 @@ 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 format_print; @@ -12,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/config.rs b/crates/pm/src/util/user_config.rs similarity index 50% rename from crates/pm/src/util/config.rs rename to crates/pm/src/util/user_config.rs index dc99b56e4a..61cee5a959 100644 --- a/crates/pm/src/util/config.rs +++ b/crates/pm/src/util/user_config.rs @@ -1,179 +1,11 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::fs; -use std::path::{Path, PathBuf}; +use std::collections::HashSet; +use std::path::PathBuf; use std::sync::{LazyLock, OnceLock}; +use super::config_file::{Config, ConfigValue}; use super::registry::{REGISTRY_NPMMIRROR, select_fastest_registry}; use super::save_type::OmitType; -pub type ConfigResult = Result; - -#[derive(Debug, Serialize, Deserialize, Default)] -pub struct Config { - values: HashMap, -} - -// global config path is ~/.utoo/config.toml -// local config path is .utoo/config.toml -impl Config { - pub async fn load(global: bool) -> ConfigResult { - if global { - return Self::load_from_path(&Self::global_config_path()?).await; - } - - let mut config = Self::load_from_path(&Self::global_config_path()?).await?; - let local_path = Self::local_config_path()?; - if crate::fs::try_exists(&local_path).await? { - let local_config = Self::load_from_path(&local_path).await?; - config.values.extend(local_config.values); - } - Ok(config) - } - - pub fn set(&mut self, key: &str, value: String, global: bool) -> ConfigResult<()> { - self.values.insert(key.to_string(), value); - self.save(global) - } - - pub fn get(&self, key: &str) -> ConfigResult> { - Ok(self.values.get(key).cloned()) - } - - async fn load_from_path(path: &Path) -> ConfigResult { - if !crate::fs::try_exists(path).await? { - return Ok(Config::default()); - } - - let content = fs::read_to_string(path)?; - let config = toml::from_str(&content)?; - Ok(config) - } - - pub fn save(&self, global: bool) -> ConfigResult<()> { - let path = if global { - Self::global_config_path()? - } else { - Self::local_config_path()? - }; - - // ensure parent directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let content = toml::to_string_pretty(self)?; - fs::write(&path, content)?; - Ok(()) - } - - fn global_config_path() -> ConfigResult { - Ok(dirs::home_dir() - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") - })? - .join(".utoo/config.toml")) - } - - fn local_config_path() -> ConfigResult { - Ok(std::env::current_dir()?.join(".utoo.toml")) - } - - pub fn get_global_config_path(&self) -> ConfigResult { - Self::global_config_path() - } - - pub fn get_local_config_path(&self) -> ConfigResult { - Self::local_config_path() - } - - pub fn list(&self) -> ConfigResult> { - Ok(self.values.iter()) - } -} - -// Configuration value parser for different types -trait ConfigValueParser { - fn parse_config_value(&self, value: &str) -> T; -} - -struct ConfigValue { - value: OnceLock, - key: &'static str, - default: T, -} - -impl ConfigValue { - const fn new(key: &'static str, default: T) -> Self { - Self { - value: OnceLock::new(), - key, - default, - } - } - - 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 - where - Self: ConfigValueParser, - { - if let Some(value) = self.value.get() { - return value.clone(); - } - - // load from config - refactored for better readability - let config_result = Config::load(false).await; - if let Ok(config) = config_result { - let value_result = config.get(self.key); - if let Ok(Some(value)) = value_result { - let parsed_value = self.parse_config_value(&value); - let _ = self.value.set(parsed_value.clone()); - return parsed_value; - } - } - - self.default.clone() - } - - fn get_sync(&self) -> T - where - Self: ConfigValueParser, - { - if let Some(value) = self.value.get() { - return value.clone(); - } - - // If not set, return the default value - self.default.clone() - } -} - -impl ConfigValueParser for ConfigValue { - fn parse_config_value(&self, value: &str) -> String { - value.to_string() - } -} - -impl ConfigValueParser for ConfigValue { - fn parse_config_value(&self, value: &str) -> bool { - value.to_lowercase() == "true" - } -} - -impl ConfigValueParser for ConfigValue { - fn parse_config_value(&self, value: &str) -> usize { - value.parse().unwrap_or(self.default) - } -} - static REGISTRY: LazyLock> = LazyLock::new(|| ConfigValue::new("registry", REGISTRY_NPMMIRROR.to_string())); @@ -189,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); @@ -215,6 +47,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, None).await; + let _ = SUPPORTS_SEMVER.set(supports); } pub fn get_registry() -> String { @@ -281,21 +118,135 @@ 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 +/// +/// 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 + 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 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") + .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 + }); + + // Update (or append) result in the cached array, deduplicating by registry URL + 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(); + // 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); + } + + 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 { +mod tests { use super::*; + use anyhow::Result; use tempfile::TempDir; + use super::super::config_file::ConfigValueParser; + #[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(); @@ -324,7 +275,6 @@ mod cache_dir_tests { 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#" @@ -335,7 +285,6 @@ cache-dir = "{}" ); 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(); 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