use crate::commands::npmrc::{NpmrcEdit, user_npmrc_path};
use aube_settings::meta as settings_meta;
use clap::{Args, Subcommand, ValueEnum};
use miette::miette;
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct ConfigArgs {
#[command(subcommand)]
pub command: ConfigCommand,
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
#[command(visible_aliases = ["rm", "remove", "unset"])]
Delete(KeyArgs),
Get(GetArgs),
#[command(visible_alias = "ls")]
List(ListArgs),
Set(SetArgs),
}
#[derive(Debug, Args)]
pub struct KeyArgs {
pub key: String,
#[arg(long, conflicts_with = "location")]
pub local: bool,
#[arg(long, value_enum, default_value_t = Location::User)]
pub location: Location,
}
impl KeyArgs {
fn effective_location(&self) -> Location {
if self.local {
Location::Project
} else {
self.location
}
}
}
#[derive(Debug, Args)]
pub struct GetArgs {
pub key: String,
#[arg(long)]
pub json: bool,
#[arg(long, conflicts_with = "location")]
pub local: bool,
#[arg(long, value_enum, default_value_t = ListLocation::Merged)]
pub location: ListLocation,
}
impl GetArgs {
fn effective_location(&self) -> ListLocation {
if self.local {
ListLocation::Project
} else {
self.location
}
}
}
#[derive(Debug, Args)]
pub struct SetArgs {
pub key: String,
pub value: String,
#[arg(long, conflicts_with = "location")]
pub local: bool,
#[arg(long, value_enum, default_value_t = Location::User)]
pub location: Location,
}
impl SetArgs {
fn effective_location(&self) -> Location {
if self.local {
Location::Project
} else {
self.location
}
}
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
pub all: bool,
#[arg(long)]
pub json: bool,
#[arg(long, conflicts_with_all = ["location", "all"])]
pub local: bool,
#[arg(long, value_enum, default_value_t = ListLocation::Merged)]
pub location: ListLocation,
}
impl ListArgs {
fn effective_location(&self) -> ListLocation {
if self.local {
ListLocation::Project
} else {
self.location
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Location {
User,
Project,
Global,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ListLocation {
Merged,
User,
Project,
Global,
}
impl Location {
fn path(self) -> miette::Result<PathBuf> {
match self {
Location::User | Location::Global => user_npmrc_path(),
Location::Project => Ok(crate::dirs::project_root_or_cwd()?.join(".npmrc")),
}
}
}
pub async fn run(args: ConfigArgs) -> miette::Result<()> {
match args.command {
ConfigCommand::Get(a) => get(a),
ConfigCommand::Set(a) => set(a),
ConfigCommand::Delete(a) => delete(a),
ConfigCommand::List(a) => list(a),
}
}
fn is_literal_alias(key: &str) -> bool {
!key.starts_with("//") && !key.contains(':')
}
fn resolve_aliases(key: &str) -> Vec<String> {
if let Some(meta) = settings_meta::find(key) {
let literals = literal_aliases(meta.npmrc_keys);
if !literals.is_empty() {
return literals;
}
}
for meta in settings_meta::all() {
let literals = literal_aliases(meta.npmrc_keys);
if literals.iter().any(|a| a == key) {
return literals;
}
}
vec![key.to_string()]
}
fn literal_aliases(keys: &[&'static str]) -> Vec<String> {
keys.iter()
.filter(|k| is_literal_alias(k))
.map(|s| s.to_string())
.collect()
}
fn preferred_write_key(input: &str, aliases: &[String]) -> String {
if aliases.iter().any(|a| a == input) {
return input.to_string();
}
aliases
.first()
.cloned()
.unwrap_or_else(|| input.to_string())
}
pub fn get(args: GetArgs) -> miette::Result<()> {
let aliases = resolve_aliases(&args.key);
let cwd = crate::dirs::project_root_or_cwd()?;
let entries: Vec<(String, String)> = match args.effective_location() {
ListLocation::Merged => read_merged(&cwd)?,
ListLocation::User | ListLocation::Global => read_single(&user_npmrc_path()?)?,
ListLocation::Project => read_single(&cwd.join(".npmrc"))?,
};
for (k, v) in entries.iter().rev() {
if aliases.iter().any(|a| a == k) {
if args.json {
println!("{}", serde_json::Value::String(v.clone()));
} else {
println!("{v}");
}
return Ok(());
}
}
println!("undefined");
Ok(())
}
pub fn set(args: SetArgs) -> miette::Result<()> {
let aliases = resolve_aliases(&args.key);
let write_key = preferred_write_key(&args.key, &aliases);
let path = args.effective_location().path()?;
let mut edit = NpmrcEdit::load(&path)?;
for alias in &aliases {
if alias != &write_key {
edit.remove(alias);
}
}
edit.set(&write_key, &args.value);
edit.save(&path)?;
eprintln!("set {}={} ({})", write_key, args.value, path.display());
Ok(())
}
fn delete(args: KeyArgs) -> miette::Result<()> {
let aliases = resolve_aliases(&args.key);
let path = args.effective_location().path()?;
if !path.exists() {
return Err(miette!("no .npmrc at {}", path.display()));
}
let mut edit = NpmrcEdit::load(&path)?;
let mut removed = false;
for alias in &aliases {
if edit.remove(alias) {
removed = true;
}
}
if !removed {
return Err(miette!("{} not set in {}", args.key, path.display()));
}
edit.save(&path)?;
eprintln!("deleted {} ({})", args.key, path.display());
Ok(())
}
fn list(args: ListArgs) -> miette::Result<()> {
let location = args.effective_location();
if args.all && !matches!(location, ListLocation::Merged) {
return Err(miette!(
"--all is only supported with --location merged (the default)"
));
}
let cwd = crate::dirs::project_root_or_cwd()?;
let entries: Vec<(String, String)> = match location {
ListLocation::Merged => read_merged(&cwd)?,
ListLocation::User | ListLocation::Global => read_single(&user_npmrc_path()?)?,
ListLocation::Project => read_single(&cwd.join(".npmrc"))?,
};
let mut seen: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
for (k, v) in entries {
let canonical = canonical_list_key(&k);
seen.insert(canonical, v);
}
let mut defaults: std::collections::HashSet<String> = std::collections::HashSet::new();
if args.all {
for meta in settings_meta::all() {
let literals = literal_aliases(meta.npmrc_keys);
let Some(primary) = literals.first().cloned() else {
continue;
};
if !literals.iter().any(|k| seen.contains_key(k)) {
seen.insert(primary.clone(), meta.default.to_string());
defaults.insert(primary);
}
}
}
if args.json {
let obj: serde_json::Map<String, serde_json::Value> = seen
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
let out = serde_json::to_string_pretty(&serde_json::Value::Object(obj))
.map_err(|e| miette!("failed to serialize config: {e}"))?;
println!("{out}");
} else {
for (k, v) in &seen {
if defaults.contains(k) {
println!("{k}={v} (default)");
} else {
println!("{k}={v}");
}
}
}
Ok(())
}
fn canonical_list_key(key: &str) -> String {
let aliases = resolve_aliases(key);
if aliases.len() == 1 && aliases[0] == key {
return key.to_string();
}
aliases.first().cloned().unwrap_or_else(|| key.to_string())
}
fn read_merged(cwd: &Path) -> miette::Result<Vec<(String, String)>> {
let mut out = Vec::new();
if let Ok(user) = user_npmrc_path() {
out.extend(read_single(&user)?);
}
out.extend(read_single(&cwd.join(".npmrc"))?);
Ok(out)
}
fn read_single(path: &std::path::Path) -> miette::Result<Vec<(String, String)>> {
if !path.exists() {
return Ok(Vec::new());
}
let edit = NpmrcEdit::load(path)?;
Ok(edit.entries())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_list_key_collapses_alias_to_primary() {
assert_eq!(canonical_list_key("autoInstallPeers"), "auto-install-peers");
assert_eq!(
canonical_list_key("auto-install-peers"),
"auto-install-peers"
);
}
#[test]
fn canonical_list_key_passthrough_for_unknown_key() {
assert_eq!(
canonical_list_key("//registry.example.com/:_authToken"),
"//registry.example.com/:_authToken"
);
}
#[test]
fn resolve_aliases_canonical_name() {
let aliases = resolve_aliases("autoInstallPeers");
assert!(aliases.iter().any(|a| a == "auto-install-peers"));
assert!(aliases.iter().any(|a| a == "autoInstallPeers"));
}
#[test]
fn resolve_aliases_from_alias() {
let aliases = resolve_aliases("auto-install-peers");
assert!(aliases.iter().any(|a| a == "auto-install-peers"));
assert!(aliases.iter().any(|a| a == "autoInstallPeers"));
}
#[test]
fn resolve_aliases_registry_excludes_template_keys() {
let aliases = resolve_aliases("registry");
assert_eq!(aliases, vec!["registry".to_string()]);
for a in &aliases {
assert!(is_literal_alias(a), "leaked template alias: {a}");
}
}
#[test]
fn resolve_aliases_template_input_is_identity() {
for template in [
"@scope:registry",
"//registry.example.com/:_authToken",
"//registry.example.com/:_auth",
] {
assert_eq!(
resolve_aliases(template),
vec![template.to_string()],
"{template} should be identity, not registries-grouped"
);
}
}
#[test]
fn is_literal_alias_recognizes_templates() {
assert!(is_literal_alias("registry"));
assert!(is_literal_alias("auto-install-peers"));
assert!(!is_literal_alias("@scope:registry"));
assert!(!is_literal_alias("//host/:_authToken"));
assert!(!is_literal_alias("//host/:_auth"));
}
#[test]
fn resolve_aliases_unknown_key_is_identity() {
let aliases = resolve_aliases("//registry.example.com/:_authToken");
assert_eq!(
aliases,
vec!["//registry.example.com/:_authToken".to_string()]
);
}
#[test]
fn preferred_write_key_keeps_user_typed_alias() {
let aliases = vec![
"auto-install-peers".to_string(),
"autoInstallPeers".to_string(),
];
assert_eq!(
preferred_write_key("autoInstallPeers", &aliases),
"autoInstallPeers"
);
assert_eq!(
preferred_write_key("auto-install-peers", &aliases),
"auto-install-peers"
);
}
#[test]
fn preferred_write_key_falls_back_to_first_alias() {
let aliases = vec![
"auto-install-peers".to_string(),
"autoInstallPeers".to_string(),
];
assert_eq!(
preferred_write_key("something-else", &aliases),
"auto-install-peers"
);
}
}