From dc54118e014c5020182a8df4f2b02796fd37a600 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:11:38 -0700 Subject: [PATCH 1/5] pm: honor NUB_CACHE_DIR for the engine cache, and report the env tier in nub config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An env-set `cacheDir` was inert in 0.6.0: a hand-written `.npmrc`-only presence gate ran ahead of the settings resolver, so `npm_config_cache_dir` and `NPM_CONFIG_CACHE_DIR` were accepted and discarded while the `.npmrc` spelling worked. The v1.35.0 engine sync dropped that gate and v0.7.0 shipped the fix, but nothing pinned it and two related defects were still live. `resolved_cache_dir` now reads the host's first-class `config_env("CACHE_DIR")` knob ahead of the settings chain. Only the resolver primer read it before, so the one spelling under nub's own brand moved a subdirectory of the cache rather than the cache. Standalone aube is unchanged: `config_env` composes the same `AUBE_CACHE_DIR` the settings table declares, which already outranked the `npm_config_*` forms, so this preserves that order rather than introducing one. `config get` and `config list` gained the env tier. `read_merged` composed embedder defaults plus files, so an env-set setting was invisible to both — an install moved the cache while `config get cache-dir` printed `undefined`. The tier is appended last, matching `cli > env > files`. Only config-carrying variables are surfaced: the `npm_config_*` family, its pnpm sibling, and the active brand's prefix. An ambient variable a setting merely observes is not configuration and stays out, which is where npm draws the line. That test is an allow-list because several settings declare both spellings of a proxy var, so a deny-list naming the uppercase forms would hide one and report the other. Scoped reads are unchanged: `--local` and `--global` ask about a file. The new `pm_env_matrix` test pins every spelling, the precedence, and both exclusions, offline, each row against a control that fails when the behavior is absent. Four comments describing the removed gate and a since-deleted `set_cache_root` registration are corrected, and the cache directory is now documented. Closes #654 --- .../nub-cli/src/pm_engine/install_family.rs | 9 +- crates/nub-cli/src/pm_engine/mod.rs | 33 +- .../src/pm_engine/store_config_family.rs | 13 +- crates/nub-cli/tests/pm_env_matrix.rs | 288 ++++++++++++++++++ site/content/docs/install/index.mdx | 24 ++ .../aube/crates/aube-settings/src/values.rs | 27 +- vendor/aube/crates/aube-util/src/env.rs | 33 ++ .../crates/aube/src/commands/config/list.rs | 16 +- .../crates/aube/src/commands/config/mod.rs | 65 +++- .../aube/src/commands/settings_context.rs | 24 +- wiki/agents.md | 2 +- 11 files changed, 495 insertions(+), 39 deletions(-) create mode 100644 crates/nub-cli/tests/pm_env_matrix.rs diff --git a/crates/nub-cli/src/pm_engine/install_family.rs b/crates/nub-cli/src/pm_engine/install_family.rs index c5a9a6754..238d50f7c 100644 --- a/crates/nub-cli/src/pm_engine/install_family.rs +++ b/crates/nub-cli/src/pm_engine/install_family.rs @@ -93,9 +93,12 @@ //! store label from the embedder identity — upstreamable as multicall //! correctness), tracked with the brand-toggle fork items. //! - `link`/`unlink -g` use the engine's global-links registry under -//! `/aube/global-links` (`global_links_dir()` derives from -//! the leaf-fixed `aube_store::dirs::cache_dir()` — literally the -//! `cacheDir` gap in `super::nub_setting_defaults`). +//! `/nub/pm/global-links`. `global_links_dir()` derives from +//! `aube_store::dirs::cache_dir()`, so it follows the identity's +//! `cache_namespace` but NOT a `cacheDir` override — relocating the cache +//! leaves the global-links registry behind. Same shape as #643 on the +//! `storeDir` axis: an override the settings-resolved consumers honor and a +//! platform-default consumer does not. //! Printed paths name that directory truthfully; the rewrite preserves //! on-disk names by design. //! - `dlx` propagates the child's exit code via `std::process::exit` diff --git a/crates/nub-cli/src/pm_engine/mod.rs b/crates/nub-cli/src/pm_engine/mod.rs index 3cd4ca956..fc9e4dbb7 100644 --- a/crates/nub-cli/src/pm_engine/mod.rs +++ b/crates/nub-cli/src/pm_engine/mod.rs @@ -536,16 +536,16 @@ pub fn dispatch_verb( /// this verb mid-lifecycle-script — and under nub, `current_exe()` IS /// nub — so cli.rs intercepts the spelling before clap and lands here. /// The printed path is data for the shim (it lands under nub's own cache -/// root via the `set_cache_root` registration), so stdout is passed -/// through; failures route through the brand rewrite like every other -/// engine report. +/// root, which the identity profile's `cache_namespace` carries), so stdout +/// is passed through; failures route through the brand rewrite like every +/// other engine report. pub(crate) fn run_node_gyp_bootstrap(args: &[String]) -> Result { let [project_dir] = args else { anyhow::bail!("usage: nub __node-gyp-bootstrap "); }; // Register nub's static identity FIRST so the bootstrap's cache lands under - // nub's namespace (`$XDG_CACHE/nub/pm/tools/node-gyp`, via the `set_cache_root` - // the identity carries) rather than aube's. This re-entry runs as a fresh + // nub's namespace (`$XDG_CACHE/nub/pm/tools/node-gyp`, via the identity's + // `cache_namespace`) rather than aube's. This re-entry runs as a fresh // child process spawned by the engine's lazy shim (`AUBE_NODE_GYP_EXE // __node-gyp-bootstrap `, where `current_exe()` is nub) before any other // preflight, so the namespace registration has to happen here. @@ -2453,14 +2453,21 @@ fn read_file_head(path: &Path, max_bytes: usize) -> std::io::Result { /// `…/nub/store/v1`). Skipped when no home directory resolves — the /// engine then falls back to its own default, which fails the same way /// nub would. -/// - `cacheDir` is still NOT set here — the engine cache moves through the -/// `aube::set_cache_root` registration in [`engine_brand_preflight`] -/// instead. The settings accessor (`resolved_cache_dir`) only consults -/// the setting when `.npmrc` sets it *explicitly* (the embedder-defaults -/// tier never reaches it, verified empirically 2026-06-09), and the -/// non-settings consumers (git clone cache, node-gyp tool cache, primer, -/// adaptive state) never read the setting at all; the process-global -/// cache root covers every one of them. +/// - `cacheDir` is still NOT set here, and does not need to be: the DEFAULT +/// location comes from the identity profile's `cache_namespace` +/// (`$XDG_CACHE/nub/pm`), which is where the engine's own +/// `aube_store::dirs::cache_dir()` lands. The setting layers a user +/// OVERRIDE on top of that default and resolves through the full chain — +/// `NUB_CACHE_DIR` / `npm_config_cache_dir` / `.npmrc cache-dir` all reach +/// `resolved_cache_dir`, embedder defaults included. (Both halves of this +/// bullet used to say otherwise: an `.npmrc`-only presence gate in front of +/// the accessor made every other source inert until it was dropped by the +/// v1.35.0 engine sync (#621), and the host-branded `NUB_CACHE_DIR` reached +/// only the resolver primer until #654. `nub_setting_defaults` is pinned by +/// `pm_env_matrix::cache_dir_env_moves_the_pm_cache`.) The handful of +/// non-settings consumers that deliberately stay on the platform default +/// regardless — the OSV advisory mirror, the bootstrapped node-gyp, git +/// clones — are documented as such on the setting itself. /// - `defaultTrust=true` — the gated default-trust floor (curated list ∧ /// registry-resolved ∧ OSV MAL-* gate active ∧ past the cooling window) /// is ON under nub in both modes; upstream aube keeps it off. Precedence diff --git a/crates/nub-cli/src/pm_engine/store_config_family.rs b/crates/nub-cli/src/pm_engine/store_config_family.rs index 07ba1e43b..e921e7eeb 100644 --- a/crates/nub-cli/src/pm_engine/store_config_family.rs +++ b/crates/nub-cli/src/pm_engine/store_config_family.rs @@ -14,11 +14,14 @@ //! - `store path` prints the *resolved* store-version dir on stdout — under //! nub's embedder defaults that is `$XDG_DATA_HOME/nub/store/v1` (data, //! not a diagnostic; already nub-named via the `storeDir` default). -//! - KNOWN GAP (inherited — see `super::nub_setting_defaults`): `cache` -//! operates on the engine's packument cache at `/aube/…` -//! because `cacheDir` can't ride the embedder-defaults tier at the pinned -//! API. Paths printed by `cache view --json` / `cache delete` are real -//! on-disk paths, which the rewrite policy deliberately preserves. +//! - `cache` operates on the engine's packument cache under the RESOLVED +//! `cacheDir` — `/nub/pm/packuments-*` by default (the +//! identity's `cache_namespace`), or wherever `NUB_CACHE_DIR` / +//! `npm_config_cache_dir` / `.npmrc cache-dir` points it. That makes +//! `cache list` the cheapest read-side proof of which cache directory the +//! engine resolved, which is how `pm_env_matrix` pins it. Paths printed by +//! `cache view --json` / `cache delete` are real on-disk paths, which the +//! rewrite policy deliberately preserves. //! - `config` write routing is pnpm-VERSION-AWARE (decision 2026-06-20, //! supersedes the earlier "npmrc-first" routing; **no `config.toml`, ever**). //! The config home for non-layout SCALAR settings is pnpm-version-dependent — diff --git a/crates/nub-cli/tests/pm_env_matrix.rs b/crates/nub-cli/tests/pm_env_matrix.rs new file mode 100644 index 000000000..ea16eec87 --- /dev/null +++ b/crates/nub-cli/tests/pm_env_matrix.rs @@ -0,0 +1,288 @@ +//! Environment-sourced PM settings, behaviorally, through the binary (#654). +//! +//! Two properties that drifted apart once and had nothing pinning them: +//! +//! 1. **The engine acts on an env-set `cacheDir`.** A hand-written +//! `.npmrc`-only presence gate in front of the settings accessor made every +//! env spelling inert; it shipped in 0.6.0 and was removed incidentally by +//! an engine sync, so nothing here would have caught either the break or +//! the fix. The host-branded `NUB_CACHE_DIR` was separately inert until the +//! same issue. +//! 2. **`config get` reports what the engine will act on.** The command read +//! files only, so it denied values the install was already using — the +//! failure the reporter could not diagnose from the output. +//! +//! All rows run OFFLINE. `cache list` walks `/packuments-v1` +//! and prints what it finds, so seeding one file there and asking which +//! directory the engine looked in needs no registry — and gives every row a +//! negative control, since an unset override finds nothing. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn nub_binary() -> PathBuf { + let mut path = std::env::current_exe().unwrap(); + path.pop(); // deps/ + path.pop(); // debug/ + path.push("nub"); + path +} + +/// Env vars that would silently decide these rows if the developer running +/// the suite happens to export one. Scrubbed from every child. +const SCRUBBED: &[&str] = &[ + "NUB_CACHE_DIR", + "AUBE_CACHE_DIR", + "npm_config_cache_dir", + "NPM_CONFIG_CACHE_DIR", + "pnpm_config_cache_dir", + "PNPM_CONFIG_CACHE_DIR", + "npm_config_node_linker", + "NPM_CONFIG_NODE_LINKER", + "npm_config_http_proxy", + "NPM_CONFIG_HTTP_PROXY", + "http_proxy", + "HTTP_PROXY", + "PROXY", + "proxy", + "CI", +]; + +/// A project dir plus an override cache dir seeded with one packument file. +/// The dead-port registry makes any accidental network use fail loudly. +struct Fixture { + project: PathBuf, + override_dir: PathBuf, +} + +fn fixture(tag: &str) -> Fixture { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "nub-pm-env-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&root); + let project = root.join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("package.json"), + r#"{"name":"app","version":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write(project.join(".npmrc"), "registry=http://127.0.0.1:1/\n").unwrap(); + Fixture { + project, + override_dir: seeded_cache(&root, "override"), + } +} + +/// A cache directory holding one packument named after its own leaf, so a +/// `cache list` naming it identifies WHICH directory the engine resolved +/// rather than merely that it resolved one. +fn seeded_cache(root: &Path, leaf: &str) -> PathBuf { + let dir = root.join(leaf); + std::fs::create_dir_all(dir.join("packuments-v1")).unwrap(); + std::fs::write(dir.join("packuments-v1").join(format!("{leaf}.json")), "{}").unwrap(); + dir +} + +/// `.npmrc` takes forward slashes on every platform; a Windows path written +/// raw would land backslashes in an ini value. +fn npmrc_path(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + +fn run(fx: &Fixture, env: &[(&str, &str)], args: &[&str]) -> (String, i32) { + let mut cmd = Command::new(nub_binary()); + cmd.args(args) + .current_dir(&fx.project) + .env("NUB_SELF_SHIM", "0") + .env("XDG_DATA_HOME", fx.project.join("xdg-data")) + .env("XDG_CACHE_HOME", fx.project.join("xdg-cache")); + for key in SCRUBBED { + cmd.env_remove(key); + } + for (key, value) in env { + cmd.env(key, value); + } + let out = cmd.output().expect("failed to spawn nub"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + out.status.code().unwrap_or(-1), + 0, + "`nub {}` failed\nstdout: {stdout}\nstderr: {stderr}", + args.join(" ") + ); + (stdout, out.status.code().unwrap_or(-1)) +} + +/// Which seeded cache directory the engine resolved, as named by `cache list`. +fn cached_names(fx: &Fixture, env: &[(&str, &str)]) -> Vec { + let (stdout, _) = run(fx, env, &["cache", "list"]); + stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + +/// Every declared spelling of the `cacheDir` override relocates the cache the +/// engine actually reads — the neutral npm forms and the host-branded +/// `NUB_CACHE_DIR`, which reaches the cache through the embedder's +/// `config_env` knob rather than the settings table. +#[test] +fn cache_dir_env_moves_the_pm_cache() { + let fx = fixture("cache-dir-env"); + let dir = fx.override_dir.display().to_string(); + + assert_eq!( + cached_names(&fx, &[]), + Vec::::new(), + "control: with no override set, the default cache is empty — without \ + this every row below would pass on a cache that never moved" + ); + + for key in [ + "npm_config_cache_dir", + "NPM_CONFIG_CACHE_DIR", + "NUB_CACHE_DIR", + ] { + assert_eq!( + cached_names(&fx, &[(key, dir.as_str())]), + vec!["override".to_string()], + "{key} did not move the cache the engine reads" + ); + } + + assert_eq!( + cached_names(&fx, &[("AUBE_CACHE_DIR", dir.as_str())]), + Vec::::new(), + "the engine's own brand must stay unreadable under nub" + ); +} + +/// Env outranks `.npmrc`, in both directions. The `.npmrc`-only row is the +/// control: without it, a platform that mangled the path out of the ini file +/// would make the env row pass for the wrong reason. +#[test] +fn cache_dir_env_outranks_npmrc() { + let fx = fixture("cache-dir-precedence"); + let from_npmrc = seeded_cache(fx.project.parent().unwrap(), "npmrc-dir"); + std::fs::write( + fx.project.join(".npmrc"), + format!( + "registry=http://127.0.0.1:1/\ncache-dir={}\n", + npmrc_path(&from_npmrc) + ), + ) + .unwrap(); + + assert_eq!( + cached_names(&fx, &[]), + vec!["npmrc-dir".to_string()], + "control: `.npmrc cache-dir` alone must move the cache" + ); + assert_eq!( + cached_names( + &fx, + &[( + "npm_config_cache_dir", + fx.override_dir.display().to_string().as_str() + )] + ), + vec!["override".to_string()], + "env must outrank `.npmrc` — the inverted-precedence failure in #654" + ); +} + +/// `config get` and `config list` report the env tier, so the command agrees +/// with the install about the effective value. Two deliberate exclusions ride +/// along: a scope selector asks about a file, and an ambient variable is not +/// configuration. +#[test] +fn config_reports_env_sourced_values() { + let fx = fixture("config-env-tier"); + let dir = fx.override_dir.display().to_string(); + let env: &[(&str, &str)] = &[ + ("npm_config_cache_dir", dir.as_str()), + ("npm_config_node_linker", "hoisted"), + ]; + + let (unset, _) = run(&fx, &[], &["config", "get", "cache-dir"]); + assert_eq!( + unset.trim(), + "undefined", + "control: unset must read as undefined, or the rows below prove nothing" + ); + + let (value, _) = run(&fx, env, &["config", "get", "cache-dir"]); + assert_eq!(value.trim(), dir, "config get denied an env-set cacheDir"); + let (linker, _) = run(&fx, env, &["config", "get", "node-linker"]); + assert_eq!( + linker.trim(), + "hoisted", + "config get returned the embedder default over an env-set nodeLinker" + ); + + let (listed, _) = run(&fx, env, &["config", "list"]); + assert!( + listed + .lines() + .any(|line| line == format!("cache-dir={dir}")), + "config list omitted the env-set cacheDir:\n{listed}" + ); + + for scope in ["--local", "--global"] { + let (scoped, _) = run(&fx, env, &["config", "get", "cache-dir", scope]); + assert_eq!( + scoped.trim(), + "undefined", + "{scope} asks about a file and must not answer from the environment" + ); + } + + let (ambient, _) = run(&fx, &[("CI", "true")], &["config", "list"]); + assert!( + !ambient.lines().any(|line| line.starts_with("ci=")), + "an ambient variable became a config row:\n{ambient}" + ); +} + +/// A setting can be fed by an ambient variable OR a config-carrying one — +/// `proxy` declares `HTTP_PROXY`, `http_proxy` AND `npm_config_http_proxy`. +/// Only the config-carrying spelling is configuration, and BOTH ambient +/// spellings have to be excluded: a deny-list naming just the uppercase forms +/// hides one and reports the other, which is what an allow-list avoids. +#[test] +fn only_config_carrying_env_vars_are_configuration() { + let fx = fixture("config-env-ambient"); + + for spelling in ["HTTP_PROXY", "http_proxy"] { + let (listed, _) = run( + &fx, + &[(spelling, "http://ambient.example")], + &["config", "list"], + ); + assert!( + !listed.lines().any(|line| line.starts_with("http-proxy=")), + "{spelling} is ambient environment and must not read as configuration:\n{listed}" + ); + } + + let (listed, _) = run( + &fx, + &[("npm_config_http_proxy", "http://config.example")], + &["config", "list"], + ); + assert!( + listed + .lines() + .any(|line| line == "http-proxy=http://config.example"), + "control: the config-carrying spelling of the same setting must be \ + reported, or the exclusions above prove nothing:\n{listed}" + ); +} diff --git a/site/content/docs/install/index.mdx b/site/content/docs/install/index.mdx index b1864f464..a51513a39 100644 --- a/site/content/docs/install/index.mdx +++ b/site/content/docs/install/index.mdx @@ -618,6 +618,30 @@ Sources, highest first: Nub appends the `v1/` schema suffix to the configured directory; a leading `~` expands to the home directory and a relative path resolves against the project root. The packument caches and the shared virtual store live under `cache-dir` (`NUB_CACHE_DIR`) instead — move both when a run must stay entirely off the default locations, and point them at the same volume so the virtual store keeps hardlinking out of the CAS. +### Cache directory + +Registry metadata and the shared virtual store live in a cache at `$XDG_CACHE_HOME/nub/pm/` (default `~/.cache/nub/pm/`), separate from the content store above. Point it somewhere else — a faster volume, a CI cache mount — with one line in `.npmrc`: + +```ini title=".npmrc" +cache-dir=/mnt/fast/nub-cache +``` + +Two environment variables set the same thing, and both outrank the file: + +```bash +NUB_CACHE_DIR=/mnt/fast/nub-cache nub install +npm_config_cache_dir=/mnt/fast/nub-cache nub install +``` + +Ask for the effective value rather than guessing which source won: + +```console +$ NUB_CACHE_DIR=/mnt/fast/nub-cache nub config get cache-dir +/mnt/fast/nub-cache +``` + +Move the content store alongside it with `store-dir` when both leave the default volume. Packages materialize into the shared virtual store by hardlink out of the store, and a hardlink cannot cross filesystems — split them and every install degrades to a per-file copy, which Nub warns about. A few small caches stay at the platform default either way: the advisory database, the bootstrapped `node-gyp`, and git clones. + ### Virtual store The default `node_modules` layout is **isolated**: direct dependencies sit at the top level, transitive packages link into a per-project virtual store, and phantom dependencies fail instead of resolving by accident. Nub's virtual store is `node_modules/.store/` (pnpm uses `node_modules/.pnpm/`) — same shape, not byte-shared, so alternating tools relinks the tree. diff --git a/vendor/aube/crates/aube-settings/src/values.rs b/vendor/aube/crates/aube-settings/src/values.rs index 6c81f9693..631955234 100644 --- a/vendor/aube/crates/aube-settings/src/values.rs +++ b/vendor/aube/crates/aube-settings/src/values.rs @@ -511,6 +511,17 @@ pub fn workspace_yaml_value<'a>( } fn raw_from_env<'a>(meta: &meta::SettingMeta, env: &'a [(String, String)]) -> Option<&'a str> { + env_alias_hit(meta, env).map(|(_, raw)| raw) +} + +/// Which declared `sources.env` alias supplies `meta`'s value, and the raw +/// value it carries. The typed accessors want only the value; provenance +/// reporters ([`env_source`]) want the alias as well, so both read the answer +/// off this one walk rather than each keeping their own idea of the order. +fn env_alias_hit<'a>( + meta: &meta::SettingMeta, + env: &'a [(String, String)], +) -> Option<(&'static str, &'a str)> { for alias in meta.env_vars.iter().rev() { // Gate the tool-branded alias (`AUBE_`) on the active embedder's // `env_prefix`: standalone aube (`Some("AUBE")`) reads it as before, an @@ -523,13 +534,27 @@ fn raw_from_env<'a>(meta: &meta::SettingMeta, env: &'a [(String, String)]) -> Op } for (key, raw) in env.iter().rev() { if key == alias { - return Some(raw); + return Some((alias, raw)); } } } None } +/// The env alias currently supplying `setting`'s value, and that value. +/// +/// Walks the declared aliases in the same order and under the same brand gate +/// as the typed accessors, so a caller reporting where a value came from +/// cannot disagree with the resolver about which variable won. `None` means +/// the environment does not set this setting at all — an unknown setting name +/// answers the same way. +pub fn env_source<'a>( + setting: &str, + env: &'a [(String, String)], +) -> Option<(&'static str, &'a str)> { + env_alias_hit(meta::find(setting)?, env) +} + /// Resolve a `bool` setting from a captured environment snapshot, /// walking the declared `sources.env` aliases in reverse priority order. /// Returns `None` on unknown setting, wrong type, or unparseable value. diff --git a/vendor/aube/crates/aube-util/src/env.rs b/vendor/aube/crates/aube-util/src/env.rs index 0134a8605..a0795c2e2 100644 --- a/vendor/aube/crates/aube-util/src/env.rs +++ b/vendor/aube/crates/aube-util/src/env.rs @@ -68,6 +68,39 @@ pub fn branded_env_alias_enabled(alias: &str) -> bool { } } +/// True when `alias` is a form that CARRIES configuration — the npm-compat +/// family, its pnpm-compat sibling, or the active tool's own branded prefix — +/// rather than an ambient variable a setting merely observes. +/// +/// An allow-list, deliberately: a settings entry may declare any external +/// variable it likes, so anything unrecognized has to read as ambient. A +/// deny-list gets this wrong in a way that is easy to miss — several settings +/// declare BOTH spellings of a proxy var (`HTTP_PROXY` and `http_proxy`), so +/// naming only the uppercase forms would hide one and surface the other. +/// +/// Distinct from "may this be read": [`branded_env_alias_enabled`] answers +/// that and always honors ambient vars. This answers "is it *configuration*", +/// and the caller is config provenance reporting — `config get` / `config +/// list` surface the env tier, where a `ci=true` row would show up in every CI +/// run for a variable nobody authored as config. npm draws the line in the +/// same place: only its `npm_config_*` family becomes visible config. +pub fn is_config_env_alias(alias: &str) -> bool { + const CONFIG_PREFIXES: &[&str] = &[ + "npm_config_", + "NPM_CONFIG_", + "pnpm_config_", + "PNPM_CONFIG_", + ]; + if CONFIG_PREFIXES.iter().any(|p| alias.starts_with(p)) { + return true; + } + embedder().env_prefix.is_some_and(|prefix| { + alias + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('_')) + }) +} + /// Does `alias` have the `_` shape of a tool-branded env /// var, as opposed to a bare external var (`CI`) or neutral proxy/Node var /// (`HTTP_PROXY`, `NODE_OPTIONS`)? aube's settings table only ever emits its diff --git a/vendor/aube/crates/aube/src/commands/config/list.rs b/vendor/aube/crates/aube/src/commands/config/list.rs index b867b4ccc..07332abc9 100644 --- a/vendor/aube/crates/aube/src/commands/config/list.rs +++ b/vendor/aube/crates/aube/src/commands/config/list.rs @@ -1,8 +1,7 @@ use super::{ - ListLocation, literal_aliases, read_merged, read_project_entries, read_user_entries, - setting_default_value, setting_for_key, settings_meta, + ListLocation, literal_aliases, primary_entry_key, read_merged, read_project_entries, + read_user_entries, setting_default_value, setting_for_key, settings_meta, }; -use aube_settings::meta::SettingMeta; use clap::Args; use miette::miette; @@ -78,7 +77,7 @@ pub fn run(args: ListArgs) -> miette::Result<()> { if meta.managed_policy.is_empty() { continue; } - let primary = primary_list_key(meta); + let primary = primary_entry_key(meta); let local = seen .get(&primary) .cloned() @@ -144,14 +143,7 @@ pub fn run(args: ListArgs) -> miette::Result<()> { } pub(super) fn canonical_list_key(key: &str) -> String { - setting_for_key(key).map_or_else(|| key.to_string(), primary_list_key) -} - -fn primary_list_key(meta: &SettingMeta) -> String { - literal_aliases(meta.npmrc_keys) - .into_iter() - .next() - .unwrap_or_else(|| meta.name.to_string()) + setting_for_key(key).map_or_else(|| key.to_string(), primary_entry_key) } pub(super) fn collect_seen( diff --git a/vendor/aube/crates/aube/src/commands/config/mod.rs b/vendor/aube/crates/aube/src/commands/config/mod.rs index 92d44df6f..a519a858b 100644 --- a/vendor/aube/crates/aube/src/commands/config/mod.rs +++ b/vendor/aube/crates/aube/src/commands/config/mod.rs @@ -374,10 +374,16 @@ fn search_text_matches(haystack: &str, term: &str) -> bool { } /// Walk every config source in low-to-high precedence order so a later -/// duplicate wins. Mirrors the default file-source chain generated for +/// duplicate wins. Mirrors the default source chain generated for /// install/runtime settings in [`aube_settings::resolved`]: /// `embedderDefaults < userNpmrc < userAubeConfig < projectNpmrc < -/// projectAubeConfig < globalConfigYaml < workspaceYaml`. +/// projectAubeConfig < globalConfigYaml < workspaceYaml < env`. +/// +/// The env tier is last because it is highest: `resolved` puts `env` above +/// every file, and every reader here takes the LAST match. Omitting it made +/// `config get`/`config list` deny values the install was already acting on — +/// an env-set `cache-dir` moved the cache while `config get cache-dir` printed +/// `undefined` (#654). npm and pnpm both surface env-set values. pub(super) fn read_merged(cwd: &Path) -> miette::Result> { let files = crate::commands::FileSources::load(cwd); let workspace_yaml = read_workspace_yaml_raw(cwd); @@ -389,9 +395,64 @@ pub(super) fn read_merged(cwd: &Path) -> miette::Result> { out.extend(files.project_aube_config); out.extend(read_yaml_flat(&files.global_config_yaml)); out.extend(read_yaml_flat(&workspace_yaml)); + out.extend(read_env_entries()); Ok(out) } +/// Settings the environment currently supplies, rendered as `.npmrc`-style +/// entries so `get`/`list`/`tui` read them exactly the way they read a file +/// entry. Scoped reads (`--global`/`--local`) get none of this: env is not a +/// file scope, and a scope selector asks about a file. +/// +/// Only *config-carrying* variables are surfaced. A setting may also be fed by +/// a bare ambient variable — `CI`, `HTTP_PROXY`, `NODE_OPTIONS` — and those are +/// the environment a run happens in rather than configuration anyone authored; +/// see [`aube_util::env::is_config_env_alias`]. When an ambient variable is +/// the one supplying the value, the setting is omitted rather than reported +/// under a lower-priority alias: the row would then disagree with the value +/// the resolver hands the install. +fn read_env_entries() -> Vec<(String, String)> { + let env = aube_settings::values::process_env(); + let mut out = Vec::new(); + for meta in settings_meta::all() { + let Some((alias, value)) = aube_settings::values::env_source(meta.name, env) else { + continue; + }; + if !aube_util::env::is_config_env_alias(alias) { + continue; + } + out.push((primary_entry_key(meta), value.to_string())); + } + // `cacheDir` is the one setting whose env surface is wider than its + // `sources.env` list: `resolved_cache_dir` also honors the host's + // first-class `config_env("CACHE_DIR")` knob (`NUB_CACHE_DIR` under nub), + // which is deliberately absent from the shared settings table so an + // embedder gains its brand for exactly this knob and not for aube's whole + // branded-alias surface. Reporting it here keeps `config get cache-dir` + // honest about the value the install will use. It is a single-member + // special case on purpose — the other two config-env knobs + // (`CONCURRENCY`, `PRIMER_TTL`) are not settings-table settings, so a + // general mapping would be machinery for a set of one. + if let Some(raw) = aube_util::env::config_env("CACHE_DIR") + && let Some(raw) = raw.to_str() + && !raw.is_empty() + && let Some(meta) = settings_meta::find("cacheDir") + { + out.push((primary_entry_key(meta), raw.to_string())); + } + out +} + +/// The `.npmrc` alias an entry for `meta` should be keyed by — its first +/// literal alias, falling back to the canonical name for a setting with no +/// `.npmrc` surface (which is what [`resolve_aliases`] looks for anyway). +pub(super) fn primary_entry_key(meta: &settings_meta::SettingMeta) -> String { + literal_aliases(meta.npmrc_keys) + .into_iter() + .next() + .unwrap_or_else(|| meta.name.to_string()) +} + pub(super) fn read_user_entries(cwd: &Path) -> miette::Result> { let mut out = Vec::new(); out.extend(aube_registry::config::load_user_npmrc_entries(cwd)); diff --git a/vendor/aube/crates/aube/src/commands/settings_context.rs b/vendor/aube/crates/aube/src/commands/settings_context.rs index fa2012db7..84e698700 100644 --- a/vendor/aube/crates/aube/src/commands/settings_context.rs +++ b/vendor/aube/crates/aube/src/commands/settings_context.rs @@ -510,8 +510,8 @@ pub(crate) fn resolve_fetch_policy(cwd: &std::path::Path) -> aube_registry::conf aube_registry::config::FetchPolicy::from_ctx(&ctx) } -/// Resolve the `cacheDir` setting for `cwd`. When set (via -/// `AUBE_CACHE_DIR` / `npm_config_cache_dir`, or `cache-dir` in +/// Resolve the `cacheDir` setting for `cwd`. When set (via the host's own +/// `_CACHE_DIR` / `npm_config_cache_dir`, or `cache-dir` in /// `.npmrc` / `aube-config.toml`), expands it and returns that path. /// Otherwise falls back to the platform cache dir: /// `$XDG_CACHE_HOME/aube` when `XDG_CACHE_HOME` is set, @@ -530,6 +530,26 @@ pub(crate) fn resolve_fetch_policy(cwd: &std::path::Path) -> aube_registry::conf pub(crate) fn resolved_cache_dir(cwd: &std::path::Path) -> std::path::PathBuf { let platform_default = || aube_store::dirs::cache_dir().unwrap_or_else(|| std::env::temp_dir().join("aube")); + // The host's first-class cache knob is read AHEAD of the settings chain. + // Byte-identical for standalone aube: `config_env` composes the same + // `AUBE_CACHE_DIR` the settings table declares, and the table already + // walks its aliases in reverse, so the branded form already outranked the + // `npm_config_*` / `pnpm_config_*` forms — this preserves that order + // rather than introducing one. Under an embedder with its own + // `config_env_prefix` it is the ONLY spelling that reaches the cache: + // aube's branded alias is gated off by `env_prefix = None`, and the host's + // form (`NUB_CACHE_DIR`) is not in the table, so without this the resolver + // primer — which reads `config_env` directly — was the knob's sole + // consumer and the cache it names silently ignored it (#654). + // `cacheDir` carries no `managedPolicy`, so returning early here skips no + // hardening pass; a setting that grows one must not be short-circuited + // this way. + if let Some(raw) = aube_util::env::config_env("CACHE_DIR") + && let Some(raw) = raw.to_str() + && !raw.is_empty() + { + return expand_setting_path(raw, cwd).unwrap_or_else(platform_default); + } with_settings_ctx(cwd, |ctx| match aube_settings::resolved::cache_dir(ctx) { Some(raw) => expand_setting_path(&raw, cwd).unwrap_or_else(platform_default), None => platform_default(), diff --git a/wiki/agents.md b/wiki/agents.md index 670887bea..790085056 100644 --- a/wiki/agents.md +++ b/wiki/agents.md @@ -393,7 +393,7 @@ General structure rules live in [`PROSE.md`](../PROSE.md). The homepage is the c - **Marketing asides use the styled treatment** (`.blog-prose blockquote`); default blockquote styling is unacceptable on the marketing site. - **Version-gated claims must trace to code, not the wiki.** Every claim about a Node-version-gated feature maps to a named constant or function in `crates/nub-core/src/node/flags.rs` or `spawn.rs` — no symbol, no claim. The wiki's `status: v0.1` tracks intent, not implementation. - **"Every supported Node version" is a banned phrase** unless the feature is `typeof`-feature-detected in the shared preload. Unflagged features are tier- and version-banded; state the floor per row. -- **Brand-boundary copy.** "The brand stops at the binary boundary" is wiki-internal shorthand, never user-facing. Absolute "the name nub never appears anywhere" claims are false (`~/.local/share/nub/store`, `~/.cache/nub`, internal `NUB_*` vars, error codes) — and do **not** claim "no `NUB_*` environment variables": three sanctioned user-facing PM knobs exist and are publicly documented (`NUB_CACHE_DIR`, `NUB_CONCURRENCY`, `NUB_PRIMER_TTL`). The correct public promise is narrower: no nub-specific imported/callable APIs, no `nub:*` namespace, no `@nub/*` scope, no `"nub"` config field — zero lock-in. +- **Brand-boundary copy.** "The brand stops at the binary boundary" is wiki-internal shorthand, never user-facing. Absolute "the name nub never appears anywhere" claims are false (`~/.local/share/nub/store`, `~/.cache/nub`, internal `NUB_*` vars, error codes) — and do **not** claim "no `NUB_*` environment variables": three sanctioned user-facing PM knobs exist — `NUB_CACHE_DIR` (documented in the install docs' cache-directory section), plus `NUB_CONCURRENCY` and `NUB_PRIMER_TTL`, which are live but documented nowhere on the site, so do not cite those two as published surface either. The correct public promise is narrower: no nub-specific imported/callable APIs, no `nub:*` namespace, no `@nub/*` scope, no `"nub"` config field — zero lock-in. - **Protective-refusal demos use real nub output.** Capture from `target/release/nub` or ground the exact string in `crates/nub-cli/src/pm_engine/` — never invent. ## Markdown navigation — line-range TOC for large files From b0d70fcb00abef4cfee18f2d884dddf97c87f7bc Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:58:48 -0700 Subject: [PATCH 2/5] pm: make the cacheDir env test hermetic, and stop the config TUI naming .npmrc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #740. `pm_env_matrix` pinned the cache and data dirs but not `HOME`, so every row read the developer's real user `.npmrc`. Reproduced: a `cache-dir` line there falsifies three of the file's own controls — `cache list` names the leaked directory instead of nothing, and both the plain and `--global` reads of `cache-dir` report it instead of `undefined`. CI has a clean `HOME`, so this would have gone red on one machine and nowhere else. Pin `HOME`, `USERPROFILE` and `XDG_CONFIG_HOME` to fixture paths, and scrub both `userconfig` spellings, which relocate the user `.npmrc` even once `HOME` is pinned. Add the row for both cache-dir spellings set at once. That combination is where the precedence rule is encoded twice — the early return in `resolved_cache_dir` and the push order of the config env tier — so a drift between them makes `config get` report a cache the install is not using, which is the invariant the change exists to restore. The config TUI rendered the merged read under the label `Effective .npmrc value:`. That read spans every source, and since the env tier joined it the value shown is routinely one no file holds, so the label names the wrong source; it is now `Effective value:`. The edit path is unaffected — it reads project scope. A `debug_assert` turns the note about `cacheDir` carrying no `managedPolicy` into a tripwire, since the enforcement it would skip runs at the end of the generated accessor. Docs: the per-file-copy warning applies only while the shared virtual store is enabled, which is off in CI — one of the two motivating examples on that page. The `nub link -g` registry is a fourth cache that stays at the platform default. Lead the environment block with the neutral `npm_config_cache_dir`, matching the preference for neutral spellings on user-facing surfaces. --- crates/nub-cli/tests/pm_env_matrix.rs | 54 +++++++++++++++++-- site/content/docs/install/index.mdx | 10 ++-- .../crates/aube/src/commands/config/tui.rs | 5 +- .../aube/src/commands/settings_context.rs | 10 +++- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/crates/nub-cli/tests/pm_env_matrix.rs b/crates/nub-cli/tests/pm_env_matrix.rs index ea16eec87..211748a35 100644 --- a/crates/nub-cli/tests/pm_env_matrix.rs +++ b/crates/nub-cli/tests/pm_env_matrix.rs @@ -29,7 +29,10 @@ fn nub_binary() -> PathBuf { } /// Env vars that would silently decide these rows if the developer running -/// the suite happens to export one. Scrubbed from every child. +/// the suite happens to export one. Scrubbed from every child. The two +/// `userconfig` spellings matter as much as the rest: they relocate the user +/// `.npmrc` even once `HOME` is pinned, which would put the host's config back +/// in front of the fixture's. const SCRUBBED: &[&str] = &[ "NUB_CACHE_DIR", "AUBE_CACHE_DIR", @@ -41,6 +44,8 @@ const SCRUBBED: &[&str] = &[ "NPM_CONFIG_NODE_LINKER", "npm_config_http_proxy", "NPM_CONFIG_HTTP_PROXY", + "npm_config_userconfig", + "NPM_CONFIG_USERCONFIG", "http_proxy", "HTTP_PROXY", "PROXY", @@ -48,10 +53,12 @@ const SCRUBBED: &[&str] = &[ "CI", ]; -/// A project dir plus an override cache dir seeded with one packument file. -/// The dead-port registry makes any accidental network use fail loudly. +/// A project dir, an empty home, and an override cache dir seeded with one +/// packument file. The dead-port registry makes any accidental network use +/// fail loudly. struct Fixture { project: PathBuf, + home: PathBuf, override_dir: PathBuf, } @@ -72,8 +79,11 @@ fn fixture(tag: &str) -> Fixture { ) .unwrap(); std::fs::write(project.join(".npmrc"), "registry=http://127.0.0.1:1/\n").unwrap(); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); Fixture { project, + home, override_dir: seeded_cache(&root, "override"), } } @@ -94,11 +104,19 @@ fn npmrc_path(path: &Path) -> String { path.display().to_string().replace('\\', "/") } +/// Every config root is pinned to the fixture, not just the cache and data +/// dirs. The user `.npmrc` is resolved from `HOME` (`USERPROFILE` on Windows), +/// so leaving those on the host puts the developer's own `~/.npmrc` in the +/// chain — and a `cache-dir` line there falsifies three of the controls below, +/// which would go red on that one machine and nowhere else. fn run(fx: &Fixture, env: &[(&str, &str)], args: &[&str]) -> (String, i32) { let mut cmd = Command::new(nub_binary()); cmd.args(args) .current_dir(&fx.project) .env("NUB_SELF_SHIM", "0") + .env("HOME", &fx.home) + .env("USERPROFILE", &fx.home) + .env("XDG_CONFIG_HOME", fx.home.join("xdg-config")) .env("XDG_DATA_HOME", fx.project.join("xdg-data")) .env("XDG_CACHE_HOME", fx.project.join("xdg-cache")); for key in SCRUBBED { @@ -199,6 +217,36 @@ fn cache_dir_env_outranks_npmrc() { ); } +/// Both env spellings set at once. This is where the precedence rule is +/// encoded twice — the early return in `resolved_cache_dir` and the push order +/// of the config env tier — so the two have to name the same directory. If +/// they drift, `config get` reports a cache the install is not using, which is +/// the failure the whole change exists to remove. +#[test] +fn both_cache_dir_spellings_agree_across_surfaces() { + let fx = fixture("cache-dir-both"); + let branded = seeded_cache(fx.project.parent().unwrap(), "branded"); + let branded_path = branded.display().to_string(); + let neutral_path = fx.override_dir.display().to_string(); + let env: &[(&str, &str)] = &[ + ("NUB_CACHE_DIR", branded_path.as_str()), + ("npm_config_cache_dir", neutral_path.as_str()), + ]; + + assert_eq!( + cached_names(&fx, env), + vec!["branded".to_string()], + "the host's own knob must outrank the neutral form, as the branded \ + alias already does inside the settings chain" + ); + let (reported, _) = run(&fx, env, &["config", "get", "cache-dir"]); + assert_eq!( + reported.trim(), + branded_path, + "config get named a different directory than the engine resolved" + ); +} + /// `config get` and `config list` report the env tier, so the command agrees /// with the install about the effective value. Two deliberate exclusions ride /// along: a scope selector asks about a file, and an ambient variable is not diff --git a/site/content/docs/install/index.mdx b/site/content/docs/install/index.mdx index a51513a39..579805c5e 100644 --- a/site/content/docs/install/index.mdx +++ b/site/content/docs/install/index.mdx @@ -629,18 +629,20 @@ cache-dir=/mnt/fast/nub-cache Two environment variables set the same thing, and both outrank the file: ```bash -NUB_CACHE_DIR=/mnt/fast/nub-cache nub install -npm_config_cache_dir=/mnt/fast/nub-cache nub install +npm_config_cache_dir=/mnt/fast/nub-cache nub install # neutral — npm and pnpm read it too +NUB_CACHE_DIR=/mnt/fast/nub-cache nub install # Nub's own spelling, wins over the above ``` Ask for the effective value rather than guessing which source won: ```console -$ NUB_CACHE_DIR=/mnt/fast/nub-cache nub config get cache-dir +$ npm_config_cache_dir=/mnt/fast/nub-cache nub config get cache-dir /mnt/fast/nub-cache ``` -Move the content store alongside it with `store-dir` when both leave the default volume. Packages materialize into the shared virtual store by hardlink out of the store, and a hardlink cannot cross filesystems — split them and every install degrades to a per-file copy, which Nub warns about. A few small caches stay at the platform default either way: the advisory database, the bootstrapped `node-gyp`, and git clones. +Move the content store alongside it with `store-dir` when both leave the default volume. While the shared virtual store is enabled, packages materialize into it by hardlink out of the content store, and a hardlink cannot cross filesystems — split the two and every install degrades to a per-file copy, which Nub warns about. That pairing does not apply in CI, where the shared store is off by default. + +Some caches stay at the platform default whatever this is set to: the advisory database, the bootstrapped `node-gyp`, git clones, and the registry behind `nub link -g`. ### Virtual store diff --git a/vendor/aube/crates/aube/src/commands/config/tui.rs b/vendor/aube/crates/aube/src/commands/config/tui.rs index 4f8143899..4abc37d7d 100644 --- a/vendor/aube/crates/aube/src/commands/config/tui.rs +++ b/vendor/aube/crates/aube/src/commands/config/tui.rs @@ -465,7 +465,10 @@ fn setting_detail_lines(meta: &settings_meta::SettingMeta) -> Vec> )), Line::from(format!("Type: {}", meta.type_)), Line::from(format!("Default: {}", meta.default)), - Line::from(format!("Effective .npmrc value: {npmrc_effective}")), + // Not "`.npmrc` value": this comes from the MERGED read, which spans + // every config source, and since the env tier joined that read the + // value shown is routinely one no file holds. + Line::from(format!("Effective value: {npmrc_effective}")), Line::from(format!( "Editing file: {}", target_file_label(target.as_ref()) diff --git a/vendor/aube/crates/aube/src/commands/settings_context.rs b/vendor/aube/crates/aube/src/commands/settings_context.rs index 84e698700..33376eaee 100644 --- a/vendor/aube/crates/aube/src/commands/settings_context.rs +++ b/vendor/aube/crates/aube/src/commands/settings_context.rs @@ -542,8 +542,14 @@ pub(crate) fn resolved_cache_dir(cwd: &std::path::Path) -> std::path::PathBuf { // primer — which reads `config_env` directly — was the knob's sole // consumer and the cache it names silently ignored it (#654). // `cacheDir` carries no `managedPolicy`, so returning early here skips no - // hardening pass; a setting that grows one must not be short-circuited - // this way. + // hardening pass. A setting that grows one must not be short-circuited + // this way, and the assert is what turns that from a note into a tripwire: + // the enforcement lives in `apply_managed_string` at the END of the + // generated accessor, which the early return never reaches. + debug_assert!( + aube_settings::meta::find("cacheDir").is_none_or(|m| m.managed_policy.is_empty()), + "cacheDir grew a managedPolicy; the early return below skips it" + ); if let Some(raw) = aube_util::env::config_env("CACHE_DIR") && let Some(raw) = raw.to_str() && !raw.is_empty() From b08d0fb19147393ee3f745539fb2dc88e5a9563d Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:21:39 -0700 Subject: [PATCH 3/5] pm: scrub the npmrc path-redirect env family, and align the cacheDir cache list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #740, all three accuracy points. `pm_env_matrix`'s new doc comment claimed every config root was pinned, but the global and builtin `.npmrc` paths derive from `NPM_CONFIG_PREFIX` / `npm_config_prefix` / `PREFIX` (`resolve_global_npmrc_paths`), with `globalconfig` and `builtin_config` as direct overrides in the same family. None were scrubbed, so running the suite from an npm script — or in any shell exporting `PREFIX` — put `/etc/npmrc` back in the chain. A `cache-dir` line there is unlikely, so this was the claim being inexact rather than a live falsifier; scrubbing the family makes it exact. The `cacheDir` setting's own docs listed three caches that stay at the platform default while the install docs now list four. That copy is what `config explain` and the TUI render, so the two surfaces disagreed; the global-links registry joins the list. Rename the TUI's `npmrc_effective` to `effective`, which is what the relabelled line and the comment above it now describe. --- crates/nub-cli/tests/pm_env_matrix.rs | 19 +++++++++++++++---- .../aube/crates/aube-settings/settings.toml | 5 +++-- .../crates/aube/src/commands/config/tui.rs | 4 ++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/nub-cli/tests/pm_env_matrix.rs b/crates/nub-cli/tests/pm_env_matrix.rs index 211748a35..bf6987b40 100644 --- a/crates/nub-cli/tests/pm_env_matrix.rs +++ b/crates/nub-cli/tests/pm_env_matrix.rs @@ -29,10 +29,14 @@ fn nub_binary() -> PathBuf { } /// Env vars that would silently decide these rows if the developer running -/// the suite happens to export one. Scrubbed from every child. The two -/// `userconfig` spellings matter as much as the rest: they relocate the user -/// `.npmrc` even once `HOME` is pinned, which would put the host's config back -/// in front of the fixture's. +/// the suite happens to export one. Scrubbed from every child. +/// +/// The path-redirecting ones matter as much as the value-carrying ones, +/// because they put a host file back in the chain after `HOME` is pinned: +/// `userconfig` relocates the user `.npmrc`, and the `prefix` / `globalconfig` +/// / `builtin_config` family is where the global and builtin npmrc paths come +/// from (`resolve_global_npmrc_paths`). Running the suite from an npm script, +/// or in any shell exporting `PREFIX`, would otherwise reach `/etc/npmrc`. const SCRUBBED: &[&str] = &[ "NUB_CACHE_DIR", "AUBE_CACHE_DIR", @@ -46,6 +50,13 @@ const SCRUBBED: &[&str] = &[ "NPM_CONFIG_HTTP_PROXY", "npm_config_userconfig", "NPM_CONFIG_USERCONFIG", + "npm_config_prefix", + "NPM_CONFIG_PREFIX", + "PREFIX", + "npm_config_globalconfig", + "NPM_CONFIG_GLOBALCONFIG", + "npm_config_builtin_config", + "NPM_CONFIG_BUILTIN_CONFIG", "http_proxy", "HTTP_PROXY", "PROXY", diff --git a/vendor/aube/crates/aube-settings/settings.toml b/vendor/aube/crates/aube-settings/settings.toml index 81a496512..2afdb17be 100644 --- a/vendor/aube/crates/aube-settings/settings.toml +++ b/vendor/aube/crates/aube-settings/settings.toml @@ -2872,8 +2872,9 @@ packument metadata (`/packuments-v1/`, `/packuments-full-v1/`) live under this directory. The content-addressable store is *not* here — it is `storeDir`, which defaults under `$XDG_DATA_HOME` instead. A few smaller caches (the OSV -advisory mirror, the bootstrapped `node-gyp`, git clones) still follow -the platform cache dir regardless of this setting. +advisory mirror, the bootstrapped `node-gyp`, git clones, and the +global-links registry behind `link -g`) still follow the platform cache +dir regardless of this setting. Set this alongside `storeDir` when the store lives on a non-default volume. Entries in the global virtual store are hardlinked out of the diff --git a/vendor/aube/crates/aube/src/commands/config/tui.rs b/vendor/aube/crates/aube/src/commands/config/tui.rs index 4abc37d7d..6e894a646 100644 --- a/vendor/aube/crates/aube/src/commands/config/tui.rs +++ b/vendor/aube/crates/aube/src/commands/config/tui.rs @@ -451,7 +451,7 @@ fn setting_detail_lines(meta: &settings_meta::SettingMeta) -> Vec> .and_then(|target| target.value().ok().flatten()) .unwrap_or_else(|| "undefined".to_string()); let npmrc_key = literal_aliases(meta.npmrc_keys).into_iter().next(); - let npmrc_effective = npmrc_key + let effective = npmrc_key .as_deref() .and_then(|key| config_value(key, ListLocation::Merged).ok().flatten()) .unwrap_or_else(|| "undefined".to_string()); @@ -468,7 +468,7 @@ fn setting_detail_lines(meta: &settings_meta::SettingMeta) -> Vec> // Not "`.npmrc` value": this comes from the MERGED read, which spans // every config source, and since the env tier joined that read the // value shown is routinely one no file holds. - Line::from(format!("Effective value: {npmrc_effective}")), + Line::from(format!("Effective value: {effective}")), Line::from(format!( "Editing file: {}", target_file_label(target.as_ref()) From c195627a9211235a90787f573567d57a74bd5b86 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:20:40 -0700 Subject: [PATCH 4/5] docs(install): point the cache-volume note at the store section above it Rebasing onto #644 put "Relocating the store" directly above the new cache directory section, and it already gives the same move-both-together advice from the store side. Stop restating it and cross-reference instead, keeping only what that section does not say: the pairing matters solely while the shared virtual store is on, which is off under CI. --- site/content/docs/install/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/content/docs/install/index.mdx b/site/content/docs/install/index.mdx index 579805c5e..cd08e141a 100644 --- a/site/content/docs/install/index.mdx +++ b/site/content/docs/install/index.mdx @@ -640,7 +640,7 @@ $ npm_config_cache_dir=/mnt/fast/nub-cache nub config get cache-dir /mnt/fast/nub-cache ``` -Move the content store alongside it with `store-dir` when both leave the default volume. While the shared virtual store is enabled, packages materialize into it by hardlink out of the content store, and a hardlink cannot cross filesystems — split the two and every install degrades to a per-file copy, which Nub warns about. That pairing does not apply in CI, where the shared store is off by default. +Sharing a volume with `store-dir`, as the section above recommends, matters only while the shared virtual store is enabled: packages materialize into it by hardlink out of the content store, and a hardlink cannot cross filesystems, so a split degrades every install to a per-file copy — which Nub warns about. In CI the shared store is off by default, so the two can sit on different volumes there at no cost. Some caches stay at the platform default whatever this is set to: the advisory database, the bootstrapped `node-gyp`, git clones, and the registry behind `nub link -g`. From 09405c9fa4d5ba27fa0a7e633c8b31a9ebb0f583 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:37:11 -0700 Subject: [PATCH 5/5] pm: name the global-links registry in the third copy of the cache list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3 on #740. The platform-default cache list exists in three places, and the prior commit updated only two of them — `settings.toml`'s `cacheDir` docs and the install docs page — so this comment deferred to "the setting itself" while enumerating one fewer entry than the setting does. Nothing checks that the three agree, which is now said out loud where the next person adding a consumer will read it. --- crates/nub-cli/src/pm_engine/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/nub-cli/src/pm_engine/mod.rs b/crates/nub-cli/src/pm_engine/mod.rs index fc9e4dbb7..611554989 100644 --- a/crates/nub-cli/src/pm_engine/mod.rs +++ b/crates/nub-cli/src/pm_engine/mod.rs @@ -2467,7 +2467,10 @@ fn read_file_head(path: &Path, max_bytes: usize) -> std::io::Result { /// `pm_env_matrix::cache_dir_env_moves_the_pm_cache`.) The handful of /// non-settings consumers that deliberately stay on the platform default /// regardless — the OSV advisory mirror, the bootstrapped node-gyp, git -/// clones — are documented as such on the setting itself. +/// clones, and the global-links registry behind `link -g` — are documented +/// as such on the setting itself. This is the third copy of that list, after +/// `settings.toml`'s `cacheDir` docs and the install docs page; nothing +/// checks that they agree, so a consumer added to one belongs in all three. /// - `defaultTrust=true` — the gated default-trust floor (curated list ∧ /// registry-resolved ∧ OSV MAL-* gate active ∧ past the cooling window) /// is ON under nub in both modes; upstream aube keeps it off. Precedence