use aube_registry::config::{NpmConfig, normalize_registry_url_pub, registry_uri_key_pub};
use miette::{Context, IntoDiagnostic, miette};
use std::path::{Path, PathBuf};
pub fn registry_host_key(url: &str) -> String {
registry_uri_key_pub(url)
}
pub struct NpmrcEdit {
lines: Vec<Line>,
}
enum Line {
Raw(String),
Entry { key: String, value: String },
}
impl NpmrcEdit {
pub fn load(path: &Path) -> miette::Result<Self> {
let content = if path.exists() {
std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", path.display()))?
} else {
String::new()
};
let mut lines = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
lines.push(Line::Raw(line.to_string()));
continue;
}
if let Some((k, v)) = line.split_once('=') {
lines.push(Line::Entry {
key: k.trim().to_string(),
value: v.trim().to_string(),
});
} else {
lines.push(Line::Raw(line.to_string()));
}
}
Ok(Self { lines })
}
pub fn set(&mut self, key: &str, value: &str) {
self.lines.retain(|line| match line {
Line::Entry { key: k, .. } => k != key,
Line::Raw(_) => true,
});
self.lines.push(Line::Entry {
key: key.to_string(),
value: value.to_string(),
});
}
pub fn entries(&self) -> Vec<(String, String)> {
self.lines
.iter()
.filter_map(|line| match line {
Line::Entry { key, value } => Some((key.clone(), value.clone())),
Line::Raw(_) => None,
})
.collect()
}
pub fn remove(&mut self, key: &str) -> bool {
let before = self.lines.len();
self.lines.retain(|line| match line {
Line::Entry { key: k, .. } => k != key,
Line::Raw(_) => true,
});
before != self.lines.len()
}
pub fn save(&self, path: &Path) -> miette::Result<()> {
let write_path = symlink_target_or_self(path).into_diagnostic()?;
let mut out = String::new();
for line in &self.lines {
match line {
Line::Raw(s) => out.push_str(s),
Line::Entry { key, value } => {
out.push_str(key);
out.push('=');
out.push_str(value);
}
}
out.push('\n');
}
aube_util::fs_atomic::atomic_write(&write_path, out.as_bytes())
.into_diagnostic()
.wrap_err_with(|| format!("failed to write {}", write_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) =
std::fs::set_permissions(&write_path, std::fs::Permissions::from_mode(0o600))
{
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_TOKEN_CHMOD_FAILED,
"failed to chmod 0600 {}: {e}. File may be world-readable, check filesystem permissions",
write_path.display()
);
}
}
Ok(())
}
}
pub(crate) fn symlink_target_or_self(path: &Path) -> std::io::Result<PathBuf> {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return Ok(path.to_path_buf());
};
if !meta.file_type().is_symlink() {
return Ok(path.to_path_buf());
}
std::fs::canonicalize(path)
}
pub fn resolve_registry(flag: Option<&str>, scope: Option<&str>) -> miette::Result<String> {
if let Some(r) = flag {
return Ok(normalize_registry_url_pub(r));
}
let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from("."));
let config = NpmConfig::load(&cwd);
if let Some(scope) = scope
&& let Some(url) = config.scoped_registries.get(scope)
{
return Ok(url.clone());
}
Ok(config.registry)
}
pub fn user_npmrc_path() -> miette::Result<PathBuf> {
let home = read_home_env().ok_or_else(|| {
miette!("could not locate home directory. set HOME or USERPROFILE to point at ~/.npmrc")
})?;
Ok(home.join(".npmrc"))
}
fn read_home_env() -> Option<PathBuf> {
if let Some(h) = std::env::var_os("HOME") {
return Some(PathBuf::from(h));
}
#[cfg(windows)]
{
if let Some(p) = std::env::var_os("USERPROFILE") {
return Some(PathBuf::from(p));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_replaces_existing_and_preserves_comments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".npmrc");
std::fs::write(
&path,
"# top comment\n\
registry=https://registry.npmjs.org/\n\
//registry.npmjs.org/:_authToken=old\n\
; trailing\n",
)
.unwrap();
let mut edit = NpmrcEdit::load(&path).unwrap();
edit.set("//registry.npmjs.org/:_authToken", "new");
edit.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("# top comment"));
assert!(after.contains("registry=https://registry.npmjs.org/"));
assert!(after.contains("//registry.npmjs.org/:_authToken=new"));
assert!(!after.contains("=old"));
assert!(after.contains("; trailing"));
}
#[test]
fn set_appends_when_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".npmrc");
std::fs::write(&path, "registry=https://r.example.com/\n").unwrap();
let mut edit = NpmrcEdit::load(&path).unwrap();
edit.set("//r.example.com/:_authToken", "tok");
edit.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("//r.example.com/:_authToken=tok"));
}
#[test]
fn remove_drops_matching_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".npmrc");
std::fs::write(
&path,
"registry=https://r.example.com/\n\
//r.example.com/:_authToken=tok\n",
)
.unwrap();
let mut edit = NpmrcEdit::load(&path).unwrap();
assert!(edit.remove("//r.example.com/:_authToken"));
edit.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("registry=https://r.example.com/"));
assert!(!after.contains("_authToken"));
}
#[cfg(unix)]
#[test]
fn save_preserves_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real-npmrc");
let link = dir.path().join(".npmrc");
std::fs::write(&target, "registry=https://r.example.com/\n").unwrap();
std::os::unix::fs::symlink("real-npmrc", &link).unwrap();
let mut edit = NpmrcEdit::load(&link).unwrap();
edit.set("minimumReleaseAge", "2880");
edit.save(&link).unwrap();
assert!(
std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
let after = std::fs::read_to_string(&target).unwrap();
assert!(after.contains("minimumReleaseAge=2880"));
}
#[cfg(unix)]
#[test]
fn save_preserves_symlink_chain() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real-npmrc");
let mid = dir.path().join("dotfiles-npmrc");
let link = dir.path().join(".npmrc");
std::fs::write(&target, "registry=https://r.example.com/\n").unwrap();
std::os::unix::fs::symlink("real-npmrc", &mid).unwrap();
std::os::unix::fs::symlink("dotfiles-npmrc", &link).unwrap();
let mut edit = NpmrcEdit::load(&link).unwrap();
edit.set("minimumReleaseAge", "2880");
edit.save(&link).unwrap();
for path in [&link, &mid] {
assert!(
std::fs::symlink_metadata(path)
.unwrap()
.file_type()
.is_symlink()
);
}
let after = std::fs::read_to_string(&target).unwrap();
assert!(after.contains("minimumReleaseAge=2880"));
}
#[test]
fn remove_missing_key_is_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".npmrc");
std::fs::write(&path, "registry=https://r.example.com/\n").unwrap();
let mut edit = NpmrcEdit::load(&path).unwrap();
assert!(!edit.remove("//r.example.com/:_authToken"));
}
#[test]
fn load_nonexistent_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.npmrc");
let mut edit = NpmrcEdit::load(&path).unwrap();
edit.set("foo", "bar");
edit.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, "foo=bar\n");
}
#[test]
fn registry_host_key_strips_scheme() {
assert_eq!(
registry_host_key("https://registry.npmjs.org/"),
"//registry.npmjs.org/"
);
assert_eq!(
registry_host_key("http://localhost:4873/"),
"//localhost:4873/"
);
}
}