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..7afcad863d --- /dev/null +++ b/crates/pm/src/cmd/login.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use colored::Colorize; + +use crate::service::auth; +use crate::util::user_config::get_registry; + +pub async fn login() -> Result<()> { + let registry = get_registry(); + + println!("Login to {}", registry.cyan()); + + let token = auth::web_login(®istry, |url| { + println!("Login at: {}", url); + if let Err(e) = open::that(url) { + tracing::warn!("Failed to open browser: {e}"); + } + }) + .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 new file mode 100644 index 0000000000..e9f4db3afc --- /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(®istry).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..e338c77209 --- /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(®istry).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..137c088472 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -33,10 +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, 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, + 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; @@ -265,6 +266,15 @@ enum Commands { registry: Option, }, + #[command(name = LOGIN_NAME, alias = LOGIN_ALIAS, about = LOGIN_ABOUT)] + Login, + + #[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 +554,15 @@ async fn async_main() -> Result<()> { Some(Commands::Ping { registry }) => { cmd::ping::ping(registry.as_deref()).await?; } + Some(Commands::Login) => { + cmd::login::login().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..fe8fe729c7 --- /dev/null +++ b/crates/pm/src/service/auth.rs @@ -0,0 +1,227 @@ +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) +} + +/// 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) + && !token.is_empty() + { + return Some(token); + } + } + + let config = Config::load(true).await.ok()?; + 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(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 { + let client = reqwest::Client::new(); + + let response = client + .post(registry_api(registry, "/-/v1/login")) + .header("npm-auth-type", "web") + .json(&serde_json::json!({ "hostname": "utoo" })) + .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") + && 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(&token_key(registry), true)?; + Ok(()) +} + +/// 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(&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/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..5a407a88f7 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) } @@ -185,3 +190,68 @@ impl ConfigValueParser for ConfigValue { value.parse().unwrap_or(self.default) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Serialize tests that override HOME to avoid races. + static HOME_LOCK: Mutex<()> = Mutex::new(()); + + /// 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 prev = std::env::var("HOME").ok(); + unsafe { std::env::set_var("HOME", dir.path()) }; + + f(); + + match prev { + Some(h) => unsafe { std::env::set_var("HOME", h) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } + + #[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())); + }); + }); + } + + #[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(); + + assert_eq!(config.get("keep").unwrap(), Some("yes".into())); + }); + }); + } +}