From 29f233cb5aea95ef6acce854096b7808bd5f735d Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Sat, 20 Jun 2026 15:49:53 -0700 Subject: [PATCH] commands: native whoami/owner/token/search/pkg/set-script Add native implementations for the registry/account and manifest verbs that were previously routed to the npm-only fallback stub: - search: GET /-/v1/search (public), pnpm-shape human output + --json - whoami: GET /-/whoami with .npmrc bearer auth - owner ls/add/rm: collaborators GET + maintainers read-modify-write PUT - token list/create/revoke: /-/npm/v1/tokens CRUD - pkg get/set/delete/fix + set-script: local package.json edits via a ported property-path module (dot/bracket paths, prototype-pollution guard), reusing the atomic key-order-preserving manifest writer Registry endpoints live on RegistryClient (client/npm_verbs.rs), reusing the existing authed*/http_for* helpers. New command modules are additive; standalone aube's lib.rs dispatch still routes these verbs to npm_fallback, so default behavior is unchanged. An embedder (nub) calls the new commands::::run directly. User-facing login hints go through aube_util::cmd so they follow the active brand. Tests: property-path units, pkg manifest round-trips, and wiremock coverage for whoami/search/owners. --- crates/aube-registry/src/client.rs | 2 + crates/aube-registry/src/client/npm_verbs.rs | 362 +++++++++++++++++ crates/aube/src/commands/mod.rs | 7 + crates/aube/src/commands/owner.rs | 95 +++++ crates/aube/src/commands/pkg.rs | 282 ++++++++++++++ crates/aube/src/commands/property_path.rs | 390 +++++++++++++++++++ crates/aube/src/commands/search.rs | 126 ++++++ crates/aube/src/commands/set_script.rs | 58 +++ crates/aube/src/commands/token.rs | 134 +++++++ crates/aube/src/commands/whoami.rs | 36 ++ 10 files changed, 1492 insertions(+) create mode 100644 crates/aube-registry/src/client/npm_verbs.rs create mode 100644 crates/aube/src/commands/owner.rs create mode 100644 crates/aube/src/commands/pkg.rs create mode 100644 crates/aube/src/commands/property_path.rs create mode 100644 crates/aube/src/commands/search.rs create mode 100644 crates/aube/src/commands/set_script.rs create mode 100644 crates/aube/src/commands/token.rs create mode 100644 crates/aube/src/commands/whoami.rs diff --git a/crates/aube-registry/src/client.rs b/crates/aube-registry/src/client.rs index b6e190cda..a5b6ed0d3 100644 --- a/crates/aube-registry/src/client.rs +++ b/crates/aube-registry/src/client.rs @@ -9,6 +9,7 @@ mod dist_tags; mod endpoints; mod http; mod lifecycle; +mod npm_verbs; mod packument; mod parse; mod request; @@ -24,6 +25,7 @@ mod slow_tarball_tests; pub use cache::CachedPackumentLookup; use dist_tags::*; use http::*; +pub use npm_verbs::{Owner, TokenInfo}; use parse::parse_full_response; /// Accept header for packument requests. `vnd.npm.install-v1+json` is the diff --git a/crates/aube-registry/src/client/npm_verbs.rs b/crates/aube-registry/src/client/npm_verbs.rs new file mode 100644 index 000000000..123f807be --- /dev/null +++ b/crates/aube-registry/src/client/npm_verbs.rs @@ -0,0 +1,362 @@ +//! Registry endpoints backing the npm-compatible account/registry verbs +//! (`whoami`, `search`, `owner`, `token`). These reuse the same TLS +//! clients and `.npmrc` auth resolution as the dist-tag / deprecate +//! writes (`authed`/`authed_for_package`/`http_for*`), so private +//! registries and scoped auth Just Work. +//! +//! None of these touch the packument cache — they are account/registry +//! operations, not metadata reads. + +use super::RegistryClient; +use super::dist_tags::encoded_name; +use crate::Error; + +/// One package owner / maintainer (npm `-/package//collaborators` +/// and the packument `maintainers` array use this shape). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Owner { + #[serde(default)] + pub name: String, + #[serde(default)] + pub email: Option, +} + +/// One npm auth token, as returned by `GET /-/npm/v1/tokens`. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TokenInfo { + /// The token key (an opaque id used to revoke). + #[serde(default)] + pub key: String, + /// The masked token value (npm returns only the first/last chars). + #[serde(default)] + pub token: String, + #[serde(default)] + pub readonly: bool, + #[serde(default)] + pub created: Option, + /// CIDR allowlist for the token, if any. + #[serde(default, rename = "cidr_whitelist")] + pub cidr_whitelist: Option>, +} + +impl RegistryClient { + /// `GET {registry}/-/whoami` — return the authenticated username. + /// Requires a configured auth token; a 401 maps to + /// [`Error::Unauthorized`] so the command layer can point at login. + pub async fn fetch_whoami(&self) -> Result { + let registry_url = self.config.registry.clone(); + let url = format!("{}/-/whoami", registry_url.trim_end_matches('/')); + let resp = self + .authed_request(reqwest::Method::GET, &url, ®istry_url) + .header("Accept", "application/json") + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::Unauthorized); + } + let resp = resp.error_for_status()?; + #[derive(serde::Deserialize)] + struct Whoami { + username: String, + } + let who: Whoami = resp.json().await?; + Ok(who.username) + } + + /// `GET {registry}/-/v1/search?text=&size=` — full-text + /// package search. Public on npmjs (no auth required) but the token is + /// attached anyway so private registries that gate search still work. + /// Returns the raw `objects` array from the search response. + pub async fn search(&self, query: &str, limit: u32) -> Result, Error> { + let registry_url = self.config.registry.clone(); + let mut url = reqwest::Url::parse(&format!( + "{}/-/v1/search", + registry_url.trim_end_matches('/') + )) + .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)))?; + url.query_pairs_mut() + .append_pair("text", query) + .append_pair("size", &limit.to_string()); + + let resp = self + .authed_request(reqwest::Method::GET, url.as_str(), ®istry_url) + .header("Accept", "application/json") + .send() + .await? + .error_for_status()?; + + #[derive(serde::Deserialize)] + struct SearchResults { + #[serde(default)] + objects: Vec, + } + #[derive(serde::Deserialize)] + struct SearchObject { + package: serde_json::Value, + } + let results: SearchResults = resp.json().await?; + Ok(results.objects.into_iter().map(|o| o.package).collect()) + } + + /// `GET {registry}/-/package//collaborators` — list owners. npm + /// returns an object mapping `username` → permission (e.g. `"read-write"`); + /// we surface just the usernames. Falls back to the packument + /// `maintainers` array when the collaborators endpoint 404s (older / + /// non-npm registries). + pub async fn fetch_owners(&self, name: &str) -> Result, Error> { + let registry_url = self.registry_url_for(name).to_string(); + let url = format!( + "{}/-/package/{}/collaborators", + registry_url.trim_end_matches('/'), + encoded_name(name), + ); + let resp = self + .authed_get_for_package(&url, ®istry_url, name) + .header("Accept", "application/json") + .send() + .await?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + // Fall back to the packument's maintainers list. + return self.owners_from_packument(name).await; + } + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::Unauthorized); + } + let resp = resp.error_for_status()?; + // collaborators is `{ "user": "read-write", ... }`. + let map: std::collections::BTreeMap = resp.json().await?; + Ok(map + .into_keys() + .map(|name| Owner { name, email: None }) + .collect()) + } + + async fn owners_from_packument(&self, name: &str) -> Result, Error> { + let packument = self.fetch_packument_json_fresh(name).await?; + let maintainers = packument + .get("maintainers") + .and_then(|m| m.as_array()) + .cloned() + .unwrap_or_default(); + Ok(maintainers + .into_iter() + .filter_map(|m| serde_json::from_value::(m).ok()) + .collect()) + } + + /// Add or remove an owner by PUT-ing the modified maintainers list to + /// the packument (`{registry}//-rev/` semantics handled by + /// the registry on a full-document PUT — same mechanism `deprecate` + /// uses). `add=true` inserts `user`; `add=false` removes it. + pub async fn change_owner( + &self, + name: &str, + user: &str, + add: bool, + otp: Option<&str>, + ) -> Result<(), Error> { + let mut packument = self.fetch_packument_json_fresh(name).await?; + let obj = packument + .as_object_mut() + .ok_or_else(|| Error::RegistryWrite { + status: 0, + body: format!("registry response for {name} is not an object"), + })?; + + let mut maintainers: Vec = obj + .get("maintainers") + .and_then(|m| m.as_array()) + .cloned() + .unwrap_or_default(); + + if add { + let already = maintainers + .iter() + .any(|m| m.get("name").and_then(|n| n.as_str()) == Some(user)); + if !already { + maintainers.push(serde_json::json!({ "name": user })); + } + } else { + maintainers.retain(|m| m.get("name").and_then(|n| n.as_str()) != Some(user)); + } + obj.insert( + "maintainers".to_string(), + serde_json::Value::Array(maintainers), + ); + + self.put_packument(name, &packument, otp).await?; + Ok(()) + } + + /// `GET {registry}/-/npm/v1/tokens` — list the authenticated user's + /// auth tokens. Requires auth. + pub async fn list_tokens(&self) -> Result, Error> { + let registry_url = self.config.registry.clone(); + let url = format!("{}/-/npm/v1/tokens", registry_url.trim_end_matches('/')); + let resp = self + .authed_request(reqwest::Method::GET, &url, ®istry_url) + .header("Accept", "application/json") + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::Unauthorized); + } + let resp = resp.error_for_status()?; + #[derive(serde::Deserialize)] + struct TokenList { + #[serde(default)] + objects: Vec, + } + let list: TokenList = resp.json().await?; + Ok(list.objects) + } + + /// `POST {registry}/-/npm/v1/tokens` — create a new auth token. + /// `password` is the account password (npm's classic-token flow); + /// `read_only` and `cidr` map to the request body. Returns the raw + /// created-token document (the full token is only shown here, once). + pub async fn create_token( + &self, + password: &str, + read_only: bool, + cidr: &[String], + ) -> Result { + let registry_url = self.config.registry.clone(); + let url = format!("{}/-/npm/v1/tokens", registry_url.trim_end_matches('/')); + let body = serde_json::json!({ + "password": password, + "readonly": read_only, + "cidr_whitelist": cidr, + }); + let resp = self + .authed_request(reqwest::Method::POST, &url, ®istry_url) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&body) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::RegistryWrite { + status: status.as_u16(), + body, + }); + } + Ok(resp.json().await.unwrap_or(serde_json::Value::Null)) + } + + /// `DELETE {registry}/-/npm/v1/tokens/token/` — revoke a token by + /// its key (or a token-value prefix, which npm also accepts). Requires + /// auth. + pub async fn revoke_token(&self, key: &str) -> Result<(), Error> { + let registry_url = self.config.registry.clone(); + let url = format!( + "{}/-/npm/v1/tokens/token/{}", + registry_url.trim_end_matches('/'), + key, + ); + let resp = self + .authed_request(reqwest::Method::DELETE, &url, ®istry_url) + .send() + .await?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(Error::NotFound(format!("token {key}"))); + } + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::Unauthorized); + } + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::RegistryWrite { + status: status.as_u16(), + body, + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::client::RegistryClient; + use crate::config::NpmConfig; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn client_for(server: &MockServer) -> RegistryClient { + let config = NpmConfig { + registry: format!("{}/", server.uri()), + ..Default::default() + }; + RegistryClient::from_config(config) + } + + #[tokio::test] + async fn whoami_returns_username() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/-/whoami")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "username": "octocat" + }))) + .mount(&server) + .await; + let client = client_for(&server); + assert_eq!(client.fetch_whoami().await.unwrap(), "octocat"); + } + + #[tokio::test] + async fn whoami_401_maps_to_unauthorized() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/-/whoami")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let client = client_for(&server); + assert!(matches!( + client.fetch_whoami().await, + Err(crate::Error::Unauthorized) + )); + } + + #[tokio::test] + async fn search_returns_package_objects() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/-/v1/search")) + .and(query_param("text", "lodash")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "objects": [ + {"package": {"name": "lodash", "version": "4.17.21"}}, + {"package": {"name": "lodash.merge", "version": "4.6.2"}} + ] + }))) + .mount(&server) + .await; + let client = client_for(&server); + let results = client.search("lodash", 20).await.unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(results[0]["name"], "lodash"); + } + + #[tokio::test] + async fn owners_lists_collaborators() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/-/package/lodash/collaborators")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jdalton": "read-write", + "mathias": "read-write" + }))) + .mount(&server) + .await; + let client = client_for(&server); + let owners = client.fetch_owners("lodash").await.unwrap(); + let names: Vec<_> = owners.iter().map(|o| o.name.clone()).collect(); + assert_eq!(names, vec!["jdalton".to_string(), "mathias".to_string()]); + } +} diff --git a/crates/aube/src/commands/mod.rs b/crates/aube/src/commands/mod.rs index 86ef5b329..7cab5a150 100644 --- a/crates/aube/src/commands/mod.rs +++ b/crates/aube/src/commands/mod.rs @@ -39,11 +39,13 @@ pub mod logout; pub mod npm_fallback; pub mod npmrc; pub mod outdated; +pub mod owner; pub mod pack; pub mod patch; pub mod patch_commit; pub mod patch_remove; pub mod peers; +pub mod pkg; pub mod prune; pub mod publish; pub mod publish_provenance; @@ -57,15 +59,19 @@ pub mod run; pub mod run_output; pub mod runtime; pub mod sbom; +pub mod search; pub mod security_scanner; +pub mod set_script; pub mod sponsors; pub mod store; +pub mod token; pub mod undeprecate; pub mod unlink; pub mod unpublish; pub mod update; pub mod version; pub mod view; +pub mod whoami; pub mod why; mod auto_install; @@ -75,6 +81,7 @@ mod fs_helpers; mod manifest_io; mod package_spec; mod project_lock; +pub mod property_path; mod script_settings; mod settings_context; mod workspace_helpers; diff --git a/crates/aube/src/commands/owner.rs b/crates/aube/src/commands/owner.rs new file mode 100644 index 000000000..7f6cfd9a4 --- /dev/null +++ b/crates/aube/src/commands/owner.rs @@ -0,0 +1,95 @@ +//! `aube owner ls|add|rm []` — manage package maintainers on +//! the registry. Mirrors `npm owner` / `pnpm owner`. +//! +//! - `ls ` — list maintainers (no auth needed for public packages). +//! - `add ` / `rm ` — read-modify-write the +//! packument's `maintainers` array and PUT it back (the same authed +//! full-document write `deprecate` uses). Requires auth. + +use clap::{Args, Subcommand}; +use miette::miette; + +use crate::commands::make_client; + +#[derive(Debug, Args)] +pub struct OwnerArgs { + #[command(subcommand)] + pub command: OwnerCommand, + + /// One-time password from a 2FA authenticator (for add/rm). + #[arg(long, value_name = "CODE", global = true)] + pub otp: Option, + + #[command(flatten)] + pub network: crate::cli_args::NetworkArgs, +} + +#[derive(Debug, Subcommand)] +pub enum OwnerCommand { + /// List the maintainers of a package. + #[command(visible_alias = "list")] + Ls { package: String }, + /// Add a maintainer to a package. + Add { package: String, user: String }, + /// Remove a maintainer from a package. + #[command(visible_alias = "remove")] + Rm { package: String, user: String }, +} + +pub async fn run(args: OwnerArgs) -> miette::Result<()> { + args.network.install_overrides(); + let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let client = make_client(&cwd); + let otp = args.otp.as_deref(); + + match args.command { + OwnerCommand::Ls { package } => { + let owners = client.fetch_owners(&package).await.map_err(map_err)?; + if owners.is_empty() { + eprintln!("no maintainers found for {package}"); + return Ok(()); + } + for owner in owners { + match owner.email { + Some(email) if !email.is_empty() => println!("{} <{}>", owner.name, email), + _ => println!("{}", owner.name), + } + } + } + OwnerCommand::Add { package, user } => { + client + .change_owner(&package, &user, true, otp) + .await + .map_err(map_err)?; + println!("+{user}: {package}"); + } + OwnerCommand::Rm { package, user } => { + client + .change_owner(&package, &user, false, otp) + .await + .map_err(map_err)?; + println!("-{user}: {package}"); + } + } + Ok(()) +} + +fn map_err(e: aube_registry::Error) -> miette::Report { + match e { + aube_registry::Error::NotFound(n) => miette!("package not found: {n}"), + aube_registry::Error::Unauthorized => { + miette!( + "not authenticated — run `{}` first", + aube_util::cmd("login") + ) + } + aube_registry::Error::Forbidden { body } => { + if body.is_empty() { + miette!("registry rejected the request (insufficient permissions)") + } else { + miette!("registry rejected the request: {body}") + } + } + other => miette!("{other}"), + } +} diff --git a/crates/aube/src/commands/pkg.rs b/crates/aube/src/commands/pkg.rs new file mode 100644 index 000000000..c93ac5124 --- /dev/null +++ b/crates/aube/src/commands/pkg.rs @@ -0,0 +1,282 @@ +//! `aube pkg get|set|delete|fix` — read and edit fields of the local +//! `package.json`. Mirrors `npm pkg` / `pnpm pkg` (`@pnpm/pkg-manifest`). +//! +//! - `get [ ...]` — print a field. One key prints the raw string +//! value (or JSON when `--json`, or for non-string values); multiple +//! keys print a JSON object keyed by the requested paths; no key prints +//! the whole manifest. Missing single-key reads print an empty line. +//! - `set = ...` — set a field. `--json` parses each value as +//! JSON (so `set private=true --json` writes a boolean, not a string). +//! - `delete ...` — remove fields. +//! - `fix` — drop malformed `name`/`version`/dep-section/`bin` fields. +//! +//! Keys are dotted/bracketed property paths (`scripts.test`, +//! `contributors[0].name`); see [`super::property_path`]. +//! +//! All edits go through [`super::update_manifest_json_object`], which +//! preserves top-level key order and writes atomically. + +use clap::Args; +use miette::miette; +use serde_json::Value; + +use super::property_path; + +#[derive(Debug, Args)] +pub struct PkgArgs { + /// Subcommand: `get`, `set`, `delete`, or `fix`. + pub subcommand: String, + + /// Arguments for the subcommand: keys for `get`/`delete`, `key=value` + /// pairs for `set`, none for `fix`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, + + /// For `set`, parse each value as JSON. For `get` of a single key, + /// return its JSON-encoded form instead of the raw string. + #[arg(long)] + pub json: bool, + + /// Operate on the package.json in this directory (default: the + /// nearest project root, or the cwd). + #[arg(short = 'C', long, value_name = "DIR")] + pub dir: Option, +} + +pub async fn run(args: PkgArgs) -> miette::Result<()> { + let dir = match &args.dir { + Some(d) => d.clone(), + None => { + crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")) + } + }; + let manifest_path = dir.join("package.json"); + + match args.subcommand.as_str() { + "get" => pkg_get(&manifest_path, &args.args, args.json), + "set" => pkg_set(&manifest_path, &args.args, args.json), + "delete" => pkg_delete(&manifest_path, &args.args), + "fix" => pkg_fix(&manifest_path), + other => Err(miette!( + "unknown `pkg` subcommand {other:?} (expected get, set, delete, or fix)" + )), + } +} + +fn read_value(manifest_path: &std::path::Path) -> miette::Result { + let content = std::fs::read_to_string(manifest_path) + .map_err(|e| miette!("failed to read {}: {e}", manifest_path.display()))?; + serde_json::from_str(&content) + .map_err(|e| miette!("failed to parse {}: {e}", manifest_path.display())) +} + +fn pkg_get(manifest_path: &std::path::Path, keys: &[String], json: bool) -> miette::Result<()> { + let manifest = read_value(manifest_path)?; + + if keys.len() == 1 { + let segments = property_path::parse(&keys[0])?; + match property_path::get(&manifest, &segments) { + None => println!(), + Some(value) => { + if json { + println!( + "{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); + } else if let Value::String(s) = value { + println!("{s}"); + } else { + println!( + "{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); + } + } + } + return Ok(()); + } + + // Zero keys → whole manifest; multiple keys → object keyed by request. + let selected = select_keys(&manifest, keys)?; + println!( + "{}", + serde_json::to_string_pretty(&selected).unwrap_or_default() + ); + Ok(()) +} + +fn select_keys(manifest: &Value, keys: &[String]) -> miette::Result { + if keys.is_empty() { + return Ok(manifest.clone()); + } + let mut out = serde_json::Map::new(); + for key in keys { + let segments = property_path::parse(key)?; + let value = property_path::get(manifest, &segments) + .cloned() + .unwrap_or(Value::Null); + out.insert(key.clone(), value); + } + Ok(Value::Object(out)) +} + +fn pkg_set(manifest_path: &std::path::Path, args: &[String], json: bool) -> miette::Result<()> { + if args.is_empty() { + return Err(miette!("`pkg set` requires at least one key=value pair")); + } + super::update_manifest_json_object(manifest_path, |obj| { + let mut root = Value::Object(std::mem::take(obj)); + for arg in args { + let Some(eq) = arg.find('=') else { + return Err(miette!( + "invalid argument {arg:?}: expected key=value format" + )); + }; + let (key, raw) = arg.split_at(eq); + let raw = &raw[1..]; + let value = if json { + serde_json::from_str(raw) + .map_err(|_| miette!("failed to parse value as JSON: {raw:?}"))? + } else { + Value::String(raw.to_string()) + }; + let segments = property_path::parse(key)?; + property_path::set(&mut root, &segments, value)?; + } + if let Value::Object(map) = root { + *obj = map; + } + Ok(()) + }) +} + +fn pkg_delete(manifest_path: &std::path::Path, keys: &[String]) -> miette::Result<()> { + if keys.is_empty() { + return Err(miette!("`pkg delete` requires at least one key")); + } + super::update_manifest_json_object(manifest_path, |obj| { + let mut root = Value::Object(std::mem::take(obj)); + for key in keys { + let segments = property_path::parse(key)?; + property_path::delete(&mut root, &segments)?; + } + if let Value::Object(map) = root { + *obj = map; + } + Ok(()) + }) +} + +fn pkg_fix(manifest_path: &std::path::Path) -> miette::Result<()> { + super::update_manifest_json_object(manifest_path, |obj| { + // name/version must be strings. + if obj.get("name").is_some_and(|v| !v.is_string()) { + obj.remove("name"); + } + if obj.get("version").is_some_and(|v| !v.is_string()) { + obj.remove("version"); + } + // Dep sections and scripts must be plain objects. + for field in [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + "scripts", + ] { + if obj.get(field).is_some_and(|v| !v.is_object()) { + obj.remove(field); + } + } + // bin must be a string or an object. + if obj + .get("bin") + .is_some_and(|v| !v.is_string() && !v.is_object()) + { + obj.remove("bin"); + } + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_manifest(dir: &std::path::Path, body: &str) -> std::path::PathBuf { + let path = dir.join("package.json"); + std::fs::write(&path, body).unwrap(); + path + } + + #[test] + fn set_creates_nested_and_preserves_top_level_order() { + let tmp = tempfile::tempdir().unwrap(); + let path = write_manifest( + tmp.path(), + "{\n \"name\": \"x\",\n \"version\": \"1.0.0\"\n}\n", + ); + + pkg_set( + &path, + &[ + "scripts.test=vitest".to_string(), + "private=true".to_string(), + ], + false, + ) + .unwrap(); + + let written: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written["scripts"]["test"], "vitest"); + // Without --json, `true` is written as the string "true". + assert_eq!(written["private"], "true"); + // Top-level order preserved (name, version come first). + let keys: Vec<&String> = written.as_object().unwrap().keys().collect(); + assert_eq!(keys[0], "name"); + assert_eq!(keys[1], "version"); + } + + #[test] + fn set_json_parses_value_types() { + let tmp = tempfile::tempdir().unwrap(); + let path = write_manifest(tmp.path(), "{}\n"); + pkg_set(&path, &["private=true".to_string()], true).unwrap(); + let written: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written["private"], serde_json::json!(true)); + } + + #[test] + fn delete_removes_nested_keys() { + let tmp = tempfile::tempdir().unwrap(); + let path = write_manifest( + tmp.path(), + "{\n \"scripts\": {\n \"test\": \"vitest\",\n \"build\": \"tsc\"\n }\n}\n", + ); + pkg_delete(&path, &["scripts.test".to_string()]).unwrap(); + let written: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(written["scripts"].get("test").is_none()); + assert_eq!(written["scripts"]["build"], "tsc"); + } + + #[test] + fn fix_drops_malformed_fields() { + let tmp = tempfile::tempdir().unwrap(); + let path = write_manifest( + tmp.path(), + "{\n \"name\": 5,\n \"version\": \"1.0.0\",\n \"scripts\": \"oops\"\n}\n", + ); + pkg_fix(&path).unwrap(); + let written: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(written.get("name").is_none(), "non-string name dropped"); + assert_eq!(written["version"], "1.0.0", "valid version kept"); + assert!( + written.get("scripts").is_none(), + "non-object scripts dropped" + ); + } +} diff --git a/crates/aube/src/commands/property_path.rs b/crates/aube/src/commands/property_path.rs new file mode 100644 index 000000000..741573d50 --- /dev/null +++ b/crates/aube/src/commands/property_path.rs @@ -0,0 +1,390 @@ +//! Dotted/bracketed property-path navigation for `package.json` editing, +//! a faithful port of pnpm's `@pnpm/object.property-path` (parse + get + +//! set + delete). Used by `pkg` and `set-script`. +//! +//! Path grammar (matching pnpm): `foo.bar.baz`, `.foo.bar`, `foo["baz"]`, +//! `foo['bar'].baz`, `["foo"].bar`, `foo[123]`. A leading `.` is allowed. +//! Bracket segments take a quoted string or an integer literal; dot +//! segments take a bare identifier. +//! +//! Security: `__proto__`, `constructor`, and `prototype` are rejected as +//! path segments (prototype-pollution guard) — same as pnpm. In Rust over +//! `serde_json::Value` there is no prototype to pollute, but we keep the +//! rejection so behavior matches pnpm byte-for-byte and a `pkg set +//! __proto__.x=1` is refused rather than silently writing a literal key. + +use miette::miette; +use serde_json::{Map, Value}; + +/// One resolved path segment: an object key or an array index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Key(String), + Index(usize), +} + +const UNSAFE_KEYS: [&str; 3] = ["__proto__", "constructor", "prototype"]; + +/// Parse a property-path string into segments. Mirrors pnpm's tokenizer: +/// identifiers after `.` (or at the start), and quoted-string / integer +/// literals inside `[...]`. +pub fn parse(path: &str) -> miette::Result> { + let chars: Vec = path.chars().collect(); + let mut i = 0; + let n = chars.len(); + let mut out: Vec = Vec::new(); + // `expect_separator` is true once we've emitted a segment and the next + // thing must be `.` or `[` (not another bare identifier) — this is how + // pnpm rejects `foo bar` while allowing `foo.bar` and `foo[0]`. + let mut expect_separator = false; + + while i < n { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + continue; + } + if c == '.' { + // A dot introduces the next identifier segment. + i += 1; + // Read the identifier. + let start = i; + while i < n && !matches!(chars[i], '.' | '[' | ']') && !chars[i].is_whitespace() { + i += 1; + } + if i == start { + return Err(miette!( + "invalid property path {path:?}: empty segment after `.`" + )); + } + out.push(Segment::Key(chars[start..i].iter().collect())); + expect_separator = true; + continue; + } + if c == '[' { + i += 1; + // Skip whitespace inside brackets. + while i < n && chars[i].is_whitespace() { + i += 1; + } + if i >= n { + return Err(miette!("invalid property path {path:?}: unterminated `[`")); + } + let seg = if chars[i] == '"' || chars[i] == '\'' { + let quote = chars[i]; + i += 1; + let start = i; + while i < n && chars[i] != quote { + i += 1; + } + if i >= n { + return Err(miette!( + "invalid property path {path:?}: unterminated string literal" + )); + } + let s: String = chars[start..i].iter().collect(); + i += 1; // consume closing quote + Segment::Key(s) + } else { + // Integer literal. + let start = i; + while i < n && chars[i].is_ascii_digit() { + i += 1; + } + if i == start { + return Err(miette!( + "invalid property path {path:?}: expected string or integer inside `[]`" + )); + } + let num: String = chars[start..i].iter().collect(); + let idx = num.parse::().map_err(|_| { + miette!("invalid property path {path:?}: bad array index {num:?}") + })?; + Segment::Index(idx) + }; + // Skip whitespace then expect `]`. + while i < n && chars[i].is_whitespace() { + i += 1; + } + if i >= n || chars[i] != ']' { + return Err(miette!("invalid property path {path:?}: expected `]`")); + } + i += 1; + out.push(seg); + expect_separator = true; + continue; + } + // A bare identifier — only legal at the very start (or, per pnpm, + // never right after another segment without a separator). + if expect_separator { + return Err(miette!("invalid property path {path:?}: unexpected {c:?}")); + } + let start = i; + while i < n && !matches!(chars[i], '.' | '[' | ']') && !chars[i].is_whitespace() { + i += 1; + } + out.push(Segment::Key(chars[start..i].iter().collect())); + expect_separator = true; + } + + if out.is_empty() { + return Err(miette!("empty property path")); + } + Ok(out) +} + +fn reject_unsafe(segments: &[Segment]) -> miette::Result<()> { + for seg in segments { + if let Segment::Key(k) = seg + && UNSAFE_KEYS.contains(&k.as_str()) + { + return Err(miette!("refusing to use unsafe property-path key {k:?}")); + } + } + Ok(()) +} + +/// Get the value at `path` in `root`, or `None` if any segment is missing +/// or traverses a non-container. Mirrors pnpm's `getObjectValueByPropertyPath`. +pub fn get<'a>(root: &'a Value, segments: &[Segment]) -> Option<&'a Value> { + let mut cur = root; + for seg in segments { + cur = match (cur, seg) { + (Value::Object(map), Segment::Key(k)) => map.get(k)?, + (Value::Array(arr), Segment::Index(idx)) => arr.get(*idx)?, + // pnpm returns undefined when an array is indexed by a + // non-numeric segment, or any container/segment mismatch. + _ => return None, + }; + } + Some(cur) +} + +/// Set `value` at `path` in `root`, creating intermediate objects/arrays +/// as needed and replacing any node whose shape disagrees with the next +/// segment. Mirrors pnpm's `setObjectValueByPropertyPath`. +pub fn set(root: &mut Value, segments: &[Segment], value: Value) -> miette::Result<()> { + reject_unsafe(segments)?; + if segments.is_empty() { + return Err(miette!("cannot set a value with an empty property path")); + } + set_inner(root, segments, value); + Ok(()) +} + +fn set_inner(node: &mut Value, segments: &[Segment], value: Value) { + let (head, rest) = segments.split_first().expect("non-empty checked by caller"); + if rest.is_empty() { + match head { + Segment::Key(k) => { + let map = ensure_object(node); + map.insert(k.clone(), value); + } + Segment::Index(idx) => { + let arr = ensure_array(node); + grow_to(arr, *idx); + arr[*idx] = value; + } + } + return; + } + let needs_array = matches!(rest[0], Segment::Index(_)); + match head { + Segment::Key(k) => { + let map = ensure_object(node); + let child = map + .entry(k.clone()) + .or_insert_with(|| placeholder(needs_array)); + if container_mismatch(child, needs_array) { + *child = placeholder(needs_array); + } + set_inner(child, rest, value); + } + Segment::Index(idx) => { + let arr = ensure_array(node); + grow_to(arr, *idx); + let child = &mut arr[*idx]; + if container_mismatch(child, needs_array) { + *child = placeholder(needs_array); + } + set_inner(child, rest, value); + } + } +} + +/// Delete the value at `path` in `root`. No-op if the path does not +/// resolve. Array elements are removed (shifting), not nulled — mirrors +/// pnpm's `deleteObjectValueByPropertyPath`. +pub fn delete(root: &mut Value, segments: &[Segment]) -> miette::Result<()> { + reject_unsafe(segments)?; + if segments.is_empty() { + return Ok(()); + } + let (last, parents) = segments.split_last().expect("non-empty checked above"); + // Walk to the parent container. + let mut cur = root; + for seg in parents { + cur = match (cur, seg) { + (Value::Object(map), Segment::Key(k)) => match map.get_mut(k) { + Some(v) => v, + None => return Ok(()), + }, + (Value::Array(arr), Segment::Index(idx)) => match arr.get_mut(*idx) { + Some(v) => v, + None => return Ok(()), + }, + _ => return Ok(()), + }; + } + match (cur, last) { + (Value::Object(map), Segment::Key(k)) => { + map.remove(k); + } + (Value::Array(arr), Segment::Index(idx)) if *idx < arr.len() => { + arr.remove(*idx); + } + _ => {} + } + Ok(()) +} + +fn placeholder(needs_array: bool) -> Value { + if needs_array { + Value::Array(Vec::new()) + } else { + Value::Object(Map::new()) + } +} + +fn container_mismatch(node: &Value, needs_array: bool) -> bool { + match node { + Value::Object(_) => needs_array, + Value::Array(_) => !needs_array, + _ => true, + } +} + +fn ensure_object(node: &mut Value) -> &mut Map { + if !node.is_object() { + *node = Value::Object(Map::new()); + } + node.as_object_mut().expect("just ensured object") +} + +fn ensure_array(node: &mut Value) -> &mut Vec { + if !node.is_array() { + *node = Value::Array(Vec::new()); + } + node.as_array_mut().expect("just ensured array") +} + +fn grow_to(arr: &mut Vec, idx: usize) { + while arr.len() <= idx { + arr.push(Value::Null); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn segs(path: &str) -> Vec { + parse(path).unwrap() + } + + #[test] + fn parses_dot_bracket_and_mixed_paths() { + assert_eq!( + segs("foo.bar.baz"), + vec![ + Segment::Key("foo".into()), + Segment::Key("bar".into()), + Segment::Key("baz".into()) + ] + ); + assert_eq!( + segs(".foo.bar"), + vec![Segment::Key("foo".into()), Segment::Key("bar".into())] + ); + assert_eq!( + segs(r#"foo["baz"]"#), + vec![Segment::Key("foo".into()), Segment::Key("baz".into())] + ); + assert_eq!( + segs("foo['bar'].baz"), + vec![ + Segment::Key("foo".into()), + Segment::Key("bar".into()), + Segment::Key("baz".into()) + ] + ); + assert_eq!( + segs(r#"["foo"].bar"#), + vec![Segment::Key("foo".into()), Segment::Key("bar".into())] + ); + assert_eq!( + segs("foo[123]"), + vec![Segment::Key("foo".into()), Segment::Index(123)] + ); + } + + #[test] + fn rejects_malformed_paths() { + assert!(parse("foo[").is_err()); + assert!(parse("foo[bar").is_err()); + assert!(parse("").is_err()); + assert!(parse("foo bar").is_err()); + } + + #[test] + fn get_navigates_objects_and_arrays_and_misses_cleanly() { + let v = json!({"a": {"b": [10, 20]}}); + assert_eq!(get(&v, &segs("a.b[1]")), Some(&json!(20))); + assert_eq!(get(&v, &segs("a.b[5]")), None); + assert_eq!(get(&v, &segs("a.missing")), None); + // Indexing an object by number, or a scalar by anything, misses. + assert_eq!(get(&v, &segs("a[0]")), None); + } + + #[test] + fn set_creates_intermediates_and_replaces_shape_mismatch() { + let mut v = json!({}); + set(&mut v, &segs("scripts.test"), json!("vitest")).unwrap(); + assert_eq!(v, json!({"scripts": {"test": "vitest"}})); + + // A scalar in the way of a deeper write is replaced with a container. + let mut v2 = json!({"a": 5}); + set(&mut v2, &segs("a.b"), json!(1)).unwrap(); + assert_eq!(v2, json!({"a": {"b": 1}})); + + // Numeric next-segment forces an array container. + let mut v3 = json!({}); + set(&mut v3, &segs("a[1]"), json!("x")).unwrap(); + assert_eq!(v3, json!({"a": [null, "x"]})); + } + + #[test] + fn delete_removes_keys_and_splices_array_elements() { + let mut v = json!({"a": {"b": 1, "c": 2}}); + delete(&mut v, &segs("a.b")).unwrap(); + assert_eq!(v, json!({"a": {"c": 2}})); + + let mut arr = json!({"a": [10, 20, 30]}); + delete(&mut arr, &segs("a[1]")).unwrap(); + assert_eq!(arr, json!({"a": [10, 30]})); + + // Missing path is a no-op. + let mut v2 = json!({"a": 1}); + delete(&mut v2, &segs("x.y.z")).unwrap(); + assert_eq!(v2, json!({"a": 1})); + } + + #[test] + fn unsafe_keys_are_rejected_on_set_and_delete() { + let mut v = json!({}); + assert!(set(&mut v, &segs("__proto__.polluted"), json!(true)).is_err()); + assert!(set(&mut v, &segs("constructor"), json!(1)).is_err()); + assert!(delete(&mut v, &segs("prototype.x")).is_err()); + } +} diff --git a/crates/aube/src/commands/search.rs b/crates/aube/src/commands/search.rs new file mode 100644 index 000000000..1e50302de --- /dev/null +++ b/crates/aube/src/commands/search.rs @@ -0,0 +1,126 @@ +//! `aube search [...]` — full-text package search against the +//! registry's `/-/v1/search` endpoint. Mirrors `npm search` / `pnpm search`. +//! +//! Output mirrors pnpm's human format (name, description, version line, +//! maintainers, keywords, package URL) and supports `--json` (the raw +//! package objects) and `--search-limit`. + +use clap::Args; +use miette::miette; +use serde_json::Value; + +use crate::commands::make_client; + +#[derive(Debug, Args)] +pub struct SearchArgs { + /// Search terms. Joined with spaces into a single query. + #[arg(required = true)] + pub query: Vec, + + /// Print the raw package objects as JSON. + #[arg(long)] + pub json: bool, + + /// Maximum number of results to show (default: 20). + #[arg(long, value_name = "N", default_value_t = 20)] + pub search_limit: u32, + + #[command(flatten)] + pub network: crate::cli_args::NetworkArgs, +} + +pub async fn run(args: SearchArgs) -> miette::Result<()> { + args.network.install_overrides(); + let query = args.query.join(" "); + if query.trim().is_empty() { + return Err(miette!("search query is required")); + } + + let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let client = make_client(&cwd); + + let packages = client + .search(&query, args.search_limit) + .await + .map_err(|e| miette!("search failed: {e}"))?; + + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&packages).unwrap_or_else(|_| "[]".to_string()) + ); + return Ok(()); + } + + if packages.is_empty() { + println!("No packages found"); + return Ok(()); + } + + let blocks: Vec = packages.iter().map(format_package).collect(); + println!("{}", blocks.join("\n\n")); + Ok(()) +} + +fn format_package(pkg: &Value) -> String { + let name = pkg.get("name").and_then(Value::as_str).unwrap_or(""); + let mut lines: Vec = vec![name.to_string()]; + + if let Some(desc) = pkg.get("description").and_then(Value::as_str) + && !desc.is_empty() + { + lines.push(desc.to_string()); + } + + let version = pkg.get("version").and_then(Value::as_str).unwrap_or(""); + let mut version_line = vec![format!("Version {version}")]; + if let Some(date) = pkg.get("date").and_then(Value::as_str) + && let Some(day) = date.split('T').next() + { + version_line.push(format!("published {day}")); + } + if let Some(author) = author_name(pkg) { + version_line.push(format!("by {author}")); + } + lines.push(version_line.join(" ")); + + if let Some(maintainers) = pkg.get("maintainers").and_then(Value::as_array) { + let names: Vec<&str> = maintainers + .iter() + .filter_map(|m| m.get("username").and_then(Value::as_str)) + .collect(); + if !names.is_empty() { + lines.push(format!("Maintainers: {}", names.join(", "))); + } + } + + if let Some(keywords) = pkg.get("keywords").and_then(Value::as_array) { + let kws: Vec<&str> = keywords.iter().filter_map(Value::as_str).collect(); + if !kws.is_empty() { + lines.push(format!("Keywords: {}", kws.join(", "))); + } + } + + // Neutral, registry-canonical package URL (pnpm emits a pnpm-branded + // npmx.dev link — not appropriate here). + if !name.is_empty() { + lines.push(format!("https://www.npmjs.com/package/{name}")); + } + + lines.join("\n") +} + +fn author_name(pkg: &Value) -> Option { + if let Some(author) = pkg.get("author") { + if let Some(name) = author.get("name").and_then(Value::as_str) { + return Some(name.to_string()); + } + if let Some(s) = author.as_str() { + return Some(s.to_string()); + } + } + pkg.get("publisher") + .and_then(|p| p.get("username")) + .and_then(Value::as_str) + .map(str::to_string) +} diff --git a/crates/aube/src/commands/set_script.rs b/crates/aube/src/commands/set_script.rs new file mode 100644 index 000000000..b971afd45 --- /dev/null +++ b/crates/aube/src/commands/set_script.rs @@ -0,0 +1,58 @@ +//! `aube set-script ` — set an entry in the local +//! `package.json` `scripts` map. Mirrors `npm set-script` / +//! `pnpm set-script` (`@pnpm/pkg-manifest`). +//! +//! Equivalent to `aube pkg set scripts.=`, but with the +//! command taken as the remaining (space-joined) positional args so the +//! shell doesn't need to quote a single `key=value`. The write reuses the +//! same atomic, key-order-preserving manifest update as `pkg`. + +use clap::Args; +use miette::miette; +use serde_json::Value; + +use super::property_path; + +#[derive(Debug, Args)] +pub struct SetScriptArgs { + /// Script name (the key under `scripts`). + pub name: String, + + /// The command the script runs. Remaining args are joined with spaces. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)] + pub command: Vec, + + /// Operate on the package.json in this directory (default: the + /// nearest project root, or the cwd). + #[arg(short = 'C', long, value_name = "DIR")] + pub dir: Option, +} + +pub async fn run(args: SetScriptArgs) -> miette::Result<()> { + if args.command.is_empty() { + return Err(miette!("`set-script` requires a script name and a command")); + } + let dir = match &args.dir { + Some(d) => d.clone(), + None => { + crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")) + } + }; + let manifest_path = dir.join("package.json"); + let command = args.command.join(" "); + + super::update_manifest_json_object(&manifest_path, |obj| { + let mut root = Value::Object(std::mem::take(obj)); + // Use the property-path setter so `scripts` is created if absent + // and a non-object `scripts` is replaced (same shape as pnpm). + let segments = vec![ + property_path::Segment::Key("scripts".to_string()), + property_path::Segment::Key(args.name.clone()), + ]; + property_path::set(&mut root, &segments, Value::String(command.clone()))?; + if let Value::Object(map) = root { + *obj = map; + } + Ok(()) + }) +} diff --git a/crates/aube/src/commands/token.rs b/crates/aube/src/commands/token.rs new file mode 100644 index 000000000..d0a158255 --- /dev/null +++ b/crates/aube/src/commands/token.rs @@ -0,0 +1,134 @@ +//! `aube token list|create|revoke` — manage the registry auth tokens of +//! the authenticated account. Mirrors `npm token` (pnpm does not implement +//! this verb; nub implements it for npm parity). +//! +//! - `list` — list the account's tokens (key, masked value, scope). +//! - `create` — create a classic auth token. The account password is read +//! from the `--password`/`-p` flag or, if absent, from stdin (so it +//! isn't captured in shell history). `--read-only` and `--cidr` map to +//! the create-token request. +//! - `revoke ` — revoke a token by its key (or token-value prefix). +//! +//! All operations require an existing auth token in `.npmrc` (you must be +//! logged in to manage tokens). + +use clap::{Args, Subcommand}; +use miette::miette; + +use crate::commands::make_client; + +#[derive(Debug, Args)] +pub struct TokenArgs { + #[command(subcommand)] + pub command: TokenCommand, + + #[command(flatten)] + pub network: crate::cli_args::NetworkArgs, +} + +#[derive(Debug, Subcommand)] +pub enum TokenCommand { + /// List the account's auth tokens. + #[command(visible_alias = "ls")] + List, + /// Create a new auth token. + Create { + /// Account password. If omitted, read from stdin. + #[arg(short = 'p', long)] + password: Option, + /// Create a read-only token. + #[arg(long)] + read_only: bool, + /// Restrict the token to these CIDR ranges. Repeatable. + #[arg(long, value_name = "CIDR")] + cidr: Vec, + }, + /// Revoke a token by its key (or token-value prefix). + #[command(visible_alias = "rm")] + Revoke { key: String }, +} + +pub async fn run(args: TokenArgs) -> miette::Result<()> { + args.network.install_overrides(); + let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let client = make_client(&cwd); + + match args.command { + TokenCommand::List => { + let tokens = client.list_tokens().await.map_err(map_err)?; + if tokens.is_empty() { + eprintln!("no tokens found"); + return Ok(()); + } + for t in tokens { + let scope = if t.readonly { + "read-only" + } else { + "read-write" + }; + let created = t.created.as_deref().unwrap_or(""); + println!("{}\t{}\t{scope}\t{created}", t.key, t.token); + } + } + TokenCommand::Create { + password, + read_only, + cidr, + } => { + let password = match password { + Some(p) => p, + None => read_password_from_stdin()?, + }; + let created = client + .create_token(&password, read_only, &cidr) + .await + .map_err(map_err)?; + // The full token is shown exactly once, here. + if let Some(token) = created.get("token").and_then(|t| t.as_str()) { + println!("{token}"); + } else { + println!( + "{}", + serde_json::to_string_pretty(&created).unwrap_or_default() + ); + } + } + TokenCommand::Revoke { key } => { + client.revoke_token(&key).await.map_err(map_err)?; + println!("revoked {key}"); + } + } + Ok(()) +} + +fn read_password_from_stdin() -> miette::Result { + use std::io::BufRead; + let mut line = String::new(); + std::io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| miette!("failed to read password from stdin: {e}"))?; + let pw = line.trim_end_matches(['\r', '\n']).to_string(); + if pw.is_empty() { + return Err(miette!( + "a password is required (pass --password or pipe it on stdin)" + )); + } + Ok(pw) +} + +fn map_err(e: aube_registry::Error) -> miette::Report { + match e { + aube_registry::Error::Unauthorized => { + miette!( + "not authenticated — run `{}` first", + aube_util::cmd("login") + ) + } + aube_registry::Error::NotFound(n) => miette!("not found: {n}"), + aube_registry::Error::RegistryWrite { status, body } => { + miette!("registry rejected the request (HTTP {status}): {body}") + } + other => miette!("{other}"), + } +} diff --git a/crates/aube/src/commands/whoami.rs b/crates/aube/src/commands/whoami.rs new file mode 100644 index 000000000..e79588450 --- /dev/null +++ b/crates/aube/src/commands/whoami.rs @@ -0,0 +1,36 @@ +//! `aube whoami` — print the username associated with the configured +//! registry auth token. Mirrors `npm whoami` / `pnpm whoami`. +//! +//! Calls `GET {registry}/-/whoami` with the `.npmrc` bearer token. With no +//! token configured (or an invalid one) the registry returns 401, which +//! surfaces as an "authentication required" error pointing at `aube login`. + +use clap::Args; +use miette::miette; + +use crate::commands::make_client; + +#[derive(Debug, Args)] +pub struct WhoamiArgs { + #[command(flatten)] + pub network: crate::cli_args::NetworkArgs, +} + +pub async fn run(args: WhoamiArgs) -> miette::Result<()> { + args.network.install_overrides(); + let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let client = make_client(&cwd); + + let username = client.fetch_whoami().await.map_err(|e| match e { + aube_registry::Error::Unauthorized => { + miette!( + "not authenticated — run `{}` first", + aube_util::cmd("login") + ) + } + other => miette!("failed to determine the current user: {other}"), + })?; + + println!("{username}"); + Ok(()) +}