From ba78736c8987f2dc6b9d668cb4a59080ee5e6751 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:48:22 +0000 Subject: [PATCH 1/3] fix(global): unlink global bins when the shared virtual store is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unlink_bins` decided whether a global bin belonged to the install being removed by canonicalizing the symlink target and requiring it to live under the install dir. With the global virtual store enabled — the default outside CI — `node_modules/` resolves through `.aube/` into `/virtual-store/-`, so the canonical target is never under the install dir: every global bin was read as owned by some other install and left behind, dangling once `remove -g` deleted the install dir. Check the lexically-normalized target first, the way the regular-file shim branch already did, and keep canonicalization as a fallback for bins linked by older versions. A bin overwritten by a later `add -g` still points at that install's path, so ownership semantics hold. The existing test missed this because `assert_file_not_exists` is `[ -f ]`, which follows symlinks and therefore passes for a dangling one; it now also asserts `[ ! -L ]`. Co-Authored-By: Claude Opus 5 --- crates/aube/src/commands/global.rs | 35 ++++++++++++++++++++++-------- test/global_install.bats | 6 +++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/crates/aube/src/commands/global.rs b/crates/aube/src/commands/global.rs index 0a5cedd29..3a6238249 100644 --- a/crates/aube/src/commands/global.rs +++ b/crates/aube/src/commands/global.rs @@ -334,20 +334,37 @@ pub fn unlink_bins(install_dir: &Path, bin_dir: &Path, bin_names: &[String]) { let link = bin_dir.join(name); match std::fs::read_link(&link) { Ok(target) => { - // Symlink bin: fully resolve and check against - // `install_canon`. Matches the pre-settings behavior. + // Symlink bin: `link_bins` wrote the target as + // `/node_modules//`, so the + // ownership check is textual for the same reason the + // shim branch below is. Canonicalizing first resolves + // through `node_modules/` and `.aube/` + // into `/virtual-store/...` whenever the + // global virtual store is on (the default outside CI) — + // that lands outside `install_dir`, the ownership check + // reads the bin as belonging to another install, and + // every global bin leaks as a dangling symlink after + // `remove -g` deletes the install dir. let absolute = if target.is_absolute() { target } else { bin_dir.join(target) }; - let Some(install_canon) = install_canon.as_ref() else { - continue; - }; - let Some(resolved) = std::fs::canonicalize(&absolute).ok() else { - continue; - }; - if resolved.starts_with(install_canon) { + let resolved = aube_linker::normalize_path(&absolute); + // Full canonicalization stays as a fallback: a bin + // linked by an older aube (or a target reached through + // a symlinked `install_dir` ancestor) only matches + // once both sides are resolved. + 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); } } diff --git a/test/global_install.bats b/test/global_install.bats index fa00a3f24..3a7648462 100644 --- a/test/global_install.bats +++ b/test/global_install.bats @@ -213,6 +213,12 @@ teardown() { run aube remove -g semver assert_success assert_file_not_exists "$AUBE_HOME/semver" + # `assert_file_not_exists` is `[ -f ]`, which follows symlinks — a + # *dangling* symlink passes it. With the global virtual store on, the + # bin's canonical target lives in the shared store rather than under + # the install dir, so the ownership check has to stay textual or the + # symlink survives as a dangle (Discussion #1219). + [ ! -L "$AUBE_HOME/semver" ] run aube list -g assert_success From 4cd56fd0acd784da72e2035aac4a4962aad6a8fb Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:49:00 +0000 Subject: [PATCH 2/3] feat(global)!: keep aube's global dirs under its own data root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aube's global install layout lived in pnpm's directories: the default root was `$XDG_DATA_HOME/pnpm`, `~/Library/pnpm` on macOS, or `%LOCALAPPDATA%\pnpm` on Windows, and `PNPM_HOME` was honored ahead of the platform default. Global bins were therefore linked into a directory another package manager owns, and the hardcoded `pnpm` leaf ignored the embedder's `data_namespace` — an embedder shipping under its own brand still installed into `.../pnpm`. Globals now hang off the same data root the store, Node runtimes, and shims already use: /bin # globalBinDir — the dir you put on PATH /global-aube # globalDir — physical installs + pointers where `` is `$XDG_DATA_HOME/`, falling back to `~/.local/share/` (`%LOCALAPPDATA%\` on Windows). `PNPM_HOME` is no longer read. `AUBE_HOME` keeps its meaning — when set it is the bin dir, with installs in a `global-aube/` subdir of it. Dropping the macOS `~/Library/pnpm` special case also means an explicit `XDG_DATA_HOME` is now honored there, which was the one place aube ignored it on macOS (Discussion #1219). Two warnings cover the migration, since both failure modes are otherwise silent: - `WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION` fires when globals are found in a pre-2.0 pnpm-named location and none exist in the new one. The old directory is only read, never written to or deleted. - `WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH` fires when `add -g` links a bin into a directory absent from `$PATH` — previously that reported success and produced a command not found. Reading pnpm's *files* is untouched: `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and `~/.config/pnpm/auth.ini` are compat surfaces, not directories aube owns. BREAKING CHANGE: `aube add -g` installs into `/global-aube` and links bins into `/bin` instead of pnpm's directories, and `PNPM_HOME` is no longer consulted. Packages installed globally by an earlier version are not migrated: they stay on disk, their bins keep working if the old directory is still on `PATH`, but `aube list -g` and `aube remove -g` no longer see them. Reinstall them with `aube add -g ` after putting the new bin dir on `PATH`, or set `AUBE_HOME` to the old location to keep the previous layout. Co-Authored-By: Claude Opus 5 --- crates/aube-codes/src/warnings.rs | 18 ++ crates/aube-settings/settings.toml | 21 ++- crates/aube/src/commands/add/global.rs | 4 + crates/aube/src/commands/global.rs | 220 +++++++++++++++++++------ docs/error-codes.data.json | 7 + docs/settings/index.md | 17 +- test/global_install.bats | 80 ++++++++- 7 files changed, 311 insertions(+), 56 deletions(-) diff --git a/crates/aube-codes/src/warnings.rs b/crates/aube-codes/src/warnings.rs index bece101c4..125514030 100644 --- a/crates/aube-codes/src/warnings.rs +++ b/crates/aube-codes/src/warnings.rs @@ -98,6 +98,10 @@ pub const WARN_AUBE_LOCKFILE_MALFORMED_PEER_SUFFIX: &str = "WARN_AUBE_LOCKFILE_MALFORMED_PEER_SUFFIX"; pub const WARN_AUBE_GLOBAL_OUTDATED_NO_LOCKFILE: &str = "WARN_AUBE_GLOBAL_OUTDATED_NO_LOCKFILE"; +// ── global installs ───────────────────────────────────────────────── +pub const WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION: &str = "WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION"; +pub const WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH: &str = "WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH"; + // ── progress UI ───────────────────────────────────────────────────── pub const WARN_AUBE_PROGRESS_OVERFLOW: &str = "WARN_AUBE_PROGRESS_OVERFLOW"; @@ -136,6 +140,7 @@ pub mod category { pub const WORKSPACE_RECURSION: &str = "Workspace recursion"; pub const SUPPLY_CHAIN: &str = "Supply chain (add-time)"; pub const NODE_RUNTIME: &str = "Node runtime"; + pub const GLOBAL_INSTALLS: &str = "Global installs"; } /// Registry of every warning code with its category and description. @@ -537,6 +542,19 @@ pub const ALL: &[CodeMeta] = &[ description: "`aube outdated -g` found a global install without a lockfile and skipped that install.", exit_code: None, }, + // Global installs + CodeMeta { + name: WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION, + category: category::GLOBAL_INSTALLS, + description: "Global packages were found under the pnpm-named directory aube used before it owned its own global layout (`$PNPM_HOME`, `$XDG_DATA_HOME/pnpm`, `~/Library/pnpm`, `%LOCALAPPDATA%\\pnpm`), while the current global directory holds none. Those installs are no longer visible to `aube list -g` / `remove -g`; reinstall them with `aube add -g`, or point `AUBE_HOME` at the old directory.", + exit_code: None, + }, + CodeMeta { + name: WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH, + category: category::GLOBAL_INSTALLS, + description: "`aube add -g` linked a bin into a directory that is not on `$PATH`, so the command it installed won't be found. Add the directory to `PATH`, or point `globalBinDir` / `AUBE_HOME` at one that already is.", + exit_code: None, + }, // Progress UI CodeMeta { name: WARN_AUBE_PROGRESS_OVERFLOW, diff --git a/crates/aube-settings/settings.toml b/crates/aube-settings/settings.toml index 34ce6c93a..297ab9b50 100644 --- a/crates/aube-settings/settings.toml +++ b/crates/aube-settings/settings.toml @@ -2617,7 +2617,16 @@ npmShared = true description = "Directory where globally installed packages live." type = "path" default = "platform-specific" -docs = "Overrides the directory where globally installed packages live. Falls back to `AUBE_HOME` / `PNPM_HOME` / platform default." +docs = """ +Overrides the directory holding the physical per-package install dirs +for `aube add -g`. Falls back to `AUBE_HOME` (when set, installs go in +a `global-aube/` subdir of it), otherwise `/global-aube` — +`$XDG_DATA_HOME/aube/global-aube`, `~/.local/share/aube/global-aube`, +or `%LOCALAPPDATA%\\aube\\global-aube` on Windows. + +`PNPM_HOME` is not consulted: aube keeps its global layout under its +own data root rather than installing into pnpm's directories. +""" sources.cli = [] sources.env = ["npm_config_global_dir", "NPM_CONFIG_GLOBAL_DIR", "AUBE_GLOBAL_DIR"] sources.npmrc = ["globalDir"] @@ -2627,7 +2636,15 @@ examples = [] description = "Directory where global binaries are symlinked." type = "path" default = "platform-specific" -docs = "Overrides the directory where global binaries are symlinked. Independent of `globalDir`; falls back to `AUBE_HOME` / `PNPM_HOME` / platform default." +docs = """ +Overrides the directory global binaries are symlinked into — the one +you put on `$PATH`. Independent of `globalDir`; falls back to +`AUBE_HOME`, otherwise `/bin` (`$XDG_DATA_HOME/aube/bin`, +`~/.local/share/aube/bin`, or `%LOCALAPPDATA%\\aube\\bin` on Windows). + +`PNPM_HOME` is not consulted: aube keeps its global layout under its +own data root rather than installing into pnpm's directories. +""" sources.cli = [] sources.env = ["npm_config_global_bin_dir", "NPM_CONFIG_GLOBAL_BIN_DIR", "AUBE_GLOBAL_BIN_DIR"] sources.npmrc = ["globalBinDir"] diff --git a/crates/aube/src/commands/add/global.rs b/crates/aube/src/commands/add/global.rs index 65ab1ba39..8b272b032 100644 --- a/crates/aube/src/commands/add/global.rs +++ b/crates/aube/src/commands/add/global.rs @@ -345,6 +345,10 @@ async fn run_global_inner( pluralizer::pluralize("bin", linked.len() as isize, true), layout.bin_dir.display() ); + // Linking a bin into a directory that isn't on `$PATH` produces a + // successful install whose command is not found — the symptom the + // user reports is "aube add -g did nothing". Say so here instead. + global::warn_if_bin_dir_not_on_path(&layout.bin_dir); } Ok(()) diff --git a/crates/aube/src/commands/global.rs b/crates/aube/src/commands/global.rs index 3a6238249..ec020a84e 100644 --- a/crates/aube/src/commands/global.rs +++ b/crates/aube/src/commands/global.rs @@ -1,10 +1,14 @@ //! Global install layout — `aube add -g`, `aube remove -g`, `aube list -g`. //! -//! Modeled on pnpm v11's per-install-dir layout: +//! The per-install-dir *shape* follows pnpm v11, but the directories are +//! aube's own — everything hangs off the tool's data root +//! (`$XDG_DATA_HOME/aube`, `~/.local/share/aube`, `%LOCALAPPDATA%\aube`), +//! alongside `store/`, `nodejs/`, and `shims/`: //! //! ```text -//! / # on PATH; bins symlink into here -//! ├── some-bin -> //node_modules/.bin/some-bin +//! / # `aube prefix -g` +//! ├── bin/ # : on PATH; bins symlink into here +//! │ └── some-bin -> //node_modules/.bin/some-bin //! └── global-aube/ # : one subdir per global package //! ├── -/ # physical install dir (normal aube project) //! │ ├── package.json @@ -31,8 +35,8 @@ use std::path::{Path, PathBuf}; /// /// `bin_dir` is the directory the user is expected to have on `$PATH` — /// it's where bin symlinks live. `pkg_dir` is where the per-install -/// directories and hash pointers live; it's an aube-specific subdir so we -/// never step on a sibling pnpm install. +/// directories and hash pointers live; it's a tool-specific subdir so two +/// tools sharing one explicitly-set home don't step on each other. #[derive(Debug, Clone)] pub struct GlobalLayout { pub bin_dir: PathBuf, @@ -46,7 +50,7 @@ impl GlobalLayout { // `bin_dir` and `pkg_dir` are independent: `globalBinDir` controls // where bin symlinks go (on PATH), `globalDir` controls where // package installs live. Neither inherits from the other — both - // fall back to the default home (_HOME → PNPM_HOME → platform). + // fall back to their own default (_HOME → the data root). 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)); @@ -55,72 +59,188 @@ impl GlobalLayout { (bin, pkg) }); - let bin_dir = setting_bin.map_or_else(resolve_home, Ok)?; - // Package-install subdir named after the active embedder so we never - // step on a sibling pnpm install. Standalone aube → `global-aube`. + let bin_dir = setting_bin.map_or_else(default_bin_dir, Ok)?; + // Package-install subdir named after the active embedder so two + // tools sharing an explicitly-set `_HOME` don't collide. + // Standalone aube → `global-aube`. let pkg_subdir = format!("global-{}", aube_util::embedder().name); - let pkg_dir = setting_pkg.map_or_else( - || resolve_home().map(|h| h.join(&pkg_subdir)), - |p| Ok(p.join(&pkg_subdir)), - )?; + 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 }) } } -/// Resolve the PATH-visible root. Honors the branded `_HOME` -/// (standalone aube → `AUBE_HOME`), then `PNPM_HOME` (so existing pnpm users -/// already have the right dir on PATH), then a platform-specific pnpm-style -/// default. An embedder with no `env_prefix` skips the branded var. -fn resolve_home() -> miette::Result { - if let Some(prefix) = aube_util::embedder().env_prefix - && let Ok(v) = std::env::var(format!("{prefix}_HOME")) - && !v.is_empty() +/// The branded home override (standalone aube → `AUBE_HOME`). When set it +/// *is* the PATH-visible bin dir, and package installs go in a subdir of +/// it — the pre-existing contract for people who opted in explicitly. An +/// embedder with no `env_prefix` skips the branded var. +fn branded_home() -> Option { + let prefix = aube_util::embedder().env_prefix?; + std::env::var(format!("{prefix}_HOME")) + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) +} + +/// The tool's own data root: `$XDG_DATA_HOME/`, falling back to +/// `~/.local/share/` (`%LOCALAPPDATA%\` on Windows). Same +/// resolution `aube_store::dirs::store_dir` uses, so global installs land +/// beside `store/`, `nodejs/`, and `shims/` instead of in a directory +/// named after another package manager. `` is the active embedder's +/// `data_namespace` (standalone aube → `aube`). +/// +/// XDG is honored on every platform, macOS included — aube already does +/// that for the store and the packument cache, and the previous +/// `~/Library/pnpm` special case was the one place a macOS user's +/// explicit `XDG_DATA_HOME` was ignored (Discussion #1219). +fn data_root() -> miette::Result { + let ns = aube_util::embedder().data_namespace; + #[cfg(windows)] { - return Ok(PathBuf::from(v)); + let local = std::env::var("LOCALAPPDATA") + .map_err(|_| miette!("LOCALAPPDATA is not set; can't locate global directory"))?; + return Ok(PathBuf::from(local).join(ns)); } - if let Ok(v) = std::env::var("PNPM_HOME") - && !v.is_empty() + #[cfg(not(windows))] { - return Ok(PathBuf::from(v)); + if let Some(xdg) = aube_util::env::xdg_data_home() { + return Ok(xdg.join(ns)); + } + let home = aube_util::env::home_dir() + .ok_or_else(|| miette!("HOME is not set; can't locate global directory"))?; + Ok(home.join(".local/share").join(ns)) + } +} + +/// Default for `globalBinDir` — the directory the user puts on `$PATH`. +/// `/bin` rather than the data root itself, so the PATH entry +/// holds bins and nothing else. +fn default_bin_dir() -> miette::Result { + if let Some(home) = branded_home() { + return Ok(home); + } + data_root().map(|d| d.join("bin")) +} + +/// Default for `globalDir` — where the physical per-package install dirs +/// and their hash pointers live. A sibling of `bin/`, not a child: the +/// PATH entry stays a directory of executables. +fn default_pkg_dir(pkg_subdir: &str) -> miette::Result { + if let Some(home) = branded_home() { + return Ok(home.join(pkg_subdir)); } - platform_default() + data_root().map(|d| d.join(pkg_subdir)) } /// Resolve the global prefix root. This is distinct from `globalBinDir`: /// users may point global bin symlinks somewhere else while the prefix -/// itself still comes from `AUBE_HOME` / `PNPM_HOME` / the platform default. +/// itself still comes from `AUBE_HOME` / the platform default. pub fn prefix_dir() -> miette::Result { - resolve_home() + if let Some(home) = branded_home() { + return Ok(home); + } + data_root() } -// Linux plus every other Unix (FreeBSD, …): pnpm special-cases only -// macOS (`~/Library/pnpm`), while Windows has its own arm below. Scoped -// to `unix` so a non-Unix, non-Windows target doesn't silently inherit -// the XDG/HOME logic — it gets a compile error instead, which is the -// signal we'd want before shipping such a build. -#[cfg(all(unix, not(target_os = "macos")))] -fn platform_default() -> miette::Result { - if let Some(xdg) = aube_util::env::xdg_data_home() { - return Ok(xdg.join("pnpm")); +/// Directories a pre-2.0 aube used as its global root, in the order that +/// version consulted them. Read only to warn: aube never installs into, +/// reads packages out of, or deletes anything under a pnpm-owned path. +fn legacy_home_candidates() -> Vec { + let mut out = Vec::new(); + if let Ok(v) = std::env::var("PNPM_HOME") + && !v.is_empty() + { + out.push(PathBuf::from(v)); } - let home = aube_util::env::home_dir() - .ok_or_else(|| miette!("HOME is not set; can't locate global directory"))?; - Ok(home.join(".local/share/pnpm")) + 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 } -#[cfg(target_os = "macos")] -fn platform_default() -> miette::Result { - let home = std::env::var("HOME") - .map_err(|_| miette!("HOME is not set; can't locate global directory"))?; - Ok(PathBuf::from(home).join("Library/pnpm")) +/// True when `pkg_dir` holds at least one hash pointer — i.e. at least one +/// global package is installed there. +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())) + }) } -#[cfg(target_os = "windows")] -fn platform_default() -> miette::Result { - let local = std::env::var("LOCALAPPDATA") - .map_err(|_| miette!("LOCALAPPDATA is not set; can't locate global directory"))?; - Ok(PathBuf::from(local).join("pnpm")) +/// Warn once per process when the caller has global packages stranded in +/// a pre-2.0 (pnpm-named) location and none in the current one. Without +/// this, `aube list -g` just comes back empty and the bins already on +/// `$PATH` keep working while `remove -g` claims they aren't installed — +/// the failure mode is silent, so the warning is the migration path. +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 "), + aube_util::embedder().env_prefix.unwrap_or("AUBE"), + legacy.display(), + ); + }); +} + +/// Warn when `bin_dir` is absent from `$PATH`. Compared canonically so a +/// `$PATH` entry that reaches the same directory through a symlink (or a +/// `~`-relative vs absolute spelling) still counts as a match; entries +/// that don't resolve are compared verbatim. +pub fn warn_if_bin_dir_not_on_path(bin_dir: &Path) { + let want = std::fs::canonicalize(bin_dir).unwrap_or_else(|_| bin_dir.to_path_buf()); + let Some(path) = std::env::var_os("PATH") else { + return; + }; + let on_path = std::env::split_paths(&path) + .any(|entry| std::fs::canonicalize(&entry).unwrap_or(entry) == want); + if on_path { + 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(), + ); } /// Create a fresh install directory under `pkg_dir`. Matches pnpm's naming diff --git a/docs/error-codes.data.json b/docs/error-codes.data.json index 11e8cbc50..3157ebd01 100644 --- a/docs/error-codes.data.json +++ b/docs/error-codes.data.json @@ -908,6 +908,12 @@ "description": "`aube outdated -g` found a global install without a lockfile and skipped that install.", "exit_code": null }, + { + "name": "WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION", + "category": "Global installs", + "description": "Global packages were found under the pnpm-named directory aube used before it owned its own global layout (`$PNPM_HOME`, `$XDG_DATA_HOME/pnpm`, `~/Library/pnpm`, `%LOCALAPPDATA%\\pnpm`), while the current global directory holds none. Those installs are no longer visible to `aube list -g` / `remove -g`; reinstall them with `aube add -g`, or point `AUBE_HOME` at the old directory.", + "exit_code": null + }, { "name": "WARN_AUBE_PROGRESS_OVERFLOW", "category": "Progress UI", @@ -1006,6 +1012,7 @@ "Registry TLS / proxy", "Resolver", "Lockfile", + "Global installs", "Progress UI", "Workspace recursion", "Supply chain (add-time)", diff --git a/docs/settings/index.md b/docs/settings/index.md index ea920e5a7..49dce2524 100644 --- a/docs/settings/index.md +++ b/docs/settings/index.md @@ -2628,7 +2628,14 @@ Directory where globally installed packages live. - Environment: `npm_config_global_dir`, `NPM_CONFIG_GLOBAL_DIR`, `AUBE_GLOBAL_DIR` - .npmrc keys: `globalDir`, `global-dir` -Overrides the directory where globally installed packages live. Falls back to `AUBE_HOME` / `PNPM_HOME` / platform default. +Overrides the directory holding the physical per-package install dirs +for `aube add -g`. Falls back to `AUBE_HOME` (when set, installs go in +a `global-aube/` subdir of it), otherwise `/global-aube` — +`$XDG_DATA_HOME/aube/global-aube`, `~/.local/share/aube/global-aube`, +or `%LOCALAPPDATA%\aube\global-aube` on Windows. + +`PNPM_HOME` is not consulted: aube keeps its global layout under its +own data root rather than installing into pnpm's directories. ### `globalBinDir` {#setting-globalbindir} @@ -2639,7 +2646,13 @@ Directory where global binaries are symlinked. - Environment: `npm_config_global_bin_dir`, `NPM_CONFIG_GLOBAL_BIN_DIR`, `AUBE_GLOBAL_BIN_DIR` - .npmrc keys: `globalBinDir`, `global-bin-dir` -Overrides the directory where global binaries are symlinked. Independent of `globalDir`; falls back to `AUBE_HOME` / `PNPM_HOME` / platform default. +Overrides the directory global binaries are symlinked into — the one +you put on `$PATH`. Independent of `globalDir`; falls back to +`AUBE_HOME`, otherwise `/bin` (`$XDG_DATA_HOME/aube/bin`, +`~/.local/share/aube/bin`, or `%LOCALAPPDATA%\aube\bin` on Windows). + +`PNPM_HOME` is not consulted: aube keeps its global layout under its +own data root rather than installing into pnpm's directories. ### `npmrcAuthFile` {#setting-npmrcauthfile} diff --git a/test/global_install.bats b/test/global_install.bats index 3a7648462..c220d3b96 100644 --- a/test/global_install.bats +++ b/test/global_install.bats @@ -45,11 +45,87 @@ teardown() { assert_output "$AUBE_HOME" } -@test "aube bin -g honors PNPM_HOME when AUBE_HOME is unset" { +@test "aube bin -g ignores PNPM_HOME" { + # aube owns its global layout; it never links bins into pnpm's + # directory, even when PNPM_HOME is the only home-ish var set. unset AUBE_HOME PNPM_HOME="$TEST_TEMP_DIR/pnpm-home" run aube bin -g assert_success - assert_output "$TEST_TEMP_DIR/pnpm-home" + assert_output "$XDG_DATA_HOME/aube/bin" +} + +@test "aube -g dirs default under the data root, honoring XDG_DATA_HOME" { + unset AUBE_HOME + + run aube prefix -g + assert_success + assert_output "$XDG_DATA_HOME/aube" + + run aube bin -g + assert_success + assert_output "$XDG_DATA_HOME/aube/bin" + + # Package installs are a *sibling* of the PATH dir, not a child of it. + run aube root -g + assert_success + assert_output "$XDG_DATA_HOME/aube/global-aube" +} + +@test "aube -g dirs fall back to ~/.local/share without XDG_DATA_HOME" { + unset AUBE_HOME + unset XDG_DATA_HOME + + run aube bin -g + assert_success + assert_output "$HOME/.local/share/aube/bin" +} + +@test "aube list -g warns when globals are stranded in the pnpm-era location" { + unset AUBE_HOME + # A hash pointer under the legacy pnpm-named home is what a pre-2.0 + # aube left behind. Nothing is installed in the new location, so the + # migration warning fires. + legacy="$XDG_DATA_HOME/pnpm/global-aube" + mkdir -p "$legacy/2d8d9b-19fcea7c050" + ln -s "$legacy/2d8d9b-19fcea7c050" "$legacy/deadbeef" + + run aube list -g + assert_success + assert_output --partial "WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION" + # The message points at the legacy *home*, since that's what the user + # would hand to AUBE_HOME to keep the old location working. + assert_output --partial "$XDG_DATA_HOME/pnpm" + # Read-only: aube warns about the pnpm-era directory, never touches it. + assert_link_exists "$legacy/deadbeef" +} + +@test "aube list -g stays quiet about the legacy dir once globals are installed" { + unset AUBE_HOME + legacy="$XDG_DATA_HOME/pnpm/global-aube" + mkdir -p "$legacy/2d8d9b-19fcea7c050" + ln -s "$legacy/2d8d9b-19fcea7c050" "$legacy/deadbeef" + + run aube add -g semver@7.7.4 + assert_success + + run aube list -g + assert_success + refute_output --partial "WARN_AUBE_GLOBAL_DIR_LEGACY_LOCATION" +} + +@test "aube add -g warns when the global bin dir is not on PATH" { + # AUBE_HOME is not on PATH in the bats env, so the bin aube just + # linked is unreachable — say so rather than reporting plain success. + run aube add -g semver@7.7.4 + assert_success + assert_output --partial "WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH" + assert_output --partial "$AUBE_HOME" +} + +@test "aube add -g stays quiet when the global bin dir is on PATH" { + PATH="$AUBE_HOME:$PATH" run aube add -g semver@7.7.4 + assert_success + refute_output --partial "WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH" } @test "aube list -g reports nothing on an empty global dir" { From ac22be64e64e68a669ee1bd664532a36fbacc343 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:10:13 +0000 Subject: [PATCH 3/3] fix(global): regenerate error-code docs and tighten PATH detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the global-directory relocation: - `docs/error-codes.data.json` was generated before `WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH` was added, so the published code table was missing it and CI's `assert render produces no diff` gate failed. Regenerated via `mise run render`. - An unset `PATH` made the not-on-PATH check return early instead of warning, even though nothing is reachable in that state. Extracted `bin_dir_on_path`, which treats a missing `PATH` as an empty search list, and unit-tested the listed / absent / unset cases. - `data_root` now falls back to `XDG_DATA_HOME` (then `~/.local/share`) on Windows when `%LOCALAPPDATA%` is unset, rather than erroring — matching `aube_store::dirs::store_dir`. `%LOCALAPPDATA%` still takes precedence there so the global dir and the content store can't end up under different roots. Co-Authored-By: Claude Opus 5 --- crates/aube/src/commands/global.rs | 90 +++++++++++++++++++++--------- docs/error-codes.data.json | 6 ++ 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/crates/aube/src/commands/global.rs b/crates/aube/src/commands/global.rs index ec020a84e..06183862e 100644 --- a/crates/aube/src/commands/global.rs +++ b/crates/aube/src/commands/global.rs @@ -91,27 +91,32 @@ fn branded_home() -> Option { /// named after another package manager. `` is the active embedder's /// `data_namespace` (standalone aube → `aube`). /// -/// XDG is honored on every platform, macOS included — aube already does -/// that for the store and the packument cache, and the previous -/// `~/Library/pnpm` special case was the one place a macOS user's -/// explicit `XDG_DATA_HOME` was ignored (Discussion #1219). +/// XDG is honored on every Unix, macOS included — aube already does that +/// for the store and the packument cache, and the previous `~/Library/pnpm` +/// special case was the one place a macOS user's explicit `XDG_DATA_HOME` +/// was ignored (Discussion #1219). +/// +/// Precedence matches `store_dir` exactly, including `%LOCALAPPDATA%` +/// winning over `XDG_DATA_HOME` on Windows: the global dir and the content +/// store must not end up under different roots on the same machine. fn data_root() -> miette::Result { let ns = aube_util::embedder().data_namespace; #[cfg(windows)] + if let Ok(local) = std::env::var("LOCALAPPDATA") + && !local.is_empty() { - let local = std::env::var("LOCALAPPDATA") - .map_err(|_| miette!("LOCALAPPDATA is not set; can't locate global directory"))?; return Ok(PathBuf::from(local).join(ns)); } - #[cfg(not(windows))] - { - if let Some(xdg) = aube_util::env::xdg_data_home() { - return Ok(xdg.join(ns)); - } - let home = aube_util::env::home_dir() - .ok_or_else(|| miette!("HOME is not set; can't locate global directory"))?; - Ok(home.join(".local/share").join(ns)) - } + // Reached on every Unix, and on Windows when `%LOCALAPPDATA%` is + // missing — where an explicitly-set `XDG_DATA_HOME` is a better answer + // than failing outright, again mirroring `store_dir`. + 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)) } /// Default for `globalBinDir` — the directory the user puts on `$PATH`. @@ -219,18 +224,24 @@ fn warn_on_legacy_global_dir(pkg_dir: &Path, pkg_subdir: &str) { }); } -/// Warn when `bin_dir` is absent from `$PATH`. Compared canonically so a -/// `$PATH` entry that reaches the same directory through a symlink (or a -/// `~`-relative vs absolute spelling) still counts as a match; entries -/// that don't resolve are compared verbatim. -pub fn warn_if_bin_dir_not_on_path(bin_dir: &Path) { - let want = std::fs::canonicalize(bin_dir).unwrap_or_else(|_| bin_dir.to_path_buf()); - let Some(path) = std::env::var_os("PATH") else { - return; +/// Whether `bin_dir` is one of the directories in `path_var`. Compared +/// canonically so a `$PATH` entry that reaches the same directory through a +/// symlink (or a `~`-relative vs absolute spelling) still counts as a +/// match; entries that don't resolve are compared verbatim. +/// +/// `None` (an unset `PATH`) is not on `PATH` — nothing is — so it answers +/// `false` rather than being treated as "can't tell, assume fine". +fn bin_dir_on_path(bin_dir: &Path, path_var: Option<&std::ffi::OsStr>) -> bool { + let Some(path) = path_var else { + return false; }; - let on_path = std::env::split_paths(&path) - .any(|entry| std::fs::canonicalize(&entry).unwrap_or(entry) == want); - if on_path { + 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) +} + +/// Warn when `bin_dir` is absent from `$PATH`. +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!( @@ -659,6 +670,33 @@ mod tests { 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))); + } + + /// An unset `PATH` means the bin is unreachable, so `add -g` must still + /// warn — the check can't quietly pass because it has nothing to search. + #[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(); diff --git a/docs/error-codes.data.json b/docs/error-codes.data.json index 3157ebd01..649980c07 100644 --- a/docs/error-codes.data.json +++ b/docs/error-codes.data.json @@ -914,6 +914,12 @@ "description": "Global packages were found under the pnpm-named directory aube used before it owned its own global layout (`$PNPM_HOME`, `$XDG_DATA_HOME/pnpm`, `~/Library/pnpm`, `%LOCALAPPDATA%\\pnpm`), while the current global directory holds none. Those installs are no longer visible to `aube list -g` / `remove -g`; reinstall them with `aube add -g`, or point `AUBE_HOME` at the old directory.", "exit_code": null }, + { + "name": "WARN_AUBE_GLOBAL_BIN_DIR_NOT_ON_PATH", + "category": "Global installs", + "description": "`aube add -g` linked a bin into a directory that is not on `$PATH`, so the command it installed won't be found. Add the directory to `PATH`, or point `globalBinDir` / `AUBE_HOME` at one that already is.", + "exit_code": null + }, { "name": "WARN_AUBE_PROGRESS_OVERFLOW", "category": "Progress UI",