From 89a965983180e298e02d498c4250a44a983e5232 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 12 Feb 2026 00:28:06 +0800 Subject: [PATCH 1/3] feat(pm): add auth and npmrc infrastructure - Add util/auth.rs for resolving registry authentication tokens - Add util/npmrc.rs for parsing .npmrc configuration files - Add service/auth.rs for web-based and legacy npm login flows - Add config delete method for logout support Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 31 ++++++ crates/pm/Cargo.toml | 1 + crates/pm/src/cmd/login.rs | 21 +++++ crates/pm/src/cmd/logout.rs | 15 +++ crates/pm/src/cmd/mod.rs | 3 + crates/pm/src/cmd/whoami.rs | 13 +++ crates/pm/src/constants.rs | 12 +++ crates/pm/src/main.rs | 28 +++++- crates/pm/src/service/auth.rs | 151 ++++++++++++++++++++++++++++++ crates/pm/src/service/mod.rs | 1 + crates/pm/src/util/config_file.rs | 5 + 11 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 crates/pm/src/cmd/login.rs create mode 100644 crates/pm/src/cmd/logout.rs create mode 100644 crates/pm/src/cmd/whoami.rs create mode 100644 crates/pm/src/service/auth.rs diff --git a/Cargo.lock b/Cargo.lock index b41856c4c4..dc9935b043 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3158,6 +3158,15 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-macro" version = "0.3.7" @@ -3170,6 +3179,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -4192,6 +4211,17 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.73" @@ -9726,6 +9756,7 @@ dependencies = [ "libdeflater", "mockito", "once_cell", + "open", "openssl", "owo-colors", "petgraph 0.6.5", diff --git a/crates/pm/Cargo.toml b/crates/pm/Cargo.toml index 2c4bed88c7..f7a3e23818 100644 --- a/crates/pm/Cargo.toml +++ b/crates/pm/Cargo.toml @@ -28,6 +28,7 @@ indicatif = "0.17.8" libc = "0.2" libdeflater = "1.25" once_cell = "1.19" +open = "5" owo-colors = { workspace = true } petgraph = "0.6" rayon = { workspace = true } diff --git a/crates/pm/src/cmd/login.rs b/crates/pm/src/cmd/login.rs new file mode 100644 index 0000000000..5828f26f11 --- /dev/null +++ b/crates/pm/src/cmd/login.rs @@ -0,0 +1,21 @@ +use anyhow::Result; +use colored::Colorize; + +use crate::service::auth; +use crate::util::user_config::get_registry; + +pub async fn login(registry_override: Option) -> Result<()> { + let registry = registry_override.unwrap_or_else(get_registry); + + println!("Login to {}", registry.cyan()); + + let token = auth::web_login(®istry, |url| { + println!("Login at: {}", url); + let _ = open::that(url); + }) + .await?; + + auth::save_token(token).await?; + println!("{}", "Logged in successfully.".green()); + Ok(()) +} diff --git a/crates/pm/src/cmd/logout.rs b/crates/pm/src/cmd/logout.rs new file mode 100644 index 0000000000..e1a6938737 --- /dev/null +++ b/crates/pm/src/cmd/logout.rs @@ -0,0 +1,15 @@ +use anyhow::Result; +use colored::Colorize; + +use crate::service::auth; +use crate::util::user_config::get_registry; + +pub async fn logout() -> Result<()> { + let registry = get_registry(); + let token = auth::require_token().await?; + + auth::logout(®istry, &token).await?; + + println!("Logged out from {}.", registry.green()); + Ok(()) +} diff --git a/crates/pm/src/cmd/mod.rs b/crates/pm/src/cmd/mod.rs index e23c480e1b..c6ab3eb017 100644 --- a/crates/pm/src/cmd/mod.rs +++ b/crates/pm/src/cmd/mod.rs @@ -6,8 +6,11 @@ pub mod init; pub mod install; pub mod link; pub mod list; +pub mod login; +pub mod logout; pub mod ping; pub mod rebuild; pub mod run; pub mod update; pub mod view; +pub mod whoami; diff --git a/crates/pm/src/cmd/whoami.rs b/crates/pm/src/cmd/whoami.rs new file mode 100644 index 0000000000..daecad5111 --- /dev/null +++ b/crates/pm/src/cmd/whoami.rs @@ -0,0 +1,13 @@ +use anyhow::Result; + +use crate::service::auth; +use crate::util::user_config::get_registry; + +pub async fn whoami() -> Result<()> { + let registry = get_registry(); + let token = auth::require_token().await?; + + let username = auth::whoami(®istry, &token).await?; + println!("{}", username); + Ok(()) +} diff --git a/crates/pm/src/constants.rs b/crates/pm/src/constants.rs index 34cb136e5d..faa494abec 100644 --- a/crates/pm/src/constants.rs +++ b/crates/pm/src/constants.rs @@ -62,6 +62,18 @@ pub mod cmd { pub const PING_ALIAS: &str = "pg"; pub const PING_ABOUT: &str = "Ping npm registry"; + pub const LOGIN_NAME: &str = "login"; + pub const LOGIN_ALIAS: &str = "lg"; + pub const LOGIN_ABOUT: &str = "Login to npm registry"; + + pub const WHOAMI_NAME: &str = "whoami"; + pub const WHOAMI_ALIAS: &str = "who"; + pub const WHOAMI_ABOUT: &str = "Display npm username"; + + pub const LOGOUT_NAME: &str = "logout"; + pub const LOGOUT_ALIAS: &str = "lo"; + pub const LOGOUT_ABOUT: &str = "Logout from 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/main.rs b/crates/pm/src/main.rs index 36f41302e7..06417a060e 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -33,10 +33,12 @@ 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, PING_ABOUT, PING_ALIAS, PING_NAME, + LINK_ABOUT, LINK_ALIAS, LINK_NAME, LIST_ALIAS, LIST_NAME, LOGIN_ABOUT, LOGIN_ALIAS, + LOGIN_NAME, LOGOUT_ABOUT, LOGOUT_ALIAS, LOGOUT_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, + VIEW_ALIAS, VIEW_ALIAS_INFO, VIEW_ALIAS_SHOW, VIEW_NAME, WHOAMI_ABOUT, WHOAMI_ALIAS, + WHOAMI_NAME, }; use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION}; use crate::helper::workspace::update_cwd_to_root; @@ -265,6 +267,19 @@ enum Commands { registry: Option, }, + #[command(name = LOGIN_NAME, alias = LOGIN_ALIAS, about = LOGIN_ABOUT)] + Login { + /// Registry URL to login to + #[arg(long)] + registry: Option, + }, + + #[command(name = WHOAMI_NAME, alias = WHOAMI_ALIAS, about = WHOAMI_ABOUT)] + Whoami, + + #[command(name = LOGOUT_NAME, alias = LOGOUT_ALIAS, about = LOGOUT_ABOUT)] + Logout, + #[command(name = CONFIG_NAME, alias = CONFIG_ALIAS, about = CONFIG_ABOUT)] Config { #[command(subcommand)] @@ -544,6 +559,15 @@ async fn async_main() -> Result<()> { Some(Commands::Ping { registry }) => { cmd::ping::ping(registry.as_deref()).await?; } + Some(Commands::Login { registry }) => { + cmd::login::login(registry).await?; + } + Some(Commands::Whoami) => { + cmd::whoami::whoami().await?; + } + Some(Commands::Logout) => { + cmd::logout::logout().await?; + } Some(Commands::Config { command }) => match command { ConfigCommands::Set { key, value, global } => { handle_config_set(key, value, global).await?; diff --git a/crates/pm/src/service/auth.rs b/crates/pm/src/service/auth.rs new file mode 100644 index 0000000000..3795989ace --- /dev/null +++ b/crates/pm/src/service/auth.rs @@ -0,0 +1,151 @@ +use anyhow::{Context, Result, bail}; + +use crate::util::config_file::Config; + +fn registry_api(registry: &str, path: &str) -> String { + format!("{}{}", registry.trim_end_matches('/'), path) +} + +/// Resolve auth token from env vars or ~/.utoo/config.toml. +pub async fn resolve_token() -> Option { + for var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] { + if let Ok(token) = std::env::var(var) { + if !token.is_empty() { + return Some(token); + } + } + } + + let config = Config::load(true).await.ok()?; + let token = config.get("auth-token").ok().flatten()?; + if token.is_empty() { None } else { Some(token) } +} + +/// Resolve token or bail with a login hint. +pub async fn require_token() -> Result { + resolve_token() + .await + .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `utoo login` to authenticate.")) +} + +/// Login to the registry via web flow. Returns the token on success. +/// +/// `on_login_url` is called with the URL the user must visit to authenticate. +pub async fn web_login( + registry: &str, + on_login_url: impl FnOnce(&str), +) -> Result { + let client = reqwest::Client::new(); + + let response = client + .post(registry_api(registry, "/-/v1/login")) + .json(&serde_json::json!({})) + .send() + .await + .context("Failed to initiate web login")?; + + let status = response.status(); + if matches!(status.as_u16(), 404 | 501) { + bail!("Registry does not support web login (HTTP {})", status); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + bail!("Web login initiation failed (HTTP {}): {}", status, body); + } + + let body: serde_json::Value = response.json().await?; + let login_url = body["loginUrl"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing loginUrl in response"))?; + let done_url = body["doneUrl"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing doneUrl in response"))?; + + on_login_url(login_url); + + let timeout = std::time::Duration::from_secs(300); + let start = std::time::Instant::now(); + let mut interval = std::time::Duration::from_secs(5); + + loop { + if start.elapsed() > timeout { + bail!("Login timed out after 5 minutes"); + } + + tokio::time::sleep(interval).await; + let poll_resp = client.get(done_url).send().await?; + + if let Some(retry_after) = poll_resp.headers().get("retry-after") { + if let Ok(secs) = retry_after.to_str().unwrap_or("5").parse::() { + interval = std::time::Duration::from_secs(secs); + } + } + + match poll_resp.status().as_u16() { + 200 => { + let body: serde_json::Value = poll_resp.json().await?; + let token = body["token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing token in login response"))?; + return Ok(token.to_string()); + } + 202 => continue, + status => { + let body = poll_resp.text().await.unwrap_or_default(); + bail!("Login polling failed (HTTP {}): {}", status, body); + } + } + } +} + +/// Query current identity from the registry. +pub async fn whoami(registry: &str, token: &str) -> Result { + let response = reqwest::Client::new() + .get(registry_api(registry, "/-/whoami")) + .bearer_auth(token) + .send() + .await + .context("Failed to send whoami request")?; + + let status = response.status(); + if status.as_u16() == 401 { + bail!("Authentication failed. Your token may be invalid or expired."); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + bail!("Whoami failed (HTTP {}): {}", status, body); + } + + let body: serde_json::Value = response.json().await?; + body["username"] + .as_str() + .map(String::from) + .ok_or_else(|| anyhow::anyhow!("Unexpected response: missing username")) +} + +/// Logout: invalidate the token on the server and remove from local config. +pub async fn logout(registry: &str, token: &str) -> Result<()> { + let response = reqwest::Client::new() + .delete(registry_api(registry, &format!("/-/user/token/{}", token))) + .bearer_auth(token) + .send() + .await + .context("Failed to send logout request")?; + + let status = response.status(); + if !status.is_success() && status.as_u16() != 404 { + let body = response.text().await.unwrap_or_default(); + tracing::warn!("Server-side token invalidation failed (HTTP {}): {}", status, body); + } + + let mut config = Config::load(true).await?; + config.delete("auth-token", true)?; + Ok(()) +} + +/// Save token to ~/.utoo/config.toml. +pub async fn save_token(token: String) -> Result<()> { + let mut config = Config::load(true).await?; + config.set("auth-token", token, true)?; + Ok(()) +} diff --git a/crates/pm/src/service/mod.rs b/crates/pm/src/service/mod.rs index 634f7f45eb..f880f22d55 100644 --- a/crates/pm/src/service/mod.rs +++ b/crates/pm/src/service/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod binary; pub mod clean; pub mod config; diff --git a/crates/pm/src/util/config_file.rs b/crates/pm/src/util/config_file.rs index 1398aa4011..a6c1fd7af3 100644 --- a/crates/pm/src/util/config_file.rs +++ b/crates/pm/src/util/config_file.rs @@ -42,6 +42,11 @@ impl Config { Ok(self.values.get(key).cloned()) } + pub fn delete(&mut self, key: &str, global: bool) -> ConfigResult<()> { + self.values.remove(key); + self.save(global) + } + pub fn get_array(&self, key: &str) -> Option<&Vec> { self.arrays.get(key) } From 4705374bf92a9e3a1aa71f0e4915acb97d91d9ee Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 22 Feb 2026 00:32:56 +0800 Subject: [PATCH 2/3] fix: cmd --- crates/pm/src/cmd/login.rs | 6 +- crates/pm/src/cmd/logout.rs | 2 +- crates/pm/src/cmd/whoami.rs | 2 +- crates/pm/src/main.rs | 21 +++--- crates/pm/src/service/auth.rs | 110 +++++++++++++++++++++++++----- crates/pm/src/service/config.rs | 4 ++ crates/pm/src/util/config_file.rs | 49 +++++++++++++ 7 files changed, 159 insertions(+), 35 deletions(-) diff --git a/crates/pm/src/cmd/login.rs b/crates/pm/src/cmd/login.rs index 5828f26f11..e3a922da4d 100644 --- a/crates/pm/src/cmd/login.rs +++ b/crates/pm/src/cmd/login.rs @@ -4,8 +4,8 @@ use colored::Colorize; use crate::service::auth; use crate::util::user_config::get_registry; -pub async fn login(registry_override: Option) -> Result<()> { - let registry = registry_override.unwrap_or_else(get_registry); +pub async fn login() -> Result<()> { + let registry = get_registry(); println!("Login to {}", registry.cyan()); @@ -15,7 +15,7 @@ pub async fn login(registry_override: Option) -> Result<()> { }) .await?; - auth::save_token(token).await?; + auth::save_token(®istry, token).await?; println!("{}", "Logged in successfully.".green()); Ok(()) } diff --git a/crates/pm/src/cmd/logout.rs b/crates/pm/src/cmd/logout.rs index e1a6938737..e9f4db3afc 100644 --- a/crates/pm/src/cmd/logout.rs +++ b/crates/pm/src/cmd/logout.rs @@ -6,7 +6,7 @@ use crate::util::user_config::get_registry; pub async fn logout() -> Result<()> { let registry = get_registry(); - let token = auth::require_token().await?; + let token = auth::require_token(®istry).await?; auth::logout(®istry, &token).await?; diff --git a/crates/pm/src/cmd/whoami.rs b/crates/pm/src/cmd/whoami.rs index daecad5111..e338c77209 100644 --- a/crates/pm/src/cmd/whoami.rs +++ b/crates/pm/src/cmd/whoami.rs @@ -5,7 +5,7 @@ use crate::util::user_config::get_registry; pub async fn whoami() -> Result<()> { let registry = get_registry(); - let token = auth::require_token().await?; + let token = auth::require_token(®istry).await?; let username = auth::whoami(®istry, &token).await?; println!("{}", username); diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 06417a060e..137c088472 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -33,12 +33,11 @@ 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, LOGIN_ABOUT, LOGIN_ALIAS, - LOGIN_NAME, LOGOUT_ABOUT, LOGOUT_ALIAS, LOGOUT_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, WHOAMI_ABOUT, WHOAMI_ALIAS, - WHOAMI_NAME, + LINK_ABOUT, LINK_ALIAS, LINK_NAME, LIST_ALIAS, LIST_NAME, LOGIN_ABOUT, LOGIN_ALIAS, LOGIN_NAME, + LOGOUT_ABOUT, LOGOUT_ALIAS, LOGOUT_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, WHOAMI_ABOUT, WHOAMI_ALIAS, WHOAMI_NAME, }; use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION}; use crate::helper::workspace::update_cwd_to_root; @@ -268,11 +267,7 @@ enum Commands { }, #[command(name = LOGIN_NAME, alias = LOGIN_ALIAS, about = LOGIN_ABOUT)] - Login { - /// Registry URL to login to - #[arg(long)] - registry: Option, - }, + Login, #[command(name = WHOAMI_NAME, alias = WHOAMI_ALIAS, about = WHOAMI_ABOUT)] Whoami, @@ -559,8 +554,8 @@ async fn async_main() -> Result<()> { Some(Commands::Ping { registry }) => { cmd::ping::ping(registry.as_deref()).await?; } - Some(Commands::Login { registry }) => { - cmd::login::login(registry).await?; + Some(Commands::Login) => { + cmd::login::login().await?; } Some(Commands::Whoami) => { cmd::whoami::whoami().await?; diff --git a/crates/pm/src/service/auth.rs b/crates/pm/src/service/auth.rs index 3795989ace..b4caa4a815 100644 --- a/crates/pm/src/service/auth.rs +++ b/crates/pm/src/service/auth.rs @@ -6,8 +6,24 @@ fn registry_api(registry: &str, path: &str) -> String { format!("{}{}", registry.trim_end_matches('/'), path) } -/// Resolve auth token from env vars or ~/.utoo/config.toml. -pub async fn resolve_token() -> Option { +/// Extract host from registry URL for use as config key suffix. +/// +/// "https://registry.npmjs.org/" -> "registry.npmjs.org" +fn registry_host(registry: &str) -> &str { + let s = registry + .strip_prefix("https://") + .or_else(|| registry.strip_prefix("http://")) + .unwrap_or(registry); + s.trim_end_matches('/') +} + +/// Config key for storing auth token per registry. +fn token_key(registry: &str) -> String { + format!("auth-token:{}", registry_host(registry)) +} + +/// Resolve auth token for a specific registry. +pub async fn resolve_token(registry: &str) -> Option { for var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] { if let Ok(token) = std::env::var(var) { if !token.is_empty() { @@ -17,29 +33,30 @@ pub async fn resolve_token() -> Option { } let config = Config::load(true).await.ok()?; - let token = config.get("auth-token").ok().flatten()?; + let token = config.get(&token_key(registry)).ok().flatten()?; if token.is_empty() { None } else { Some(token) } } /// Resolve token or bail with a login hint. -pub async fn require_token() -> Result { - resolve_token() - .await - .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `utoo login` to authenticate.")) +pub async fn require_token(registry: &str) -> Result { + resolve_token(registry).await.ok_or_else(|| { + anyhow::anyhow!( + "Not logged in to {}. Run `utoo login` to authenticate.", + registry + ) + }) } /// Login to the registry via web flow. Returns the token on success. /// /// `on_login_url` is called with the URL the user must visit to authenticate. -pub async fn web_login( - registry: &str, - on_login_url: impl FnOnce(&str), -) -> Result { +pub async fn web_login(registry: &str, on_login_url: impl FnOnce(&str)) -> Result { let client = reqwest::Client::new(); let response = client .post(registry_api(registry, "/-/v1/login")) - .json(&serde_json::json!({})) + .header("npm-auth-type", "web") + .json(&serde_json::json!({ "hostname": "utoo" })) .send() .await .context("Failed to initiate web login")?; @@ -135,17 +152,76 @@ pub async fn logout(registry: &str, token: &str) -> Result<()> { let status = response.status(); if !status.is_success() && status.as_u16() != 404 { let body = response.text().await.unwrap_or_default(); - tracing::warn!("Server-side token invalidation failed (HTTP {}): {}", status, body); + tracing::warn!( + "Server-side token invalidation failed (HTTP {}): {}", + status, + body + ); } let mut config = Config::load(true).await?; - config.delete("auth-token", true)?; + config.delete(&token_key(registry), true)?; Ok(()) } -/// Save token to ~/.utoo/config.toml. -pub async fn save_token(token: String) -> Result<()> { +/// Save token to ~/.utoo/config.toml, keyed by registry. +pub async fn save_token(registry: &str, token: String) -> Result<()> { let mut config = Config::load(true).await?; - config.set("auth-token", token, true)?; + config.set(&token_key(registry), token, true)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_registry_api_trailing_slash() { + assert_eq!( + registry_api("https://registry.npmjs.org/", "/-/whoami"), + "https://registry.npmjs.org/-/whoami" + ); + } + + #[test] + fn test_registry_api_no_trailing_slash() { + assert_eq!( + registry_api("https://registry.npmjs.org", "/-/v1/login"), + "https://registry.npmjs.org/-/v1/login" + ); + } + + #[test] + fn test_registry_host_https() { + assert_eq!( + registry_host("https://registry.npmjs.org/"), + "registry.npmjs.org" + ); + } + + #[test] + fn test_registry_host_http() { + assert_eq!(registry_host("http://localhost:4873/"), "localhost:4873"); + } + + #[test] + fn test_registry_host_bare() { + assert_eq!(registry_host("registry.npmjs.org"), "registry.npmjs.org"); + } + + #[test] + fn test_token_key_default_registry() { + assert_eq!( + token_key("https://registry.npmjs.org/"), + "auth-token:registry.npmjs.org" + ); + } + + #[test] + fn test_token_key_custom_registry() { + assert_eq!( + token_key("http://localhost:4873"), + "auth-token:localhost:4873" + ); + } +} diff --git a/crates/pm/src/service/config.rs b/crates/pm/src/service/config.rs index 092a896cf8..ecdfbe83d5 100644 --- a/crates/pm/src/service/config.rs +++ b/crates/pm/src/service/config.rs @@ -72,6 +72,10 @@ impl ConfigService { (cmd::LINK_NAME, cmd::LINK_ALIAS, cmd::LINK_ABOUT), (cmd::CONFIG_NAME, cmd::CONFIG_ALIAS, cmd::CONFIG_ABOUT), (cmd::RUN_NAME, cmd::RUN_ALIAS, cmd::RUN_ABOUT), + (cmd::PING_NAME, cmd::PING_ALIAS, cmd::PING_ABOUT), + (cmd::LOGIN_NAME, cmd::LOGIN_ALIAS, cmd::LOGIN_ABOUT), + (cmd::LOGOUT_NAME, cmd::LOGOUT_ALIAS, cmd::LOGOUT_ABOUT), + (cmd::WHOAMI_NAME, cmd::WHOAMI_ALIAS, cmd::WHOAMI_ABOUT), ("*", "", "→ ut run *"), ]; diff --git a/crates/pm/src/util/config_file.rs b/crates/pm/src/util/config_file.rs index a6c1fd7af3..a59e573159 100644 --- a/crates/pm/src/util/config_file.rs +++ b/crates/pm/src/util/config_file.rs @@ -190,3 +190,52 @@ impl ConfigValueParser for ConfigValue { value.parse().unwrap_or(self.default) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn seed_config(values: &[(&str, &str)]) -> Config { + let map = values + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + Config { + values: map, + arrays: HashMap::new(), + } + } + + #[tokio::test] + async fn test_delete_removes_key_and_persists() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + // seed two keys, save to temp file + let config = seed_config(&[("foo", "bar"), ("baz", "qux")]); + let content = toml::to_string_pretty(&config).unwrap(); + fs::write(&path, &content).unwrap(); + + // load → delete → save back + let mut config = Config::load_from_path(&path).await.unwrap(); + assert_eq!(config.get("foo").unwrap(), Some("bar".into())); + + config.values.remove("foo"); + let content = toml::to_string_pretty(&config).unwrap(); + fs::write(&path, &content).unwrap(); + + // reload from disk — foo gone, baz kept + let reloaded = Config::load_from_path(&path).await.unwrap(); + assert_eq!(reloaded.get("foo").unwrap(), None); + assert_eq!(reloaded.get("baz").unwrap(), Some("qux".into())); + } + + #[tokio::test] + async fn test_delete_nonexistent_key_is_noop() { + let config = seed_config(&[("keep", "yes")]); + + // deleting a key that doesn't exist just returns None + assert_eq!(config.values.get("nope"), None); + assert_eq!(config.get("keep").unwrap(), Some("yes".into())); + } +} From a7acf8a86e3a3522e505ce34f29889af40d77e73 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 22 Feb 2026 01:08:48 +0800 Subject: [PATCH 3/3] fix: ci --- crates/pm/src/cmd/login.rs | 4 +- crates/pm/src/service/auth.rs | 16 +++--- crates/pm/src/util/config_file.rs | 84 ++++++++++++++++++------------- 3 files changed, 61 insertions(+), 43 deletions(-) diff --git a/crates/pm/src/cmd/login.rs b/crates/pm/src/cmd/login.rs index e3a922da4d..7afcad863d 100644 --- a/crates/pm/src/cmd/login.rs +++ b/crates/pm/src/cmd/login.rs @@ -11,7 +11,9 @@ pub async fn login() -> Result<()> { let token = auth::web_login(®istry, |url| { println!("Login at: {}", url); - let _ = open::that(url); + if let Err(e) = open::that(url) { + tracing::warn!("Failed to open browser: {e}"); + } }) .await?; diff --git a/crates/pm/src/service/auth.rs b/crates/pm/src/service/auth.rs index b4caa4a815..fe8fe729c7 100644 --- a/crates/pm/src/service/auth.rs +++ b/crates/pm/src/service/auth.rs @@ -25,10 +25,10 @@ fn token_key(registry: &str) -> String { /// Resolve auth token for a specific registry. pub async fn resolve_token(registry: &str) -> Option { for var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] { - if let Ok(token) = std::env::var(var) { - if !token.is_empty() { - return Some(token); - } + if let Ok(token) = std::env::var(var) + && !token.is_empty() + { + return Some(token); } } @@ -92,10 +92,10 @@ pub async fn web_login(registry: &str, on_login_url: impl FnOnce(&str)) -> Resul tokio::time::sleep(interval).await; let poll_resp = client.get(done_url).send().await?; - if let Some(retry_after) = poll_resp.headers().get("retry-after") { - if let Ok(secs) = retry_after.to_str().unwrap_or("5").parse::() { - interval = std::time::Duration::from_secs(secs); - } + if let Some(retry_after) = poll_resp.headers().get("retry-after") + && let Ok(secs) = retry_after.to_str().unwrap_or("5").parse::() + { + interval = std::time::Duration::from_secs(secs); } match poll_resp.status().as_u16() { diff --git a/crates/pm/src/util/config_file.rs b/crates/pm/src/util/config_file.rs index a59e573159..5a407a88f7 100644 --- a/crates/pm/src/util/config_file.rs +++ b/crates/pm/src/util/config_file.rs @@ -194,48 +194,64 @@ impl ConfigValueParser for ConfigValue { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; - fn seed_config(values: &[(&str, &str)]) -> Config { - let map = values - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - Config { - values: map, - arrays: HashMap::new(), - } - } + /// Serialize tests that override HOME to avoid races. + static HOME_LOCK: Mutex<()> = Mutex::new(()); - #[tokio::test] - async fn test_delete_removes_key_and_persists() { + /// Run an async closure with HOME pointed at a temp dir, so + /// Config::load/save(global=true) never touches the real config. + fn with_temp_home(f: impl FnOnce()) { + let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - - // seed two keys, save to temp file - let config = seed_config(&[("foo", "bar"), ("baz", "qux")]); - let content = toml::to_string_pretty(&config).unwrap(); - fs::write(&path, &content).unwrap(); + let prev = std::env::var("HOME").ok(); + unsafe { std::env::set_var("HOME", dir.path()) }; - // load → delete → save back - let mut config = Config::load_from_path(&path).await.unwrap(); - assert_eq!(config.get("foo").unwrap(), Some("bar".into())); + f(); - config.values.remove("foo"); - let content = toml::to_string_pretty(&config).unwrap(); - fs::write(&path, &content).unwrap(); + match prev { + Some(h) => unsafe { std::env::set_var("HOME", h) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } - // reload from disk — foo gone, baz kept - let reloaded = Config::load_from_path(&path).await.unwrap(); - assert_eq!(reloaded.get("foo").unwrap(), None); - assert_eq!(reloaded.get("baz").unwrap(), Some("qux".into())); + #[test] + fn test_delete_removes_key_and_persists() { + with_temp_home(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut config = Config::load(true).await.unwrap(); + config.set("foo", "bar".into(), true).unwrap(); + config.set("baz", "qux".into(), true).unwrap(); + + // call the actual delete method + config.delete("foo", true).unwrap(); + + // in-memory: foo gone, baz kept + assert_eq!(config.get("foo").unwrap(), None); + assert_eq!(config.get("baz").unwrap(), Some("qux".into())); + + // reload from disk: still gone + let reloaded = Config::load(true).await.unwrap(); + assert_eq!(reloaded.get("foo").unwrap(), None); + assert_eq!(reloaded.get("baz").unwrap(), Some("qux".into())); + }); + }); } - #[tokio::test] - async fn test_delete_nonexistent_key_is_noop() { - let config = seed_config(&[("keep", "yes")]); + #[test] + fn test_delete_nonexistent_key_is_noop() { + with_temp_home(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut config = Config::load(true).await.unwrap(); + config.set("keep", "yes".into(), true).unwrap(); + + // deleting a key that doesn't exist should not error + config.delete("nope", true).unwrap(); - // deleting a key that doesn't exist just returns None - assert_eq!(config.values.get("nope"), None); - assert_eq!(config.get("keep").unwrap(), Some("yes".into())); + assert_eq!(config.get("keep").unwrap(), Some("yes".into())); + }); + }); } }