use miette::{Context, IntoDiagnostic, miette};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct GlobalLayout {
pub bin_dir: PathBuf,
pub pkg_dir: PathBuf,
}
impl GlobalLayout {
pub fn resolve() -> miette::Result<Self> {
let cwd = std::env::current_dir().unwrap_or_default();
let (setting_bin, setting_pkg) = super::with_settings_ctx(&cwd, |ctx| {
let bin = aube_settings::resolved::global_bin_dir(ctx)
.and_then(|raw| super::expand_setting_path(&raw, &cwd));
let pkg = aube_settings::resolved::global_dir(ctx)
.and_then(|raw| super::expand_setting_path(&raw, &cwd));
(bin, pkg)
});
let bin_dir = setting_bin.map_or_else(default_bin_dir, Ok)?;
let pkg_subdir = format!("global-{}", aube_util::embedder().name);
let pkg_dir = setting_pkg
.map_or_else(|| default_pkg_dir(&pkg_subdir), |p| Ok(p.join(&pkg_subdir)))?;
warn_on_legacy_global_dir(&pkg_dir, &pkg_subdir);
Ok(Self { bin_dir, pkg_dir })
}
}
fn branded_home() -> Option<PathBuf> {
let prefix = aube_util::embedder().env_prefix?;
std::env::var(format!("{prefix}_HOME"))
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
fn data_root() -> miette::Result<PathBuf> {
let ns = aube_util::embedder().data_namespace;
#[cfg(windows)]
if let Ok(local) = std::env::var("LOCALAPPDATA")
&& !local.is_empty()
{
return Ok(PathBuf::from(local).join(ns));
}
let data_home = match aube_util::env::xdg_data_home() {
Some(xdg) => xdg,
None => aube_util::env::home_dir()
.ok_or_else(|| miette!("HOME is not set; can't locate global directory"))?
.join(".local/share"),
};
Ok(data_home.join(ns))
}
fn default_bin_dir() -> miette::Result<PathBuf> {
if let Some(home) = branded_home() {
return Ok(home);
}
data_root().map(|d| d.join("bin"))
}
fn default_pkg_dir(pkg_subdir: &str) -> miette::Result<PathBuf> {
if let Some(home) = branded_home() {
return Ok(home.join(pkg_subdir));
}
data_root().map(|d| d.join(pkg_subdir))
}
pub fn prefix_dir() -> miette::Result<PathBuf> {
if let Some(home) = branded_home() {
return Ok(home);
}
data_root()
}
fn legacy_home_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(v) = std::env::var("PNPM_HOME")
&& !v.is_empty()
{
out.push(PathBuf::from(v));
}
if cfg!(windows) {
if let Ok(local) = std::env::var("LOCALAPPDATA") {
out.push(PathBuf::from(local).join("pnpm"));
}
} else if cfg!(target_os = "macos")
&& let Some(home) = aube_util::env::home_dir()
{
out.push(home.join("Library/pnpm"));
}
if !cfg!(windows) {
match aube_util::env::xdg_data_home() {
Some(xdg) => out.push(xdg.join("pnpm")),
None => {
if let Some(home) = aube_util::env::home_dir() {
out.push(home.join(".local/share/pnpm"));
}
}
}
}
out
}
fn has_global_installs(pkg_dir: &Path) -> bool {
std::fs::read_dir(pkg_dir).is_ok_and(|entries| {
entries
.flatten()
.any(|e| e.file_type().is_ok_and(|t| t.is_symlink()))
})
}
fn warn_on_legacy_global_dir(pkg_dir: &Path, pkg_subdir: &str) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
if has_global_installs(pkg_dir) {
return;
}
let Some(legacy) = legacy_home_candidates()
.into_iter()
.find(|home| has_global_installs(&home.join(pkg_subdir)))
else {
return;
};
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION,
legacy_dir = %legacy.display(),
current_dir = %pkg_dir.display(),
"global packages from an older aube are still in {}; aube now keeps its own global \
directory at {}. Reinstall them with `{}`, or set {}_HOME={} to keep using the old \
location.",
legacy.display(),
pkg_dir.display(),
aube_util::cmd("add -g <pkg>"),
aube_util::embedder().env_prefix.unwrap_or("AUBE"),
legacy.display(),
);
});
}
fn bin_dir_on_path(bin_dir: &Path, path_var: Option<&std::ffi::OsStr>) -> bool {
let Some(path) = path_var else {
return false;
};
let want = std::fs::canonicalize(bin_dir).unwrap_or_else(|_| bin_dir.to_path_buf());
std::env::split_paths(path).any(|entry| std::fs::canonicalize(&entry).unwrap_or(entry) == want)
}
pub fn warn_if_bin_dir_not_on_path(bin_dir: &Path) {
if bin_dir_on_path(bin_dir, std::env::var_os("PATH").as_deref()) {
return;
}
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH,
bin_dir = %bin_dir.display(),
"{} is not on your PATH, so globally installed commands won't be found. Add it to PATH \
(e.g. `export PATH=\"{}:$PATH\"`), or set globalBinDir to a directory that already is.",
bin_dir.display(),
bin_dir.display(),
);
}
pub fn create_install_dir(pkg_dir: &Path) -> miette::Result<PathBuf> {
std::fs::create_dir_all(pkg_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create global dir {}", pkg_dir.display()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let name = format!("{:x}-{:x}", std::process::id(), now);
let dir = pkg_dir.join(name);
std::fs::create_dir_all(&dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create install dir {}", dir.display()))?;
Ok(dir)
}
pub fn cache_key(aliases: &[String], registries: &BTreeMap<String, String>) -> String {
let mut sorted = aliases.to_vec();
sorted.sort();
let registries_vec: Vec<(&String, &String)> = registries.iter().collect();
let payload = serde_json::json!([sorted, registries_vec]).to_string();
let digest = Sha256::digest(payload.as_bytes());
hex::encode(digest)
}
pub fn hash_link(pkg_dir: &Path, hash: &str) -> PathBuf {
pkg_dir.join(hash)
}
#[derive(Debug, Clone)]
pub struct GlobalPackageInfo {
pub hash: String,
pub install_dir: PathBuf,
pub aliases: Vec<String>,
}
pub fn scan_packages(pkg_dir: &Path) -> Vec<GlobalPackageInfo> {
let Ok(entries) = std::fs::read_dir(pkg_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_symlink() {
continue;
}
let link_path = entry.path();
let Ok(install_dir) = crate::dirs::canonicalize(&link_path) else {
continue;
};
let manifest_path = install_dir.join("package.json");
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) else {
continue;
};
if deps.is_empty() {
continue;
}
let aliases: Vec<String> = deps.keys().cloned().collect();
out.push(GlobalPackageInfo {
hash: entry.file_name().to_string_lossy().into_owned(),
install_dir,
aliases,
});
}
out
}
pub fn find_package(pkg_dir: &Path, alias: &str) -> Option<GlobalPackageInfo> {
scan_packages(pkg_dir)
.into_iter()
.find(|info| info.aliases.iter().any(|a| a == alias))
}
pub fn symlink_force(target: &Path, link: &Path) -> miette::Result<()> {
super::remove_existing(link)?;
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
.into_diagnostic()
.wrap_err_with(|| format!("failed to symlink {}", link.display()))?;
}
#[cfg(windows)]
{
if target.is_dir() {
aube_linker::create_dir_link(target, link)
.into_diagnostic()
.wrap_err_with(|| format!("failed to symlink {}", link.display()))?;
} else {
std::os::windows::fs::symlink_file(target, link)
.into_diagnostic()
.wrap_err_with(|| format!("failed to symlink {}", link.display()))?;
}
}
Ok(())
}
pub fn link_bins(
install_dir: &Path,
bin_dir: &Path,
aliases: &[String],
shim_opts: aube_linker::BinShimOptions,
) -> miette::Result<Vec<String>> {
std::fs::create_dir_all(bin_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create bin dir {}", bin_dir.display()))?;
let modules = super::project_modules_dir(install_dir);
let mut linked = Vec::new();
for alias in aliases {
let pkg_dir = modules.join(alias);
let manifest_path = pkg_dir.join("package.json");
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let Some(bin_field) = json.get("bin") else {
continue;
};
let bins: Vec<(String, String)> = match bin_field {
serde_json::Value::String(path) => {
let name = alias.rsplit('/').next().unwrap_or(alias).to_string();
vec![(name, path.clone())]
}
serde_json::Value::Object(map) => map
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect(),
_ => continue,
};
for (name, rel) in bins {
if aube_linker::validate_bin_name(&name).is_err()
|| aube_linker::validate_bin_target(&rel).is_err()
{
continue;
}
let target = pkg_dir.join(&rel);
aube_linker::create_bin_shim(bin_dir, &name, &target, shim_opts)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create bin shim for {name}"))?;
linked.push(name);
}
}
Ok(linked)
}
pub fn unlink_bins(install_dir: &Path, bin_dir: &Path, bin_names: &[String]) {
#[cfg(unix)]
{
let install_canon = std::fs::canonicalize(install_dir).ok();
let install_lex = aube_linker::normalize_path(install_dir);
for name in bin_names {
let link = bin_dir.join(name);
match std::fs::read_link(&link) {
Ok(target) => {
let absolute = if target.is_absolute() {
target
} else {
bin_dir.join(target)
};
let resolved = aube_linker::normalize_path(&absolute);
if resolved.starts_with(&install_lex)
|| install_canon
.as_ref()
.is_some_and(|canon| resolved.starts_with(canon))
|| std::fs::canonicalize(&absolute).is_ok_and(|resolved| {
install_canon
.as_ref()
.is_some_and(|canon| resolved.starts_with(canon))
})
{
let _ = std::fs::remove_file(&link);
}
}
Err(_) => {
let Some(content) = std::fs::read_to_string(&link).ok() else {
continue;
};
let Some(rel) = aube_linker::parse_posix_shim_target(&content) else {
continue;
};
let resolved = aube_linker::normalize_path(&bin_dir.join(rel));
if resolved.starts_with(&install_lex)
|| install_canon
.as_ref()
.is_some_and(|canon| resolved.starts_with(canon))
{
let _ = std::fs::remove_file(&link);
}
}
}
}
}
#[cfg(windows)]
{
let Ok(install_canon) = std::fs::canonicalize(install_dir) else {
return;
};
for name in bin_names {
let cmd_path = bin_dir.join(format!("{name}.cmd"));
let Ok(content) = std::fs::read_to_string(&cmd_path) else {
continue;
};
let owned = content
.lines()
.filter_map(|line| {
let line = line.trim();
if line.contains("%~dp0\\") && !line.contains(".exe\"") {
let start = line.find("%~dp0\\")?;
let after = &line[start + 6..]; let end = after.find('"')?;
Some(after[..end].to_string())
} else {
None
}
})
.next();
if let Some(rel) = owned {
let resolved = bin_dir.join(&rel);
if let Ok(resolved) = std::fs::canonicalize(&resolved)
&& !resolved.starts_with(&install_canon)
{
continue; }
}
aube_linker::remove_bin_shim(bin_dir, name);
}
}
}
pub fn bin_names_for(install_dir: &Path, aliases: &[String]) -> Vec<String> {
let modules = super::project_modules_dir(install_dir);
let mut out = Vec::new();
for alias in aliases {
let manifest_path = modules.join(alias).join("package.json");
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let Some(bin_field) = json.get("bin") else {
continue;
};
match bin_field {
serde_json::Value::String(_) => {
out.push(alias.rsplit('/').next().unwrap_or(alias).to_string());
}
serde_json::Value::Object(map) => {
for name in map.keys() {
out.push(name.clone());
}
}
_ => {}
}
}
out
}
pub fn remove_package(info: &GlobalPackageInfo, layout: &GlobalLayout) -> miette::Result<()> {
let bins = bin_names_for(&info.install_dir, &info.aliases);
unlink_bins(&info.install_dir, &layout.bin_dir, &bins);
let hash_ptr = hash_link(&layout.pkg_dir, &info.hash);
super::remove_existing(&hash_ptr)?;
let pkg_canon =
crate::dirs::canonicalize(&layout.pkg_dir).unwrap_or_else(|_| layout.pkg_dir.clone());
if info.install_dir.starts_with(&pkg_canon) {
match std::fs::remove_dir_all(&info.install_dir) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e).into_diagnostic().wrap_err_with(|| {
format!(
"failed to remove install dir {}",
info.install_dir.display()
)
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_is_stable_across_alias_order() {
let regs: BTreeMap<String, String> = [(
"default".to_string(),
"https://registry.npmjs.org/".to_string(),
)]
.into_iter()
.collect();
let a = cache_key(&["lodash".into(), "chalk".into()], ®s);
let b = cache_key(&["chalk".into(), "lodash".into()], ®s);
assert_eq!(a, b);
}
#[test]
fn bin_dir_on_path_matches_a_listed_entry() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let path = std::env::join_paths(["/usr/bin".as_ref(), bin.as_os_str()]).unwrap();
assert!(bin_dir_on_path(&bin, Some(&path)));
}
#[test]
fn bin_dir_on_path_rejects_an_absent_entry() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let path = std::env::join_paths(["/usr/bin"]).unwrap();
assert!(!bin_dir_on_path(&bin, Some(&path)));
}
#[test]
fn bin_dir_on_path_is_false_when_path_is_unset() {
let dir = tempfile::tempdir().unwrap();
assert!(!bin_dir_on_path(dir.path(), None));
assert!(!bin_dir_on_path(dir.path(), Some(std::ffi::OsStr::new(""))));
}
#[test]
fn cache_key_changes_with_aliases() {
let regs = BTreeMap::new();
let a = cache_key(&["lodash".into()], ®s);
let b = cache_key(&["chalk".into()], ®s);
assert_ne!(a, b);
}
}