diff --git a/Cargo.lock b/Cargo.lock index 00723589..dc2a49a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,7 +2250,9 @@ dependencies = [ name = "oro-client" version = "0.3.29" dependencies = [ + "anyhow", "async-std", + "async-trait", "base64 0.21.4", "chrono", "futures 0.3.28", @@ -2266,6 +2268,7 @@ dependencies = [ "reqwest-retry", "serde", "serde_json", + "task-local-extensions", "thiserror", "tracing", "url", @@ -2317,6 +2320,7 @@ dependencies = [ "oro-client", "reqwest", "thiserror", + "tracing", "url", ] @@ -2955,9 +2959,9 @@ dependencies = [ [[package]] name = "reqwest-middleware" -version = "0.2.3" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff44108c7925d082f2861e683a88618b68235ad9cdc60d64d9d1188efc951cdb" +checksum = "4531c89d50effe1fac90d095c8b133c20c5c714204feee0bfc3fd158e784209d" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 30d5712e..23c390a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ homepage = "https://orogene.dev" rust-version = "1.67.1" [workspace.dependencies] +anyhow = "1.0.75" async-compression = "0.3.5" async-process = "1.0.1" async-std = "1.12.0" @@ -125,7 +126,7 @@ rand = "0.8.5" reflink-copy = "0.1.5" regex = "1.7.2" reqwest = "0.11.14" -reqwest-middleware = "0.2.0" +reqwest-middleware = "=0.2.2" resvg = "0.29.0" rkyv = "0.7.41" sentry = "0.31.0" @@ -136,6 +137,7 @@ ssri = "9.0.0" supports-unicode = "2.0.0" syn = "1.0.33" tar = "0.4.38" +task-local-extensions = "0.1.4" tempfile = "3.3.0" term_grid = "0.1.7" term_size = "0.3.2" diff --git a/crates/nassun/src/client.rs b/crates/nassun/src/client.rs index 6bc420c4..bc457d39 100644 --- a/crates/nassun/src/client.rs +++ b/crates/nassun/src/client.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use async_std::sync::Arc; -use oro_client::OroClient; +use oro_client::{OroClient, OroClientBuilder}; use oro_common::{CorgiManifest, CorgiPackument, CorgiVersionMetadata, Packument, VersionMetadata}; use url::Url; @@ -20,23 +20,16 @@ use crate::resolver::{PackageResolution, PackageResolver}; use crate::tarball::Tarball; /// Build a new Nassun instance with specified options. -#[derive(Clone, Debug, PartialEq, Eq, Default)] +#[derive(Clone, Debug, Default)] pub struct NassunOpts { + client_builder: OroClientBuilder, + client: Option, #[cfg(not(target_arch = "wasm32"))] cache: Option, base_dir: Option, default_tag: Option, registries: HashMap, Url>, - credentials: Vec<(String, String, String)>, memoize_metadata: bool, - #[cfg(not(target_arch = "wasm32"))] - proxy: bool, - #[cfg(not(target_arch = "wasm32"))] - proxy_url: Option, - #[cfg(not(target_arch = "wasm32"))] - no_proxy_domain: Option, - #[cfg(not(target_arch = "wasm32"))] - fetch_retries: u32, } impl NassunOpts { @@ -44,14 +37,24 @@ impl NassunOpts { Default::default() } + /// A preconfigured [`OroClient`] to use for requests. Providing this will + /// override all other client-related options. + pub fn client(mut self, client: OroClient) -> Self { + self.client = Some(client); + self + } + /// Cache directory to use for requests. #[cfg(not(target_arch = "wasm32"))] pub fn cache(mut self, cache: impl AsRef) -> Self { self.cache = Some(PathBuf::from(cache.as_ref())); + self.client_builder = self.client_builder.cache(cache.as_ref()); self } + /// Sets the default registry for requests. pub fn registry(mut self, registry: Url) -> Self { + self.client_builder = self.client_builder.registry(registry.clone()); self.registries.insert(None, registry); self } @@ -66,12 +69,34 @@ impl NassunOpts { self } - /// Set the credential-config for this instance. The config has the form (registry,key,value) - /// where registry is the host of the registry, key is the key of a single entry in the respective - /// config JSON object and value is the corresponding value. A single registry may have multiple entries - /// in this vector (e.g. "username" & "password"). The format will be handled by by OroClient. - pub fn credentials(mut self, credentials: Vec<(String, String, String)>) -> Self { - self.credentials = credentials; + /// Sets basic auth credentials for a registry. + pub fn basic_auth( + mut self, + registry: Url, + username: impl AsRef, + password: Option>, + ) -> Self { + let username = username.as_ref(); + let password = password.map(|p| p.as_ref().to_string()); + self.client_builder = + self.client_builder + .basic_auth(registry, username.to_string(), password); + self + } + + /// Sets bearer token credentials for a registry. + pub fn token_auth(mut self, registry: Url, token: impl AsRef) -> Self { + self.client_builder = self + .client_builder + .token_auth(registry, token.as_ref().to_string()); + self + } + + /// Sets the legacy, pre-encoded auth token for a registry. + pub fn legacy_auth(mut self, registry: Url, legacy_auth_token: impl AsRef) -> Self { + self.client_builder = self + .client_builder + .legacy_auth(registry, legacy_auth_token.as_ref().to_string()); self } @@ -96,67 +121,45 @@ impl NassunOpts { self } - #[cfg(not(target_arch = "wasm32"))] - pub fn proxy(mut self, proxy: bool) -> Self { - self.proxy = proxy; + /// Number of times to retry failed requests. + pub fn retries(mut self, retries: u32) -> Self { + self.client_builder = self.client_builder.retries(retries); self } + /// Whether to use a proxy for requests. #[cfg(not(target_arch = "wasm32"))] - pub fn proxy_url(mut self, proxy_url: impl AsRef) -> Self { - self.proxy_url = Some(proxy_url.as_ref().into()); + pub fn proxy(mut self, proxy: bool) -> Self { + self.client_builder = self.client_builder.proxy(proxy); self } + /// Proxy URL to use for requests. If `no_proxy_domain` is needed, it must + /// be called before this method. #[cfg(not(target_arch = "wasm32"))] - pub fn no_proxy_domain(mut self, no_proxy_domain: impl AsRef) -> Self { - self.no_proxy_domain = Some(no_proxy_domain.as_ref().into()); - self + pub fn proxy_url(mut self, proxy_url: impl AsRef) -> Result { + self.client_builder = self.client_builder.proxy_url(proxy_url.as_ref())?; + Ok(self) } + /// Sets the NO_PROXY domain. #[cfg(not(target_arch = "wasm32"))] - pub fn fetch_retries(mut self, fetch_retries: u32) -> Self { - self.fetch_retries = fetch_retries; + pub fn no_proxy_domain(mut self, no_proxy_domain: impl AsRef) -> Self { + self.client_builder = self + .client_builder + .no_proxy_domain(no_proxy_domain.as_ref()); self } /// Build a new Nassun instance from this options object. pub fn build(self) -> Nassun { - let registry = self - .registries - .get(&None) - .cloned() - .unwrap_or_else(|| "https://registry.npmjs.org/".parse().unwrap()); - #[cfg(target_arch = "wasm32")] - let client_builder = OroClient::builder().registry(registry); - #[cfg(not(target_arch = "wasm32"))] - let mut client_builder = OroClient::builder() - .registry(registry) - .fetch_retries(self.fetch_retries) - .proxy(self.proxy); - #[cfg(not(target_arch = "wasm32"))] - if let Some(domain) = self.no_proxy_domain { - client_builder = client_builder.no_proxy_domain(domain); - } - #[cfg(not(target_arch = "wasm32"))] - if let Some(proxy) = self.proxy_url { - if let Ok(builder) = client_builder.clone().proxy_url(&proxy) { - client_builder = builder; - } else { - tracing::warn!("Failed to parse proxy URL: {}", proxy) - } - } #[cfg(not(target_arch = "wasm32"))] let cache = if let Some(cache) = self.cache { - client_builder = client_builder.cache(cache.clone()); Arc::new(Some(cache)) } else { Arc::new(None) }; - client_builder = client_builder - .credentials(self.credentials) - .expect("failed to set credential list"); - let client = client_builder.build(); + let client = self.client.unwrap_or_else(|| self.client_builder.build()); Nassun { #[cfg(not(target_arch = "wasm32"))] cache, diff --git a/crates/node-maintainer/src/maintainer.rs b/crates/node-maintainer/src/maintainer.rs index e398600d..e701f16a 100644 --- a/crates/node-maintainer/src/maintainer.rs +++ b/crates/node-maintainer/src/maintainer.rs @@ -35,6 +35,7 @@ pub type ScriptLineHandler = Arc; #[derive(Clone)] pub struct NodeMaintainerOptions { nassun_opts: NassunOpts, + nassun: Option, concurrency: usize, locked: bool, kdl_lock: Option, @@ -139,10 +140,30 @@ impl NodeMaintainerOptions { self } - /// Credentials map used for all registries - /// This will be resolved into a proper credentials map inside nassun - pub fn credentials(mut self, credentials: Vec<(String, String, String)>) -> Self { - self.nassun_opts = self.nassun_opts.credentials(credentials); + /// Sets basic auth credentials for a registry. + pub fn basic_auth( + mut self, + registry: Url, + username: impl AsRef, + password: Option>, + ) -> Self { + let username = username.as_ref(); + let password = password.map(|p| p.as_ref().to_string()); + self.nassun_opts = self.nassun_opts.basic_auth(registry, username, password); + self + } + + /// Sets bearer token credentials for a registry. + pub fn token_auth(mut self, registry: Url, token: impl AsRef) -> Self { + self.nassun_opts = self.nassun_opts.token_auth(registry, token.as_ref()); + self + } + + /// Sets the legacy, pre-encoded auth token for a registry. + pub fn legacy_auth(mut self, registry: Url, legacy_auth_token: impl AsRef) -> Self { + self.nassun_opts = self + .nassun_opts + .legacy_auth(registry, legacy_auth_token.as_ref()); self } @@ -160,6 +181,13 @@ impl NodeMaintainerOptions { self } + /// Provide a pre-configured Nassun instance. Using this option will + /// disable all other nassun-related configurations. + pub fn nassun(mut self, nassun: Nassun) -> Self { + self.nassun = Some(nassun); + self + } + /// When extracting packages, prefer to copy files instead of linking /// them. /// @@ -189,9 +217,9 @@ impl NodeMaintainerOptions { } #[cfg(not(target_arch = "wasm32"))] - pub fn proxy_url(mut self, proxy_url: impl AsRef) -> Self { - self.nassun_opts = self.nassun_opts.proxy_url(proxy_url.as_ref()); - self + pub fn proxy_url(mut self, proxy_url: impl AsRef) -> Result { + self.nassun_opts = self.nassun_opts.proxy_url(proxy_url.as_ref())?; + Ok(self) } #[cfg(not(target_arch = "wasm32"))] @@ -306,7 +334,7 @@ impl NodeMaintainerOptions { root: CorgiManifest, ) -> Result { let lockfile = self.get_lockfile().await?; - let nassun = self.nassun_opts.build(); + let nassun = self.nassun.unwrap_or_else(|| self.nassun_opts.build()); let root_pkg = Nassun::dummy_from_manifest(root.clone()); let proj_root = self.root.unwrap_or_else(|| PathBuf::from(".")); let mut resolver = Resolver { @@ -415,6 +443,7 @@ impl Default for NodeMaintainerOptions { fn default() -> Self { NodeMaintainerOptions { nassun_opts: Default::default(), + nassun: None, concurrency: DEFAULT_CONCURRENCY, kdl_lock: None, npm_lock: None, diff --git a/crates/oro-client/Cargo.toml b/crates/oro-client/Cargo.toml index 969e5684..c3f20956 100644 --- a/crates/oro-client/Cargo.toml +++ b/crates/oro-client/Cargo.toml @@ -14,6 +14,8 @@ rust-version.workspace = true [dependencies] oro-common = { version = "=0.3.29", path = "../oro-common" } +anyhow = { workspace = true } +async-trait = { workspace = true } chrono = { workspace = true } base64 = { workspace = true } futures = { workspace = true, features = ["io-compat"] } @@ -25,6 +27,7 @@ reqwest-middleware = { workspace = true } reqwest-retry = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +task-local-extensions = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } diff --git a/crates/oro-client/src/api/login.rs b/crates/oro-client/src/api/login.rs index 74e4a197..3258ab87 100644 --- a/crates/oro-client/src/api/login.rs +++ b/crates/oro-client/src/api/login.rs @@ -41,6 +41,7 @@ pub struct LoginWeb { #[derive(Debug, Clone, Default)] pub struct LoginOptions { pub scope: Option, + pub client: Option, } #[derive(Deserialize, Serialize)] @@ -90,6 +91,7 @@ impl OroClient { .client .post(url.clone()) .headers(headers) + .header("X-Oro-Registry", self.registry.to_string()) .send() .await? .notify() @@ -124,6 +126,7 @@ impl OroClient { let response = self .client .put(url.clone()) + .header("X-Oro-Registry", self.registry.to_string()) .headers(headers) .body( serde_json::to_string(&LoginCouch { @@ -197,6 +200,7 @@ impl OroClient { let response = self .client_uncached .get(done_url.as_ref()) + .header("X-Oro-Registry", self.registry.to_string()) .headers(headers) .send() .await? @@ -296,7 +300,8 @@ mod test { "password", None, &LoginOptions { - scope: Some("@mycompany".to_owned()) + scope: Some("@mycompany".to_owned()), + client: None, } ) .await?, diff --git a/crates/oro-client/src/api/logout.rs b/crates/oro-client/src/api/logout.rs index 8483a4e3..39d60513 100644 --- a/crates/oro-client/src/api/logout.rs +++ b/crates/oro-client/src/api/logout.rs @@ -4,6 +4,7 @@ impl OroClient { pub async fn delete_token(&self, token: &String) -> Result<(), OroClientError> { self.client .delete(self.registry.join(&format!("-/user/token/{token}"))?) + .header("X-Oro-Registry", self.registry.to_string()) .send() .await?; Ok(()) diff --git a/crates/oro-client/src/api/packument.rs b/crates/oro-client/src/api/packument.rs index bb4fa6ca..b0a6899a 100644 --- a/crates/oro-client/src/api/packument.rs +++ b/crates/oro-client/src/api/packument.rs @@ -1,10 +1,7 @@ -use base64::{engine::general_purpose, Engine as _}; use oro_common::{CorgiPackument, Packument}; use reqwest::{StatusCode, Url}; -#[cfg(not(target = "wasm"))] -use reqwest_middleware::RequestBuilder; -use crate::{credentials::Credentials, OroClient, OroClientError}; +use crate::{OroClient, OroClientError}; pub(crate) const CORGI_HEADER: &str = "application/vnd.npm.install-v1+json; q=1.0,application/json; q=0.8,*/*"; @@ -41,16 +38,18 @@ impl OroClient { url: &Url, use_corgi: bool, ) -> Result { - let client = self.client.get(url.clone()).header( - "Accept", - if use_corgi { - CORGI_HEADER - } else { - "application/json" - }, - ); Ok(self - .with_credentials(url, client)? + .client + .get(url.clone()) + .header("X-Oro-Registry", self.registry.to_string()) + .header( + "Accept", + if use_corgi { + CORGI_HEADER + } else { + "application/json" + }, + ) .send() .await? .error_for_status() @@ -67,34 +66,6 @@ impl OroClient { .text() .await?) } - - fn with_credentials( - &self, - url: &Url, - builder: RequestBuilder, - ) -> Result { - let credentials = url.host_str().and_then(|h| self.credentials.get(h)); - if let Some(cred) = credentials { - match cred { - Credentials::Basic { username, password } => { - Ok(builder.basic_auth(username, Some(password))) - } - Credentials::EncodedBasic(auth) => { - let decoded = general_purpose::STANDARD.decode(auth)?; - let string = String::from_utf8_lossy(&decoded); - let mut parts = string.split(':'); - if let Some(username) = parts.next() { - Ok(builder.basic_auth(username, parts.next())) - } else { - Err(OroClientError::AuthStringMissingUsername(auth.clone())) - } - } - Credentials::Token(token) => Ok(builder.bearer_auth(token)), - } - } else { - Ok(builder) - } - } } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -196,22 +167,9 @@ mod test { async fn fetch_with_credentials() -> Result<()> { let mock_server = MockServer::start().await; let url: Url = mock_server.uri().parse().into_diagnostic()?; - let host = url.host_str().unwrap(); - let cred_config = vec![ - ( - host.to_string(), - "username".to_string(), - "testuser".to_string(), - ), - ( - host.to_string(), - "password".to_string(), - "testpassword".to_string(), - ), - ]; let client = OroClient::builder() + .basic_auth(url.clone(), "testuser".into(), Some("testpassword".into())) .registry(url) - .credentials(cred_config)? .build(); Mock::given(method("GET")) diff --git a/crates/oro-client/src/api/ping.rs b/crates/oro-client/src/api/ping.rs index 088740af..ad9ee539 100644 --- a/crates/oro-client/src/api/ping.rs +++ b/crates/oro-client/src/api/ping.rs @@ -5,6 +5,7 @@ impl OroClient { Ok(self .client .get(self.registry.join("-/ping?write=true")?) + .header("X-Oro-Registry", self.registry.to_string()) .send() .await? .error_for_status()? diff --git a/crates/oro-client/src/api/stream_external.rs b/crates/oro-client/src/api/stream_external.rs index 572015e4..ab765bc6 100644 --- a/crates/oro-client/src/api/stream_external.rs +++ b/crates/oro-client/src/api/stream_external.rs @@ -18,6 +18,7 @@ impl OroClient { // cache them, cache them manually. self.client_uncached .get(url.to_string()) + .header("X-Oro-Registry", self.registry.to_string()) .send() .await? .error_for_status()? diff --git a/crates/oro-client/src/auth_middleware.rs b/crates/oro-client/src/auth_middleware.rs new file mode 100644 index 00000000..cb9c8f05 --- /dev/null +++ b/crates/oro-client/src/auth_middleware.rs @@ -0,0 +1,80 @@ +use std::{collections::HashMap, sync::Arc}; + +use reqwest::{header::HeaderValue, Request, Response}; +use reqwest_middleware::{Middleware, Next, Result}; +use task_local_extensions::Extensions; +use url::Url; + +use crate::credentials::Credentials; + +#[derive(Debug, Clone)] +pub(crate) struct AuthMiddleware(pub(crate) Arc>); + +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +impl Middleware for AuthMiddleware { + async fn handle( + &self, + mut req: Request, + extensions: &mut Extensions, + next: Next<'_>, + ) -> Result { + let reg = req + .headers() + .get("X-Oro-Registry") + .expect("Request did not have an x-oro-registry header. This is a bug in oro-client."); + let credentials = self.0.get(&nerf_dart( + &Url::parse(reg.to_str().expect("This should stringify just fine.")) + .expect("This should have already been parsed and serialized previously."), + )); + if let Some(cred) = credentials { + let auth_header = match cred { + Credentials::Basic { username, password } => { + basic_auth(username, password.as_ref()) + } + Credentials::EncodedBasic(auth) => { + let mut val = HeaderValue::from_str(&format!("Basic {auth}")) + .map_err(|e| anyhow::anyhow!(e))?; + val.set_sensitive(true); + val + } + Credentials::Token(token) => { + let mut val = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|e| anyhow::anyhow!(e))?; + val.set_sensitive(true); + val + } + }; + req.headers_mut() + .append(reqwest::header::AUTHORIZATION, auth_header); + } + next.run(req, extensions).await + } +} + +// From reqwest utils. +fn basic_auth(username: U, password: Option

) -> HeaderValue +where + U: std::fmt::Display, + P: std::fmt::Display, +{ + use base64::prelude::BASE64_STANDARD; + use base64::write::EncoderWriter; + use std::io::Write; + + let mut buf = b"Basic ".to_vec(); + { + let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD); + let _ = write!(encoder, "{}:", username); + if let Some(password) = password { + let _ = write!(encoder, "{}", password); + } + } + let mut header = HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue"); + header.set_sensitive(true); + header +} + +pub fn nerf_dart(url: &Url) -> String { + format!("//{}{}", url.host_str().unwrap_or(""), url.path()) +} diff --git a/crates/oro-client/src/client.rs b/crates/oro-client/src/client.rs index 856385fc..1b721f97 100644 --- a/crates/oro-client/src/client.rs +++ b/crates/oro-client/src/client.rs @@ -4,7 +4,7 @@ use std::{collections::HashMap, sync::Arc}; #[cfg(not(target_arch = "wasm32"))] use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache}; -use miette::Result; +#[cfg(target_arch = "wasm32")] use reqwest::Client; #[cfg(not(target_arch = "wasm32"))] use reqwest::ClientBuilder; @@ -14,12 +14,17 @@ use reqwest_middleware::ClientWithMiddleware; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; use url::Url; -use crate::{credentials::Credentials, OroClientError}; +#[cfg(not(target_arch = "wasm32"))] +use crate::OroClientError; +use crate::{ + auth_middleware::{self, AuthMiddleware}, + credentials::Credentials, +}; #[derive(Clone, Debug)] pub struct OroClientBuilder { registry: Url, - fetch_retries: u32, + retries: u32, credentials: HashMap, #[cfg(not(target_arch = "wasm32"))] cache: Option, @@ -45,9 +50,9 @@ impl Default for OroClientBuilder { #[cfg(not(target_arch = "wasm32"))] no_proxy_domain: None, #[cfg(not(test))] - fetch_retries: 2, + retries: 2, #[cfg(test)] - fetch_retries: 0, + retries: 0, } } } @@ -62,37 +67,38 @@ impl OroClientBuilder { self } - pub fn credentials(mut self, credentials: Vec<(String, String, String)>) -> Result { - let mut vars = HashMap::new(); - for (registry, key, value) in credentials.into_iter() { - if !vars.contains_key(®istry) { - vars.insert(registry.clone(), HashMap::new()); - } - let existing = vars - .get_mut(®istry) - .and_then(|reg| reg.insert(key.clone(), value.clone())); - if existing.is_some() { - Err(OroClientError::CredentialsConfigError(format!( - "Key \"{}\" already exists for registry {}", - key, registry - )))? - } - } - for (registry, config) in vars.into_iter() { - self.credentials.insert(registry, config.try_into()?); - } - Ok(self) + pub fn basic_auth(mut self, registry: Url, username: String, password: Option) -> Self { + self.credentials.insert( + auth_middleware::nerf_dart(®istry), + Credentials::Basic { username, password }, + ); + self } - #[cfg(not(target_arch = "wasm32"))] - pub fn cache(mut self, cache: impl AsRef) -> Self { - self.cache = Some(PathBuf::from(cache.as_ref())); + pub fn token_auth(mut self, registry: Url, token: String) -> Self { + self.credentials.insert( + auth_middleware::nerf_dart(®istry), + Credentials::Token(token), + ); + self + } + + pub fn legacy_auth(mut self, registry: Url, legacy_auth_token: String) -> Self { + self.credentials.insert( + auth_middleware::nerf_dart(®istry), + Credentials::EncodedBasic(legacy_auth_token), + ); + self + } + + pub fn retries(mut self, retries: u32) -> Self { + self.retries = retries; self } #[cfg(not(target_arch = "wasm32"))] - pub fn fetch_retries(mut self, fetch_retries: u32) -> Self { - self.fetch_retries = fetch_retries; + pub fn cache(mut self, cache: impl AsRef) -> Self { + self.cache = Some(PathBuf::from(cache.as_ref())); self } @@ -151,12 +157,14 @@ impl OroClientBuilder { client_core.build().expect("Fail to build HTTP client.") }; - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(self.fetch_retries); + let retry_policy = ExponentialBackoff::builder().build_with_max_retries(self.retries); let retry_strategy = RetryTransientMiddleware::new_with_policy(retry_policy); + let credentials = Arc::new(self.credentials); #[allow(unused_mut)] - let mut client_builder = - reqwest_middleware::ClientBuilder::new(client_raw.clone()).with(retry_strategy); + let mut client_builder = reqwest_middleware::ClientBuilder::new(client_raw.clone()) + .with(retry_strategy) + .with(AuthMiddleware(credentials.clone())); #[cfg(not(target_arch = "wasm32"))] if let Some(cache_loc) = self.cache { @@ -169,15 +177,15 @@ impl OroClientBuilder { })); } - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(self.fetch_retries); + let retry_policy = ExponentialBackoff::builder().build_with_max_retries(self.retries); let retry_strategy = RetryTransientMiddleware::new_with_policy(retry_policy); - let client_uncached_builder = - reqwest_middleware::ClientBuilder::new(client_raw).with(retry_strategy); + let client_uncached_builder = reqwest_middleware::ClientBuilder::new(client_raw) + .with(retry_strategy) + .with(AuthMiddleware(credentials)); OroClient { registry: Arc::new(self.registry), - #[cfg(not(target_arch = "wasm32"))] client: client_builder.build(), client_uncached: client_uncached_builder.build(), } diff --git a/crates/oro-client/src/credentials.rs b/crates/oro-client/src/credentials.rs index 054121a2..2634ad17 100644 --- a/crates/oro-client/src/credentials.rs +++ b/crates/oro-client/src/credentials.rs @@ -7,7 +7,10 @@ use crate::OroClientError; #[derive(Clone)] pub enum Credentials { /// HTTP basic auth credentials - Basic { username: String, password: String }, + Basic { + username: String, + password: Option, + }, /// HTTP basic auth credentials, pre-encoded EncodedBasic(String), /// HTTP Bearer token auth @@ -17,10 +20,9 @@ pub enum Credentials { impl Debug for Credentials { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Basic { - username, - password: _, - } => f.write_fmt(format_args!("Basic(username={},password=***)", username)), + Self::Basic { username, .. } => { + f.write_fmt(format_args!("Basic(username={},password=***)", username)) + } Self::EncodedBasic(_) => f.write_str("EncodedBasic(***)"), Self::Token(_) => f.write_str("Token(***)"), } @@ -33,12 +35,10 @@ impl TryFrom> for Credentials { fn try_from(value: HashMap) -> Result { if let Some(token) = value.get("token") { Ok(Self::Token(token.to_owned())) - } else if let (Some(username), Some(password)) = - (value.get("username"), value.get("password")) - { + } else if let (Some(username), password) = (value.get("username"), value.get("password")) { Ok(Self::Basic { username: username.to_owned(), - password: password.to_owned(), + password: password.map(|s| s.to_owned()), }) } else if let Some(auth) = value.get("auth") { Ok(Self::EncodedBasic(auth.to_owned())) diff --git a/crates/oro-client/src/lib.rs b/crates/oro-client/src/lib.rs index c3e4b396..70c37bd8 100644 --- a/crates/oro-client/src/lib.rs +++ b/crates/oro-client/src/lib.rs @@ -1,6 +1,7 @@ //! A general-use client for interacting with NPM registry APIs. mod api; +mod auth_middleware; mod client; mod credentials; mod error; @@ -8,5 +9,6 @@ mod notify; pub use api::login; pub use api::packument; +pub use auth_middleware::nerf_dart; pub use client::{OroClient, OroClientBuilder}; pub use error::OroClientError; diff --git a/crates/oro-config/src/lib.rs b/crates/oro-config/src/lib.rs index d0428544..a23439c7 100644 --- a/crates/oro-config/src/lib.rs +++ b/crates/oro-config/src/lib.rs @@ -85,7 +85,7 @@ impl OroConfigLayerExt for Command { ValueKind::Table(map) => { for (k, v) in map { args.push(OsString::from(format!("--{}", opt))); - args.push(OsString::from(format!("{key}:{k}={v}"))); + args.push(OsString::from(format!("{{{key}}}{k}={v}"))); } } // TODO: error if val.kind is an Array diff --git a/crates/oro-npm-account/Cargo.toml b/crates/oro-npm-account/Cargo.toml index de644886..c4b6a274 100644 --- a/crates/oro-npm-account/Cargo.toml +++ b/crates/oro-npm-account/Cargo.toml @@ -17,9 +17,10 @@ oro-client = { version = "=0.3.29", path = "../oro-client" } async-std = { workspace = true } base64 = { workspace = true } dialoguer = { workspace = true } -open = { workspace = true } -reqwest = { workspace = true } kdl = { workspace = true } -url = { workspace = true } miette = { workspace = true } +open = { workspace = true } +reqwest = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } diff --git a/crates/oro-npm-account/src/config.rs b/crates/oro-npm-account/src/config.rs index 569ad8f7..82b67532 100644 --- a/crates/oro-npm-account/src/config.rs +++ b/crates/oro-npm-account/src/config.rs @@ -1,18 +1,19 @@ use base64::{engine::general_purpose, Engine as _}; use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue}; use reqwest::header::HeaderValue; +use url::Url; pub enum Credentials { - AuthToken(String), + Token(String), /// Decryptable username and password combinations - Auth(String), - UsernameAndPassword { + LegacyAuth(String), + BasicAuth { username: String, - password: String, + password: Option, }, } -pub fn set_credentials_by_uri(uri: &str, credentials: &Credentials, config: &mut KdlDocument) { +pub fn set_credentials_by_uri(uri: &Url, credentials: &Credentials, config: &mut KdlDocument) { if config.get_mut("options").is_none() { config.nodes_mut().push(KdlNode::new("options")); } @@ -33,33 +34,35 @@ pub fn set_credentials_by_uri(uri: &str, credentials: &Credentials, config: &mut .and_then(|user| user.children_mut().as_mut()) { match credentials { - Credentials::AuthToken(auth_token) => { + Credentials::Token(auth_token) => { let current_node = user.nodes_mut(); - let mut node = KdlNode::new(uri); + let mut node = KdlNode::new(uri.as_ref()); clean_auth_nodes(uri, current_node); - node.push(KdlEntry::new_prop("auth-token", auth_token.as_ref())); + node.push(KdlEntry::new_prop("token", auth_token.as_ref())); current_node.push(node); } - Credentials::Auth(token) => { + Credentials::LegacyAuth(token) => { let current_node = user.nodes_mut(); - let mut node = KdlNode::new(uri); + let mut node = KdlNode::new(uri.as_ref()); clean_auth_nodes(uri, current_node); - node.push(KdlEntry::new_prop("auth", token.as_ref())); + node.push(KdlEntry::new_prop("legacy-auth", token.as_ref())); current_node.push(node); } - Credentials::UsernameAndPassword { username, password } => { + Credentials::BasicAuth { username, password } => { let current_node = user.nodes_mut(); - let mut node = KdlNode::new(uri); + let mut node = KdlNode::new(uri.as_ref()); clean_auth_nodes(uri, current_node); node.push(KdlEntry::new_prop("username", username.as_ref())); - node.push(KdlEntry::new_prop("password", password.as_ref())); + if let Some(pass) = password { + node.push(KdlEntry::new_prop("password", pass.as_ref())); + } current_node.push(node); } } } } -pub fn set_scoped_registry(scope: &str, registry: &str, config: &mut KdlDocument) { +pub fn set_scoped_registry(scope: &str, registry: &Url, config: &mut KdlDocument) { if config.get_mut("options").is_none() { config.nodes_mut().push(KdlNode::new("options")); } @@ -85,57 +88,79 @@ pub fn set_scoped_registry(scope: &str, registry: &str, config: &mut KdlDocument let current_node = scoped_registries.nodes_mut(); let mut node = KdlNode::new(scope); clean_scoped_registry_nodes(scope, current_node); - node.push(KdlValue::String(registry.to_owned())); + node.push(KdlValue::String(registry.as_ref().to_owned())); current_node.push(node); } } -pub fn get_credentials_by_uri(uri: &str, config: &KdlDocument) -> Option { +pub fn get_credentials_by_uri(uri: &Url, config: &KdlDocument) -> Option { config .get("options") .and_then(|options| options.children()) .and_then(|options_children| options_children.get("auth")) .and_then(|user| user.children()) - .and_then(|user_children| user_children.get(uri)) + .and_then(|user_children| { + user_children.nodes().iter().find(|node| { + let Ok(node_url) = Url::parse(node.name().value()) else { + return false; + }; + oro_client::nerf_dart(&node_url) == oro_client::nerf_dart(uri) + }) + }) .and_then(|credentials| { - let token = credentials.get("auth"); - let auth_token = credentials.get("auth-token"); + let token = credentials.get("token"); + let legacy_auth = credentials.get("legacy-auth"); let username = credentials.get("username"); let password = credentials.get("password"); - match (token, auth_token, username, password) { + match (token, legacy_auth, username, password) { + (_, Some(token), ..) => Some(Credentials::Token(token.as_string()?.into())), (.., Some(username), Some(password)) => { let username = username.as_string()?; let password = password.as_string()?; let password = general_purpose::STANDARD.decode(password).ok()?; let password = String::from_utf8_lossy(&password).to_string(); - Some(Credentials::Auth( + Some(Credentials::LegacyAuth( general_purpose::STANDARD.encode(format!("{username}:{password}")), )) } - (_, Some(auth_token), ..) => { - Some(Credentials::AuthToken(auth_token.as_string()?.into())) + (Some(legacy_auth), ..) => { + Some(Credentials::LegacyAuth(legacy_auth.as_string()?.into())) } - (Some(token), ..) => Some(Credentials::Auth(token.as_string()?.into())), _ => None, } }) } -pub fn clear_crendentials_by_uri(uri: &str, config: &mut KdlDocument) { - if let Some(user_children) = config +pub fn clear_crendentials_by_uri(uri: &Url, config: &mut KdlDocument) { + if let Some(auth_children) = config .get_mut("options") .and_then(|options| options.children_mut().as_mut()) - .and_then(|options_children| options_children.get_mut("user")) - .and_then(|user| user.children_mut().as_mut()) + .and_then(|options_children| options_children.get_mut("auth")) + .and_then(|auth| auth.children_mut().as_mut()) { - clean_auth_nodes(uri, user_children.nodes_mut()); + clean_auth_nodes(uri, auth_children.nodes_mut()); + if auth_children.nodes().is_empty() { + if let Some(children) = config + .get_mut("options") + .and_then(|options| options.children_mut().as_mut()) + { + children + .nodes_mut() + .retain_mut(|node| node.name().value() != "auth"); + } + } }; } -fn clean_auth_nodes(uri: &str, nodes: &mut Vec) { - nodes.retain_mut(|node| node.name().value() != uri); +fn clean_auth_nodes(uri: &Url, nodes: &mut Vec) { + nodes.retain_mut(|node| { + let Ok(node_url) = Url::parse(node.name().value()) else { + return false; + }; + oro_client::nerf_dart(&node_url) != oro_client::nerf_dart(uri) + }); } fn clean_scoped_registry_nodes(scope: &str, nodes: &mut Vec) { @@ -147,10 +172,10 @@ impl TryFrom for HeaderValue { fn try_from(value: Credentials) -> Result { match value { - Credentials::AuthToken(auth_token) => { + Credentials::Token(auth_token) => { Ok(HeaderValue::from_str(&format!("Bearer {auth_token}"))?) } - Credentials::Auth(auth) => Ok(HeaderValue::from_str(&format!("Basic {auth}"))?), + Credentials::LegacyAuth(auth) => Ok(HeaderValue::from_str(&format!("Basic {auth}"))?), _ => Err(Self::Error::UnsupportedConversionError), } } diff --git a/crates/oro-npm-account/src/login.rs b/crates/oro-npm-account/src/login.rs index bcc0b9c9..abb97594 100644 --- a/crates/oro-npm-account/src/login.rs +++ b/crates/oro-npm-account/src/login.rs @@ -11,10 +11,15 @@ pub async fn login( registry: &Url, options: &LoginOptions, ) -> Result { - let client = OroClient::new(registry.clone()); + let client = options + .client + .clone() + .unwrap_or_else(|| OroClient::new(registry.clone())); match auth_type { AuthType::Web => { let login_web = client.login_web(options).await?; + // TODO: make clickable in supported terminals. + tracing::info!("Login URL: {}", login_web.login_url); open(login_web.login_url).map_err(OroNpmAccountError::OpenURLError)?; loop { diff --git a/src/apply_args.rs b/src/apply_args.rs index 8f9f3856..6551efbf 100644 --- a/src/apply_args.rs +++ b/src/apply_args.rs @@ -10,6 +10,8 @@ use tracing::{Instrument, Span}; use tracing_indicatif::span_ext::IndicatifSpanExt; use url::Url; +use crate::nassun_args::NassunArgs; + /// Applies the current project's requested dependencies to `node_modules/`, /// adding, removing, and updating dependencies as needed. This command is /// intended to be an idempotent way to make sure your `node_modules` is in @@ -90,36 +92,35 @@ pub struct ApplyArgs { #[arg(from_global)] pub registry: Url, - /// The credentials map contained in the config. This is handed #[arg(from_global)] - pub credentials: Vec<(String, String, String)>, + pub scoped_registries: Vec<(String, Url)>, #[arg(from_global)] - pub scoped_registries: Vec<(String, Url)>, + pub proxy: bool, #[arg(from_global)] - pub json: bool, + pub proxy_url: Option, #[arg(from_global)] - pub root: PathBuf, + pub no_proxy_domain: Option, #[arg(from_global)] - pub cache: Option, + pub retries: u32, #[arg(from_global)] - pub emoji: bool, + pub auth: Vec<(String, String, String)>, #[arg(from_global)] - pub proxy: bool, + pub json: bool, #[arg(from_global)] - pub proxy_url: Option, + pub root: PathBuf, #[arg(from_global)] - pub no_proxy_domain: Option, + pub cache: Option, #[arg(from_global)] - pub fetch_retries: u32, + pub emoji: bool, } impl ApplyArgs { @@ -132,7 +133,9 @@ impl ApplyArgs { } let root = &self.root; - let maintainer = self.resolve(manifest, self.configured_maintainer()).await?; + let maintainer = self + .resolve(manifest, self.configured_maintainer()?) + .await?; if !self.lockfile_only { self.prune(&maintainer).await?; @@ -164,20 +167,18 @@ impl ApplyArgs { Ok(()) } - fn configured_maintainer(&self) -> NodeMaintainerOptions { + fn configured_maintainer(&self) -> Result { let root = &self.root; + let nassun = NassunArgs::from_apply_args(self).to_nassun()?; let mut nm = NodeMaintainerOptions::new(); nm = nm - .registry(self.registry.clone()) - .credentials(self.credentials.clone()) + .nassun(nassun) .locked(self.locked) - .default_tag(&self.default_tag) .concurrency(self.concurrency) .script_concurrency(self.script_concurrency) .root(root) .prefer_copy(self.prefer_copy) .hoisted(self.hoisted) - .proxy(self.proxy) .on_resolution_added(move || { Span::current().pb_inc_length(1); }) @@ -213,23 +214,11 @@ impl ApplyArgs { span.pb_set_message(line); }); - if let Some(no_proxy_domain) = self.no_proxy_domain.as_deref() { - nm = nm.no_proxy_domain(no_proxy_domain); - } - - if let Some(proxy_url) = self.proxy_url.as_deref() { - nm = nm.proxy_url(proxy_url); - } - - for (scope, registry) in &self.scoped_registries { - nm = nm.scope_registry(scope, registry.clone()); - } - if let Some(cache) = self.cache.as_deref() { nm = nm.cache(cache); } - nm + Ok(nm) } async fn resolve( diff --git a/src/client_args.rs b/src/client_args.rs new file mode 100644 index 00000000..5d80a312 --- /dev/null +++ b/src/client_args.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; + +use clap::Args; +use oro_client::{OroClientBuilder, OroClientError}; +use url::Url; + +use crate::{apply_args::ApplyArgs, nassun_args::NassunArgs}; + +#[derive(Debug, Args)] +pub struct ClientArgs { + #[arg(from_global)] + pub cache: Option, + + #[arg(from_global)] + pub proxy: bool, + + #[arg(from_global)] + pub proxy_url: Option, + + #[arg(from_global)] + pub no_proxy_domain: Option, + + #[arg(from_global)] + pub retries: u32, + + #[arg(from_global)] + pub auth: Vec<(String, String, String)>, +} + +impl From for ClientArgs { + fn from(value: ApplyArgs) -> Self { + Self { + cache: value.cache, + proxy: value.proxy, + proxy_url: value.proxy_url, + no_proxy_domain: value.no_proxy_domain, + retries: value.retries, + auth: value.auth, + } + } +} + +impl From for ClientArgs { + fn from(value: NassunArgs) -> Self { + Self { + cache: value.cache, + proxy: value.proxy, + proxy_url: value.proxy_url, + no_proxy_domain: value.no_proxy_domain, + retries: value.retries, + auth: value.auth, + } + } +} + +impl TryFrom for OroClientBuilder { + type Error = OroClientError; + fn try_from(value: ClientArgs) -> Result { + let mut builder = OroClientBuilder::new() + .retries(value.retries) + .proxy(value.proxy); + if let Some(cache) = value.cache { + builder = builder.cache(cache); + } + if let Some(domain) = value.no_proxy_domain { + builder = builder.no_proxy_domain(domain) + } + if let Some(url) = value.proxy_url { + builder = builder.proxy_url(url)?; + } + for (reg, key, val) in &value.auth { + let url = Url::parse(reg)?; + if key == "token" { + builder = builder.token_auth(url, val.into()); + } else if key == "username" { + let mut password = None; + for (reg2, key2, val2) in &value.auth { + if reg2 == reg && key2 == "password" { + password = Some(val2.to_owned()); + break; + } + } + builder = builder.basic_auth(url, val.into(), password); + } else if key == "legacy-auth" { + builder = builder.legacy_auth(url, val.into()); + } else if key == "password" { + } else { + tracing::warn!("Invalid authentication configuration for {reg}: {key} {val}"); + } + } + Ok(builder) + } +} diff --git a/src/commands/add.rs b/src/commands/add.rs index e9f11415..47c10302 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -44,7 +44,7 @@ impl OroCommand for AddCmd { .into_diagnostic()?, ) .into_diagnostic()?; - let nassun = NassunArgs::from_apply_args(&self.apply).to_nassun(); + let nassun = NassunArgs::from_apply_args(&self.apply).to_nassun()?; use PackageResolution as Pr; use PackageSpec as Ps; let mut count = 0; diff --git a/src/commands/login.rs b/src/commands/login.rs index cd00fb9c..28a3de61 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,3 +1,4 @@ +use crate::client_args::ClientArgs; use crate::commands::OroCommand; use async_trait::async_trait; use clap::{clap_derive::ValueEnum, Args}; @@ -5,6 +6,7 @@ use directories::ProjectDirs; use kdl::KdlDocument; use miette::{IntoDiagnostic, Result}; use oro_client::login::{AuthType, LoginOptions}; +use oro_client::OroClientBuilder; use oro_npm_account::config::{self, Credentials}; use oro_npm_account::login::login; use std::path::PathBuf; @@ -41,56 +43,49 @@ pub struct LoginCmd { /// Associate an operation with a scope for a scoped registry. #[arg(long)] scope: Option, + + #[command(flatten)] + client_args: ClientArgs, } #[async_trait] impl OroCommand for LoginCmd { async fn execute(self) -> Result<()> { - if let Some(config_dir) = &self - .config - .map(|config_path| { - config_path - .parent() - .expect("must have a parent") - .to_path_buf() - }) - .or(ProjectDirs::from("", "", "orogene") - .map(|config| config.config_dir().to_path_buf())) - { - let registry = self.registry.to_string(); - if !config_dir.exists() { - std::fs::create_dir_all(config_dir).unwrap(); - } - let config_path = config_dir.join("oro.kdl"); - let mut config: KdlDocument = std::fs::read_to_string(&config_path) + if let Some(config_path) = &self.config.or_else(|| { + ProjectDirs::from("", "", "orogene") + .map(|config| config.config_dir().to_path_buf().join("oro.kdl")) + }) { + std::fs::create_dir_all(config_path.parent().expect("must have parent")).unwrap(); + let mut config: KdlDocument = std::fs::read_to_string(config_path) .into_diagnostic()? .parse()?; - tracing::info!("Login in on {}", ®istry); + tracing::info!("Logging in to {}", self.registry); + + let builder: OroClientBuilder = self.client_args.try_into()?; let token = login( &self.auth_type.into(), &self.registry, &LoginOptions { scope: self.scope.clone(), + client: Some(builder.registry(self.registry.clone()).build()), }, ) .await .into_diagnostic()?; - tracing::info!("Logged in on {}", ®istry); - config::set_credentials_by_uri( - ®istry, - &Credentials::AuthToken(token.token), + &self.registry, + &Credentials::Token(token.token), &mut config, ); if let Some(scope) = self.scope { - config::set_scoped_registry(&scope, ®istry, &mut config); + config::set_scoped_registry(&scope, &self.registry, &mut config); } - std::fs::write(&config_path, config.to_string()).into_diagnostic()?; + std::fs::write(config_path, config.to_string()).into_diagnostic()?; } Ok(()) } diff --git a/src/commands/logout.rs b/src/commands/logout.rs index cf48a0aa..dfd63a3f 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -1,10 +1,10 @@ -use crate::commands::OroCommand; +use crate::{client_args::ClientArgs, commands::OroCommand}; use async_trait::async_trait; use clap::Args; use directories::ProjectDirs; use kdl::KdlDocument; use miette::{IntoDiagnostic, Result}; -use oro_client::OroClient; +use oro_client::OroClientBuilder; use oro_npm_account::config::{self, Credentials}; use std::path::PathBuf; use url::Url; @@ -17,43 +17,34 @@ pub struct LogoutCmd { #[arg(from_global)] config: Option, + + #[command(flatten)] + client_args: ClientArgs, } #[async_trait] impl OroCommand for LogoutCmd { async fn execute(self) -> Result<()> { - if let Some(config_dir) = &self - .config - .map(|config_path| { - config_path - .parent() - .expect("must have a parent") - .to_path_buf() - }) - .or(ProjectDirs::from("", "", "orogene") - .map(|config| config.config_dir().to_path_buf())) - { - let client = OroClient::new(self.registry.clone()); - let registry = self.registry.to_string(); - if !config_dir.exists() { - std::fs::create_dir_all(config_dir).unwrap(); - } - let config_path = config_dir.join("oro.kdl"); - let mut config: KdlDocument = std::fs::read_to_string(&config_path) + if let Some(config_path) = &self.config.or_else(|| { + ProjectDirs::from("", "", "orogene") + .map(|config| config.config_dir().to_path_buf().join("oro.kdl")) + }) { + let builder: OroClientBuilder = self.client_args.try_into()?; + let client = builder.registry(self.registry.clone()).build(); + std::fs::create_dir_all(config_path.parent().expect("must have parent")) + .into_diagnostic()?; + let mut config: KdlDocument = std::fs::read_to_string(config_path) .into_diagnostic()? .parse()?; - match config::get_credentials_by_uri(®istry, &config) { - Some(Credentials::AuthToken(token)) => { - client.delete_token(&token).await.into_diagnostic()?; - } - _ => { - tracing::error!("Not logged in to {registry}, so can't log out!"); - } + if let Some(Credentials::Token(token)) = + config::get_credentials_by_uri(&self.registry, &config) + { + client.delete_token(&token).await.into_diagnostic()?; } - config::clear_crendentials_by_uri(®istry, &mut config); - std::fs::write(&config_path, config.to_string()).into_diagnostic()?; + config::clear_crendentials_by_uri(&self.registry, &mut config); + std::fs::write(config_path, config.to_string()).into_diagnostic()?; } Ok(()) } diff --git a/src/commands/ping.rs b/src/commands/ping.rs index dd8b335f..84bea301 100644 --- a/src/commands/ping.rs +++ b/src/commands/ping.rs @@ -3,11 +3,11 @@ use std::time::Instant; use async_trait::async_trait; use clap::Args; use miette::{IntoDiagnostic, Result, WrapErr}; -use oro_client::{self, OroClient}; +use oro_client::{self, OroClientBuilder}; use serde_json::Value; use url::Url; -use crate::commands::OroCommand; +use crate::{client_args::ClientArgs, commands::OroCommand}; /// Ping the registry. #[derive(Debug, Args)] @@ -20,6 +20,9 @@ pub struct PingCmd { #[arg(from_global)] emoji: bool, + + #[command(flatten)] + client_args: ClientArgs, } #[async_trait] @@ -28,7 +31,8 @@ impl OroCommand for PingCmd { let start = Instant::now(); let registry = self.registry; tracing::info!("{}ping: {registry}", if self.emoji { "➡️ " } else { "" }); - let client = OroClient::new(registry.clone()); + let client_builder: OroClientBuilder = self.client_args.try_into()?; + let client = client_builder.registry(registry.clone()).build(); let payload = client.ping().await?; let time = start.elapsed().as_micros() as f32 / 1000.0; tracing::info!("{}pong: {time}ms", if self.emoji { "⬅️ " } else { "" }); diff --git a/src/commands/view.rs b/src/commands/view.rs index 82a87a08..6c1d262a 100644 --- a/src/commands/view.rs +++ b/src/commands/view.rs @@ -27,7 +27,7 @@ pub struct ViewCmd { #[async_trait] impl OroCommand for ViewCmd { async fn execute(self) -> Result<()> { - let pkg = self.nassun_args.to_nassun().resolve(&self.pkg).await?; + let pkg = self.nassun_args.to_nassun()?.resolve(&self.pkg).await?; let packument = pkg.packument().await?; let metadata = pkg.metadata().await?; // TODO: oro view pkg [[....]] diff --git a/src/lib.rs b/src/lib.rs index 546f54fb..71698fa5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,6 +114,7 @@ use commands::OroCommand; pub use error::OroError; mod apply_args; +mod client_args; mod commands; mod error; mod nassun_args; @@ -165,15 +166,17 @@ pub struct Orogene { /// provide credentials for multiple registries at a time, and different /// credential fields for a registry. /// - /// The syntax is `--credentials my.registry.com:username=foo - /// --credentials my.registry.com:password=sekrit`. + /// The syntax is `--auth {my.registry.com}token=deadbeef + /// --auth {my.registry.com}username=myuser`. + /// + /// Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #[arg( help_heading = "Global Options", global = true, long, value_parser = parse_nested_key_value:: )] - credentials: Vec<(String, String, String)>, + auth: Vec<(String, String, String)>, /// Location of disk cache. /// @@ -293,14 +296,14 @@ pub struct Orogene { )] no_proxy_domain: Option, - /// Package will retry when network failed. + /// How many times to retry failed network operations. #[arg( help_heading = "Global Options", global = true, long, default_value_t = 2 )] - fetch_retries: u32, + retries: u32, } impl Orogene { @@ -506,8 +509,12 @@ impl Orogene { // We skip first-time-setup operations in CI entirely. if self.first_time && !is_ci::cached() { tracing::info!("Performing first-time setup..."); - if let Some(dirs) = ProjectDirs::from("", "", "orogene") { - let config_dir = dirs.config_dir(); + if let Some(config_path) = self + .config + .clone() + .or_else(|| ProjectDirs::from("", "", "orogene").map(|p| p.config_dir().to_owned())) + { + let config_dir = config_path.parent().expect("must have parent"); if !config_dir.exists() { std::fs::create_dir_all(config_dir).unwrap(); } @@ -799,16 +806,19 @@ where V: std::str::FromStr, V::Err: std::error::Error + Send + Sync + 'static, { - let colon_pos = s - .find(':') - .ok_or_else(|| format!("invalid TOP_KEY:NESTED_KEY=VALUE entry: no `:` found in `{s}`",))?; - let eq_pos = s - .find('=') - .ok_or_else(|| format!("invalid TOP_KEY:NESTED_KEY=VALUE entry: no `=` found in `{s}`"))?; + let open_pos = s.find('{').ok_or_else(|| { + format!("invalid {{TOP_KEY}}NESTED_KEY=VALUE entry: no `{{` found in `{s}`",) + })?; + let close_pos = s.find('}').ok_or_else(|| { + format!("invalid {{TOP_KEY}}NESTED_KEY=VALUE entry: no `}}` found in `{s}`",) + })?; + let eq_pos = s.find('=').ok_or_else(|| { + format!("invalid {{TOP_KEY}}NESTED_KEY=VALUE entry: no `=` found in `{s}`") + })?; Ok(( - s[..colon_pos].parse()?, - s[colon_pos + 1..eq_pos].parse()?, + s[open_pos + 1..close_pos].parse()?, + s[close_pos + 1..eq_pos].parse()?, s[eq_pos + 1..].parse()?, )) } diff --git a/src/nassun_args.rs b/src/nassun_args.rs index 48dfd47b..38513c56 100644 --- a/src/nassun_args.rs +++ b/src/nassun_args.rs @@ -1,31 +1,45 @@ use std::path::PathBuf; use clap::Args; +use miette::Result; use nassun::{Nassun, NassunOpts}; +use oro_client::OroClientBuilder; use url::Url; -use crate::apply_args::ApplyArgs; +use crate::{apply_args::ApplyArgs, client_args::ClientArgs}; -#[derive(Debug, Args)] +#[derive(Clone, Debug, Args)] pub struct NassunArgs { /// Default dist-tag to use when resolving package versions. #[arg(long, default_value = "latest")] - default_tag: String, + pub default_tag: String, #[arg(from_global)] - registry: Url, + pub registry: Url, #[arg(from_global)] - credentials: Vec<(String, String, String)>, + pub scoped_registries: Vec<(String, Url)>, #[arg(from_global)] - scoped_registries: Vec<(String, Url)>, + pub root: PathBuf, #[arg(from_global)] - root: PathBuf, + pub cache: Option, #[arg(from_global)] - cache: Option, + pub proxy: bool, + + #[arg(from_global)] + pub proxy_url: Option, + + #[arg(from_global)] + pub no_proxy_domain: Option, + + #[arg(from_global)] + pub retries: u32, + + #[arg(from_global)] + pub auth: Vec<(String, String, String)>, } impl NassunArgs { @@ -33,25 +47,31 @@ impl NassunArgs { Self { default_tag: apply_args.default_tag.clone(), registry: apply_args.registry.clone(), - credentials: apply_args.credentials.clone(), scoped_registries: apply_args.scoped_registries.clone(), root: apply_args.root.clone(), cache: apply_args.cache.clone(), + proxy: apply_args.proxy, + proxy_url: apply_args.proxy_url.clone(), + no_proxy_domain: apply_args.no_proxy_domain.clone(), + retries: apply_args.retries, + auth: apply_args.auth.clone(), } } - pub fn to_nassun(&self) -> Nassun { + pub fn to_nassun(&self) -> Result { + let client_args: ClientArgs = ((*self).clone()).into(); + let client_builder: OroClientBuilder = client_args.try_into()?; let mut nassun_opts = NassunOpts::new() .registry(self.registry.clone()) .base_dir(self.root.clone()) - .default_tag(&self.default_tag); + .default_tag(&self.default_tag) + .client(client_builder.build()); for (scope, registry) in &self.scoped_registries { nassun_opts = nassun_opts.scope_registry(scope.clone(), registry.clone()); } - nassun_opts = nassun_opts.credentials(self.credentials.clone()); if let Some(cache) = &self.cache { nassun_opts = nassun_opts.cache(cache.clone()); } - nassun_opts.build() + Ok(nassun_opts.build()) } } diff --git a/tests/snapshots/help__add.snap b/tests/snapshots/help__add.snap index 8a4fff8f..172e2361 100644 --- a/tests/snapshots/help__add.snap +++ b/tests/snapshots/help__add.snap @@ -1,6 +1,5 @@ --- source: tests/help.rs -assertion_line: 7 expression: "sub_md(\"add\")" --- stderr: @@ -134,11 +133,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -208,9 +209,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__apply.snap b/tests/snapshots/help__apply.snap index c56abea3..1fa18210 100644 --- a/tests/snapshots/help__apply.snap +++ b/tests/snapshots/help__apply.snap @@ -1,6 +1,5 @@ --- source: tests/help.rs -assertion_line: 12 expression: "sub_md(\"apply\")" --- stderr: @@ -114,11 +113,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -188,9 +189,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__login.snap b/tests/snapshots/help__login.snap index 2f19e7c5..e68b8f15 100644 --- a/tests/snapshots/help__login.snap +++ b/tests/snapshots/help__login.snap @@ -58,11 +58,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -132,9 +134,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__logout.snap b/tests/snapshots/help__logout.snap index d9bf5c6e..5d67e9f0 100644 --- a/tests/snapshots/help__logout.snap +++ b/tests/snapshots/help__logout.snap @@ -47,11 +47,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -121,9 +123,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__ping.snap b/tests/snapshots/help__ping.snap index ab64182d..0c14bd49 100644 --- a/tests/snapshots/help__ping.snap +++ b/tests/snapshots/help__ping.snap @@ -1,6 +1,5 @@ --- source: tests/help.rs -assertion_line: 17 expression: "sub_md(\"ping\")" --- stderr: @@ -48,11 +47,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -122,9 +123,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__reapply.snap b/tests/snapshots/help__reapply.snap index 553bba0e..b26d4f41 100644 --- a/tests/snapshots/help__reapply.snap +++ b/tests/snapshots/help__reapply.snap @@ -1,6 +1,5 @@ --- source: tests/help.rs -assertion_line: 22 expression: "sub_md(\"reapply\")" --- stderr: @@ -110,11 +109,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -184,9 +185,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__remove.snap b/tests/snapshots/help__remove.snap index b8d1e1ad..d7d4a623 100644 --- a/tests/snapshots/help__remove.snap +++ b/tests/snapshots/help__remove.snap @@ -1,6 +1,5 @@ --- source: tests/help.rs -assertion_line: 27 expression: "sub_md(\"remove\")" --- stderr: @@ -118,11 +117,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -192,9 +193,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2] diff --git a/tests/snapshots/help__view.snap b/tests/snapshots/help__view.snap index 4b3e1f4f..24292596 100644 --- a/tests/snapshots/help__view.snap +++ b/tests/snapshots/help__view.snap @@ -1,6 +1,5 @@ --- source: tests/help.rs -assertion_line: 32 expression: "sub_md(\"view\")" --- stderr: @@ -62,11 +61,13 @@ Registry to use for a specific `@scope`, using `--scoped-registry @scope=https:/ Can be provided multiple times to specify multiple scoped registries. -#### `--credentials ` +#### `--auth ` Credentials to apply to registries when they're accessed. You can provide credentials for multiple registries at a time, and different credential fields for a registry. -The syntax is `--credentials my.registry.com:username=foo --credentials my.registry.com:password=sekrit`. +The syntax is `--auth {my.registry.com}token=deadbeef --auth {my.registry.com}username=myuser`. + +Valid auth fields are: `token`, `username`, `password`, and `legacy-auth`. #### `--cache ` @@ -136,9 +137,9 @@ Use commas to separate multiple entries, e.g. `.host1.com,.host2.com`. Can also be configured through the `NO_PROXY` environment variable, like `NO_PROXY=.host1.com`. -#### `--fetch-retries ` +#### `--retries ` -Package will retry when network failed +How many times to retry failed network operations \[default: 2]