From 755dff8c5de3034358e20e5a0a56e4702653720e Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 31 Jul 2026 16:06:42 -0700 Subject: [PATCH] pm: add per-invocation minimumReleaseAge CLI flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing the supply-chain age gate previously required editing .npmrc or setting an env var — a global, persistent change to get past a single command. `nubx ` hit this routinely with no one-shot way through. Adds two flags, on every surface that resolves from the registry (install/ci, the engine verbs via EngineGlobals, and nubx): --minimum-release-age --minimum-release-age-exclude (repeatable) Each mirrors both surfaces the setting already has. The spellings are pnpm 11.9.0's. The VALUES are what the matching nub.jsonc field accepts: `--minimum-release-age` takes the same `` grammar as `install.minimumReleaseAge` (s|m|h|d|w) by calling the same parser, so the file and the flag cannot drift, and `--minimum-release-age-exclude` takes the entries of `install.minimumReleaseAgeExclude`. A bare number on the flag means MINUTES, which nub.jsonc deliberately rejects (npm counts days, pnpm counts minutes, so an unqualified number in a config file is a trap). On the CLI that ambiguity is already settled by the tool nub mirrors. Sub-minute durations round UP, so tightening the gate can never silently disable it — the same rule, and reason, as the bunfig seconds->minutes path. No strictness flag, and no `install.minimumReleaseAgeStrict` in nub.jsonc. The gate is enforced, and `--minimum-release-age=0` is the opt-out: turn the window off rather than keep one and quietly install versions that fail it. pnpm's `minimumReleaseAgeStrict` defaults to false, leaving its 24h window advisory unless separately opted into; nub does not reproduce that. Verified 0 disables completely — including alongside `minimumReleaseAgeStrict=true` and `paranoid=true`, neither of which can resurrect a zero window. Three defects in the age-gate help fixed alongside: - `nub add` emitted its refusal with no help text at all, while the install path had full remedies. - The exclude remedy named `task.name`, the user-facing alias, but the exempt matcher binds `task.registry_name()`. For `"foo": "npm:real-pkg@^1"` nub printed `--minimum-release-age-exclude=foo`, which clap and the config parser both accept and the matcher then silently ignores. Both detail structs now carry the registry identity and the remedies print it. (`add` already used the registry name — there the alias lives in `spec.alias` — so it is unchanged.) - Every remedy pointed at turning the gate off, though the flag takes a duration. Both help paths now lead with shortening and treat `0` as the limit case. Plus the arity hazard already documented for `--loglevel`: `nub install --minimum-release-age 0` read `0` as a package and misrouted to `add`. Both value flags are registered in VALUE_FLAGS, the nubx pre-positional scanner, and the dlx scanner's pinned list (added by #587), with a regression test confirmed to fail without the fix. --- crates/nub-cli/src/cli.rs | 123 +++++++---- .../nub-cli/src/pm_engine/install_family.rs | 51 ++++- .../nub-cli/src/pm_engine/min_release_age.rs | 201 ++++++++++++++++++ crates/nub-cli/src/pm_engine/mod.rs | 1 + crates/nub-cli/src/project_config.rs | 9 +- site/content/docs/install/index.mdx | 21 +- vendor/aube/crates/aube-resolver/src/error.rs | 51 ++++- vendor/aube/crates/aube-resolver/src/tests.rs | 84 +++++++- .../crates/aube/src/commands/add/manifest.rs | 19 +- 9 files changed, 506 insertions(+), 54 deletions(-) diff --git a/crates/nub-cli/src/cli.rs b/crates/nub-cli/src/cli.rs index da1e8ea87..34e95abbf 100644 --- a/crates/nub-cli/src/cli.rs +++ b/crates/nub-cli/src/cli.rs @@ -938,6 +938,12 @@ pub enum Command { #[arg(long = "no-check")] no_check: bool, + /// Per-invocation `minimumReleaseAge` overrides for the fetch path. + /// `nubx ` is the common way to hit the age gate, + /// so the escape hatch belongs on this surface. + #[command(flatten)] + age_gate: crate::pm_engine::AgeGateFlags, + /// Remaining arguments forwarded to the binary. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -1108,6 +1114,9 @@ pub enum Command { #[command(flatten)] output: crate::pm_engine::OutputFlags, + + #[command(flatten)] + age_gate: crate::pm_engine::AgeGateFlags, }, /// Clean install for CI: delete node_modules, install strictly from the @@ -1152,6 +1161,9 @@ pub enum Command { #[command(flatten)] output: crate::pm_engine::OutputFlags, + + #[command(flatten)] + age_gate: crate::pm_engine::AgeGateFlags, }, } @@ -1379,6 +1391,10 @@ fn install_to_add_args(rest: &[String]) -> Option> { // them here prevents the space-separated value from looking like a pkg. "--loglevel", "--reporter", + // Same shape: `nub install --minimum-release-age 0` would otherwise + // read `0` as a package and route the whole command to `add`. + "--minimum-release-age", + "--minimum-release-age-exclude", ]; let mut i = 0; while i < body.len() { @@ -2108,6 +2124,10 @@ fn value_consuming_flags(subcommand: &str) -> &'static [&'static str] { // (repeatable, takes the package spec as a following token). They must be // listed so `nubx -p left-pad cowsay` binds `left-pad` to the package, not // the bin positional. + // The age-gate value flags take a following token, so `nubx + // --minimum-release-age 1d cowsay` must bind `1d` to the flag rather + // than read it as the bin. Both age-gate flags are value-taking; there + // is no boolean sibling, since nub ships no strictness flag. "nubx" => &[ "--filter", "-F", @@ -2116,6 +2136,8 @@ fn value_consuming_flags(subcommand: &str) -> &'static [&'static str] { "--package", "-p", "--cwd", + "--minimum-release-age", + "--minimum-release-age-exclude", ], "watch" => &["--cwd"], _ => &[], @@ -2491,12 +2513,17 @@ fn dispatch_subcommand(rest: Vec) -> Result { yes, ignore_existing, no_check, + age_gate, mut args, }) => { args.extend(suffix); if no_check { crate::verify_deps::disable(); } + // Only the DLX fallback below resolves from the registry, but the + // bag is inert on the local-bin path, so publish unconditionally + // rather than duplicating the call into each branch. + age_gate.apply(); filter.extend(workspace); let recursive = recursive || parallel || include_workspace_root; let workspace_run = recursive || !filter.is_empty() || parallel; @@ -2610,30 +2637,34 @@ fn dispatch_subcommand(rest: Vec) -> Result { fail_if_no_match, include_workspace_root, output, - }) => crate::pm_engine::run_install(crate::pm_engine::InstallFlags { - frozen_lockfile, - no_frozen_lockfile, - prefer_frozen_lockfile, - prod, - dev, - ignore_scripts, - no_optional, - offline, - prefer_offline, - lockfile_only, - force, - node_linker, - registry, - dir, - filter: crate::pm_engine::WorkspaceFilterFlags { - filter, - filter_prod, - recursive, - fail_if_no_match, - include_workspace_root, - }, - output, - }), + age_gate, + }) => { + age_gate.apply(); + crate::pm_engine::run_install(crate::pm_engine::InstallFlags { + frozen_lockfile, + no_frozen_lockfile, + prefer_frozen_lockfile, + prod, + dev, + ignore_scripts, + no_optional, + offline, + prefer_offline, + lockfile_only, + force, + node_linker, + registry, + dir, + filter: crate::pm_engine::WorkspaceFilterFlags { + filter, + filter_prod, + recursive, + fail_if_no_match, + include_workspace_root, + }, + output, + }) + } Some(Command::Ci { ignore_scripts, no_optional, @@ -2645,20 +2676,24 @@ fn dispatch_subcommand(rest: Vec) -> Result { fail_if_no_match, include_workspace_root, output, - }) => crate::pm_engine::run_ci(crate::pm_engine::CiFlags { - ignore_scripts, - no_optional, - registry, - dir, - filter: crate::pm_engine::WorkspaceFilterFlags { - filter, - filter_prod, - recursive, - fail_if_no_match, - include_workspace_root, - }, - output, - }), + age_gate, + }) => { + age_gate.apply(); + crate::pm_engine::run_ci(crate::pm_engine::CiFlags { + ignore_scripts, + no_optional, + registry, + dir, + filter: crate::pm_engine::WorkspaceFilterFlags { + filter, + filter_prod, + recursive, + fail_if_no_match, + include_workspace_root, + }, + output, + }) + } // `node` is intercepted at the top of `dispatch_subcommand` (manual // sub-verb match in `run_node`) and never reaches clap here. Some(Command::Node { .. }) => unreachable!("`node` is handled before clap dispatch"), @@ -10185,6 +10220,18 @@ mod tests { None, "nub install --loglevel=silent stays on the native install path (equals form)" ); + // `--minimum-release-age` has the same shape: its MINUTES value in the + // space form would read as a package spec and misroute the install. + assert_eq!( + install_to_add_args(&args(&["install", "--minimum-release-age", "0"])), + None, + "nub install --minimum-release-age 0 stays on the native install path" + ); + assert_eq!( + install_to_add_args(&args(&["install", "--minimum-release-age", "0", "react"])), + Some(args(&["add", "--minimum-release-age", "0", "react"])), + "--minimum-release-age 0 with a package routes to add, minutes not mis-forwarded" + ); // Output-control flags combined with a real package still route to add, // with the flag consumed as a flag (value NOT forwarded as a package). assert_eq!( diff --git a/crates/nub-cli/src/pm_engine/install_family.rs b/crates/nub-cli/src/pm_engine/install_family.rs index 13a326197..1c4571c3a 100644 --- a/crates/nub-cli/src/pm_engine/install_family.rs +++ b/crates/nub-cli/src/pm_engine/install_family.rs @@ -188,9 +188,19 @@ pub(crate) fn run_verb( // ───────────────────────── parse plumbing ────────────────────────── -// The subset of aube's *global* clap flags nub honors on engine verbs, -// parsed at the verb position (nub has no pre-verb engine flag surface). -// Spellings mirror `vendor/aube/crates/aube/src/lib.rs::Cli` exactly. +// What nub augments onto every install-family verb, parsed at the verb +// position (nub has no pre-verb engine flag surface). TWO groups, and the +// distinction matters when adding a field: +// +// 1. The subset of aube's *global* clap flags nub honors. These mirror +// `vendor/aube/crates/aube/src/lib.rs::Cli` exactly — `--dir`, the +// workspace selectors, and the output flags below. +// 2. NUB-OWN flags with no aube `Cli` counterpart: the `age_gate` group. +// aube exposes `minimumReleaseAge` only through config and its generic +// `--config.` extractor (which is dead under nub, living in aube's +// unused `cli_main`), so these spellings come from pnpm's CLI type map +// rather than from aube. +// // Deliberately absent: `--workspace-root` (aube chdirs to the workspace // root pre-dispatch; the helper is crate-private, and half-honoring the // flag as filter-only would silently run against the wrong directory — @@ -224,6 +234,27 @@ struct EngineGlobals { /// forwarded to the engine's text-mode renderers. #[command(flatten)] output: super::output::OutputFlags, + + /// Per-invocation `minimumReleaseAge` overrides — a NUB-OWN group, not an + /// aube `Cli` mirror (see the note above the struct). + /// + /// This rides on `EngineGlobals`, so it reaches EVERY verb in the family — + /// all ~20 `run_verb` arms, verified by sweeping `--help`, not estimated. + /// Only a minority actually consult it: `add`/`update`/`dedupe`/`import` + /// resolve from the registry directly, and `dlx`/`create` through their + /// transient install. The rest accept it and ignore it — `remove` and + /// `prune` only re-resolve or trim what is already there, and the + /// local-state verbs (`link`/`unlink`, `approve-builds`/`ignored-builds`, + /// the `patch*` trio, `rebuild`) never resolve at all. + /// + /// Carried anyway rather than split per-verb: the cost is an inert `--help` + /// line on those verbs, and threading a second args group through the + /// family would be more machinery than that is worth. The read-only verbs + /// (`why`, `list`, `licenses`, `outdated`, `audit`) are unaffected either + /// way — they parse through `info_family`'s own `parse_verb` and never see + /// this struct. + #[command(flatten)] + age_gate: super::min_release_age::AgeGateFlags, } impl EngineGlobals { @@ -267,10 +298,14 @@ fn verb_command(typed: &str) -> clap::Command { fn parse_verb(typed: &str, args: &[String]) -> Result> { let argv = std::iter::once(format!("nub {typed}")).chain(args.iter().cloned()); match verb_command::(typed).try_get_matches_from(argv) { - Ok(matches) => Ok(ParsedVerb::Run( - EngineGlobals::from_arg_matches(&matches)?, - A::from_arg_matches(&matches)?, - )), + Ok(matches) => { + let globals = EngineGlobals::from_arg_matches(&matches)?; + // Publish before the verb runs: the engine reads the age gate + // through process-global settings accessors, not through anything + // threaded into the verb's own args. + globals.age_gate.apply(); + Ok(ParsedVerb::Run(globals, A::from_arg_matches(&matches)?)) + } Err(err) => { let text = present::rewrite_help(err.render().to_string().trim_end()); if matches!( @@ -1719,6 +1754,8 @@ mod tests { "--filter", "--filter-prod", "--loglevel", + "--minimum-release-age", + "--minimum-release-age-exclude", "--package", "--prefix", "--registry", diff --git a/crates/nub-cli/src/pm_engine/min_release_age.rs b/crates/nub-cli/src/pm_engine/min_release_age.rs index d64b7cd82..de2041b1a 100644 --- a/crates/nub-cli/src/pm_engine/min_release_age.rs +++ b/crates/nub-cli/src/pm_engine/min_release_age.rs @@ -292,6 +292,124 @@ fn emit_notice(target: &Target, recorded: &[String]) { )); } +/// Engine minutes for a `--minimum-release-age` value, accepting BOTH surfaces +/// this setting already has: +/// +/// - `` (`s|m|h|d|w`) — the exact grammar `nub.jsonc`'s +/// `install.minimumReleaseAge` accepts, parsed by the same +/// [`crate::project_config::parse_duration`] so a value that works in the file +/// works on the flag and the two cannot drift. +/// - a bare integer — MINUTES, mirroring pnpm's CLI, whose type map declares +/// `minimum-release-age` as a Number. +/// +/// The file deliberately REJECTS the bare form (npm counts days, pnpm counts +/// minutes, so an unqualified number in a config file is a trap). On the CLI the +/// ambiguity is already settled by the tool nub mirrors, so accepting it is +/// compatibility rather than a hazard. +/// +/// Sub-minute values round UP, never down: the engine setting is whole minutes, +/// and `30s` collapsing to `0` would SILENTLY DISABLE the gate instead of +/// tightening it. Same rule, and the same reason, as the bunfig seconds→minutes +/// conversion in [`super::bun_config`]. A literal `0` still means zero — that is +/// the documented "turn it off" value, not a rounding artifact. +fn parse_release_age_minutes(raw: &str) -> Result { + let s = raw.trim(); + if let Ok(minutes) = s.parse::() { + return Ok(minutes); + } + // Take the config parser's VERDICT but not its wording: its `Display` + // attributes the failure to `nub.jsonc`, which is a lie on a command line. + // The grammar stays in one place; only the sentence differs. + let dur = crate::project_config::parse_duration(s, "--minimum-release-age").map_err(|_| { + format!( + "expected minutes (e.g. `1440`) or a duration with a unit \ + s|m|h|d|w (e.g. `3d`), got `{raw}`" + ) + })?; + Ok(dur.as_secs().div_ceil(60)) +} + +// The per-invocation age-gate flags, flattened into every nub surface that +// resolves from the registry (`install`/`ci`, the engine verbs via +// `EngineGlobals`, and `nubx`). +// +// Each flag mirrors BOTH of the surfaces this setting already has: the pnpm +// spelling (its CLI type map declares `minimum-release-age` and +// `minimum-release-age-exclude`) and the value grammar of the matching +// `nub.jsonc` field (`install.minimumReleaseAge`, +// `install.minimumReleaseAgeExclude`). +// +// NO STRICTNESS FLAG, deliberately. Under nub the gate defaults to strict, and +// the way to opt out is `--minimum-release-age=0` — turn the window OFF, rather +// than keep a window and quietly install versions that fail it. pnpm's +// `minimumReleaseAgeStrict` defaults to false, which leaves its 24h window +// advisory unless you separately opt in; nub does not reproduce that. So there +// is no `--[no-]minimum-release-age-strict`, and no +// `install.minimumReleaseAgeStrict` in `nub.jsonc` either — one axis (how +// long), not two. +// +// (Plain `//`, not rustdoc: a `///` comment on a clap `Args` struct becomes the +// augmented command's `--help` about-text and clobbers the verb's own, the same +// hazard `EngineGlobals` documents. This one leaked onto `nub add --help`.) +#[derive(Debug, Default, Clone, clap::Args)] +pub struct AgeGateFlags { + /// How old a version must be before it can be installed: a duration with a + /// unit (`30s`, `5m`, `2h`, `3d`, `1w`), or a bare number meaning minutes. + /// `0` turns the age gate off for this run. Overrides `minimumReleaseAge` + /// from config. + #[arg(long, value_name = "DURATION", value_parser = parse_release_age_minutes)] + pub minimum_release_age: Option, + + /// Exempt a package from the age gate for this run (repeatable). Same entry + /// grammar as the config field: a bare name, a `*` name glob, or a name with + /// a version range. + #[arg(long, value_name = "PKG")] + pub minimum_release_age_exclude: Vec, +} + +impl AgeGateFlags { + /// The `(setting, value)` pairs for [`aube_settings::set_global_cli_overrides`]. + /// Keys are the canonical setting names — `cli_key_matches` compares in + /// kebab-case, so these reach `minimumReleaseAge` / `minimumReleaseAgeExclude` + /// without needing a `sources.cli` alias declared in `settings.toml`. + fn cli_overrides(&self) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(minutes) = self.minimum_release_age { + out.push(("minimumReleaseAge".to_string(), minutes.to_string())); + } + if !self.minimum_release_age_exclude.is_empty() { + // The settings reader takes the LAST matching CLI entry and parses it + // as one list, so repeated `--minimum-release-age-exclude` flags have + // to arrive joined rather than as separate entries. + out.push(( + "minimumReleaseAgeExclude".to_string(), + self.minimum_release_age_exclude.join(","), + )); + } + out + } + + /// Publish the flags into the engine's process-global CLI-override bag, + /// which `aube_settings`' typed accessors consult ahead of every other + /// source. One call covers the whole run: the resolver, the default-trust + /// floor, and any chained install all read through the same accessors, so + /// nothing needs the value threaded down to it. + /// + /// A managed (org-policy) config still wins — `minimumReleaseAge` carries + /// `managedPolicy = "max"`, applied by the generated accessor's finalizer + /// after this bag is consulted, so a CLI flag can raise the floor but never + /// lower one an administrator set. + /// + /// The bag is a `OnceLock`, so skip the call when nothing was passed rather + /// than latching an empty vec. + pub fn apply(&self) { + let overrides = self.cli_overrides(); + if !overrides.is_empty() { + aube_settings::set_global_cli_overrides(overrides); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -426,4 +544,87 @@ mod tests { vec!["vite@6.0.0"] ); } + + /// Zero must reach the engine as a literal `0`, because `0` is the ONLY way + /// to turn the gate off — nub ships no strictness flag, so a value that + /// arrived as anything else (or was dropped as "unset") would leave a user + /// who asked for no window still gated. `resolve_minimum_release_age` + /// short-circuits to `None` on exactly `0`. + #[test] + fn zero_reaches_the_engine_as_the_off_switch() { + let flags = AgeGateFlags { + minimum_release_age: Some(0), + ..Default::default() + }; + assert_eq!( + flags.cli_overrides(), + vec![("minimumReleaseAge".to_string(), "0".to_string())], + "0 must be published verbatim, not elided as a falsy/default value" + ); + // Every spelling of "no window" collapses to the same 0. + assert_eq!(parse_release_age_minutes("0"), Ok(0)); + assert_eq!(parse_release_age_minutes("0s"), Ok(0)); + assert_eq!(parse_release_age_minutes("0d"), Ok(0)); + } + + /// The flag takes both surfaces the setting already has: `nub.jsonc`'s + /// unit grammar, and pnpm's bare-number-means-minutes. + #[test] + fn release_age_accepts_the_config_units_and_pnpms_bare_minutes() { + // Bare number = minutes, matching pnpm's CLI type map. + assert_eq!(parse_release_age_minutes("1440"), Ok(1440)); + assert_eq!(parse_release_age_minutes("0"), Ok(0)); + // Every unit `nub.jsonc`'s install.minimumReleaseAge accepts. + assert_eq!(parse_release_age_minutes("5m"), Ok(5)); + assert_eq!(parse_release_age_minutes("2h"), Ok(120)); + assert_eq!(parse_release_age_minutes("3d"), Ok(4320)); + assert_eq!(parse_release_age_minutes("1w"), Ok(10_080)); + // `1d` is the default window, so this is the identity a user checks. + assert_eq!( + parse_release_age_minutes("1d"), + parse_release_age_minutes("1440") + ); + } + + /// Sub-minute values must round UP. Truncating `30s` to `0` would turn a + /// request to TIGHTEN the gate into silently disabling it — the same trap + /// the bunfig seconds→minutes conversion guards against. + #[test] + fn sub_minute_release_age_rounds_up_so_it_never_disables_the_gate() { + assert_eq!(parse_release_age_minutes("30s"), Ok(1)); + assert_eq!(parse_release_age_minutes("1s"), Ok(1)); + assert_eq!(parse_release_age_minutes("90s"), Ok(2)); + // An explicit zero is the documented "off" value, not a rounding artifact. + assert_eq!(parse_release_age_minutes("0s"), Ok(0)); + } + + /// The flag inherits the config grammar's rejections, so a value the file + /// refuses cannot sneak in through the CLI. + #[test] + fn release_age_rejects_what_the_config_grammar_rejects() { + for bad in ["3y", "-1d", "3 d", "d", "3_0d", ""] { + assert!( + parse_release_age_minutes(bad).is_err(), + "{bad:?} must be rejected on the flag, as it is in nub.jsonc" + ); + } + } + + /// Repeated `--minimum-release-age-exclude` must arrive as ONE joined entry: + /// the settings reader takes the last matching CLI key and parses it as a + /// single list, so separate entries would drop all but the final package. + #[test] + fn repeated_excludes_are_joined_into_one_cli_entry() { + let flags = AgeGateFlags { + minimum_release_age_exclude: vec!["react".into(), "@myorg/*".into()], + ..Default::default() + }; + let overrides = flags.cli_overrides(); + let exclude: Vec<_> = overrides + .iter() + .filter(|(k, _)| k == "minimumReleaseAgeExclude") + .collect(); + assert_eq!(exclude.len(), 1, "must be one entry, got {overrides:?}"); + assert_eq!(exclude[0].1, "react,@myorg/*"); + } } diff --git a/crates/nub-cli/src/pm_engine/mod.rs b/crates/nub-cli/src/pm_engine/mod.rs index 576f0822f..f3b0f7bfe 100644 --- a/crates/nub-cli/src/pm_engine/mod.rs +++ b/crates/nub-cli/src/pm_engine/mod.rs @@ -74,6 +74,7 @@ pub mod vite_compat; pub use install_family::{ CiFlags, InstallFlags, WorkspaceFilterFlags, run_ci, run_dlx_for_nubx, run_install, }; +pub use min_release_age::AgeGateFlags; pub use output::OutputFlags; use std::path::{Path, PathBuf}; diff --git a/crates/nub-cli/src/project_config.rs b/crates/nub-cli/src/project_config.rs index 6200c41fc..de4565c20 100644 --- a/crates/nub-cli/src/project_config.rs +++ b/crates/nub-cli/src/project_config.rs @@ -1298,7 +1298,14 @@ fn validate_dlx(v: &Value, path: &str) -> Result { /// units `s|m|h|d|w` ONLY (no months/years — calendar ambiguity; `m` is /// unambiguously minutes). A bare unit-less number is REJECTED (the npm-days vs /// pnpm-minutes trap, made unrepresentable). -fn parse_duration(s: &str, path: &str) -> Result { +/// +/// Shared with the `--minimum-release-age` CLI flag +/// ([`crate::pm_engine::min_release_age`]) so the file and the flag cannot drift +/// to two different grammars. The flag layers ONE relaxation on top: a bare +/// number there means MINUTES, because that is pnpm's CLI contract and the CLI +/// mirrors pnpm. The file keeps the rejection — it is nub's own surface, with no +/// incumbent grammar to match and nothing to disambiguate it. +pub(crate) fn parse_duration(s: &str, path: &str) -> Result { let invalid = |msg: &str| ConfigError::Value { path: path.into(), message: format!("invalid duration `{s}` — {msg}"), diff --git a/site/content/docs/install/index.mdx b/site/content/docs/install/index.mdx index ab869014e..56564257a 100644 --- a/site/content/docs/install/index.mdx +++ b/site/content/docs/install/index.mdx @@ -434,7 +434,24 @@ Which manifest field grants permission tracks the inferred incumbent: pnpm proje A registry-resolved version must be older than `minimumReleaseAge` — 24 hours by default — before Nub will install it. The window is what keeps a compromised publish out of your tree during the hours between it going up and being caught. -Publish dates come from the registry's `time` metadata, so the gate is only as strong as what the registry serves. Under the default `minimumReleaseAgeStrict=true`, an age Nub cannot establish counts as a failure rather than a pass: +Two flags adjust it for a single command, so getting past a block never means editing config: + +```console +$ nub add some-tool +ERR_NUB_NO_MATURE_MATCHING_VERSION # ❌ every matching version is too new + +$ nub add some-tool --minimum-release-age=0 # turn the window off, this run only +$ nub add some-tool --minimum-release-age=2h # or just shorten it +$ nub add some-tool --minimum-release-age-exclude=some-tool # exempt one package +``` + +The duration takes a unit — `s`, `m`, `h`, `d`, or `w` — and a bare number means minutes, matching pnpm. Sub-minute values round up, so a short window can never round down to zero and switch the gate off by accident. + +Both work on every command that resolves from the registry, including the remote bin runner. An organization-managed config sets a floor the flags cannot lower. + +The window is enforced, not advisory: when nothing satisfying the range is old enough, the install fails rather than quietly taking a version that missed the cutoff. There is no flag to relax that — setting the window to `0` turns it off outright, which is the honest way to say you don't want one. (This is where Nub departs from pnpm, whose `minimumReleaseAgeStrict` is off unless you set it, leaving its default window advisory.) + +Publish dates come from the registry's `time` metadata, so the gate is only as strong as what the registry serves. An age Nub cannot establish counts as a failure rather than a pass: | Registry metadata | Outcome | |---|---| @@ -450,7 +467,7 @@ minimumReleaseAgeExclude=internal-pkg # exempt one package (comma-separated) minimumReleaseAge=0 # turn the window off ``` -Loosening strictness is a third, and it is not a smaller window. With `minimumReleaseAgeStrict=false` an undateable version counts as clearing the gate, so the newest version matching your range is installed with no age checked at all. Where versions *are* dated and all of them are too new, the same setting instead falls back to the lowest satisfying version. +There is a third way through that Nub does not recommend and never suggests in its own error output. The `minimumReleaseAgeStrict` setting is still readable from `.npmrc` for compatibility, and setting it to `false` makes an undateable version count as clearing the gate — so the newest version matching your range is installed with no age checked at all, and where versions *are* dated and all are too new, the lowest satisfying one is taken instead. That is a window you are no longer enforcing while still appearing to have one. If you don't want the window, set `minimumReleaseAge=0` and say so plainly. ### Default-trust floor diff --git a/vendor/aube/crates/aube-resolver/src/error.rs b/vendor/aube/crates/aube-resolver/src/error.rs index d5db7d68c..049bb7b3d 100644 --- a/vendor/aube/crates/aube-resolver/src/error.rs +++ b/vendor/aube/crates/aube-resolver/src/error.rs @@ -83,6 +83,18 @@ pub struct NoMatchDetails { #[derive(Debug)] pub struct AgeGateDetails { pub name: String, + /// The identity `minimumReleaseAgeExclude` actually matches on — + /// `ResolveTask::registry_name()`, i.e. the real name for an + /// `npm:`-aliased dep and `name` for everything else. + /// + /// Kept separate from `name` because the two differ exactly where it + /// matters: for `"foo": "npm:real-pkg@^1"` the human reads `foo`, but an + /// exclude entry naming `foo` matches nothing (the exempt closure in + /// `resolve::driver` binds the registry name). Printing `name` in an + /// exclude remedy hands the user an entry clap accepts and the matcher + /// then silently ignores, so the remedies below use THIS field and the + /// prose keeps `name`. + pub registry_name: String, pub range: String, pub minutes: u64, pub importer: String, @@ -103,6 +115,8 @@ pub struct AgeGateDetails { #[derive(Debug)] pub struct UndatedDetails { pub name: String, + /// The exclude-matching identity — see [`AgeGateDetails::registry_name`]. + pub registry_name: String, pub range: String, pub importer: String, pub ancestors: Vec<(String, String)>, @@ -315,6 +329,7 @@ pub(crate) fn build_age_gate( let (dated, _) = satisfying_versions(task, packument); AgeGateDetails { name: task.name.clone(), + registry_name: task.registry_name().to_string(), range: task.range.clone(), minutes, importer: task.importer.clone(), @@ -330,6 +345,7 @@ pub(crate) fn build_release_age_missing_time( let (_, undated) = satisfying_versions(task, packument); UndatedDetails { name: task.name.clone(), + registry_name: task.registry_name().to_string(), range: task.range.clone(), importer: task.importer.clone(), ancestors: task.ancestors.to_vec(), @@ -386,8 +402,31 @@ fn format_age_gate_help(d: &AgeGateDetails) -> String { .join(", ") )); } - s.push_str("to bypass: loosen `minimumReleaseAge` in .npmrc, set `minimumReleaseAgeStrict=false` to fall back to the lowest satisfying version, or add `"); - s.push_str(&d.name); + // Lead with the one-shot flags — this error most often interrupts a single + // command (a dlx of a just-published tool), where editing config to get + // through it once is the wrong shape of remedy. The persistent config + // remedies follow for the case where the exemption should stick. + // + // Every remedy named here must be one nub actually accepts, and every one is + // about the WINDOW, never its strictness: under nub the gate is enforced, so + // the way out is a shorter window or an exemption, not a window that is + // quietly ignored. (`minimumReleaseAgeStrict=false` is deliberately absent + // for that reason — it remains a settable key, but recommending it would + // point users at a posture nub does not stand behind.) + // Offer SHORTENING first and `0` as its limit: the flag takes a duration, + // so pointing every blocked user straight at "switch the gate off" is a + // heavier remedy than the situation usually needs. + s.push_str( + "to bypass for this run: `--minimum-release-age=` to shorten the window \ + (`0` turns it off), or `--minimum-release-age-exclude=", + ); + // The exclude remedies print `registry_name`, NOT `name` — see the field + // docs. For an `npm:`-aliased dep they differ, and an entry naming the alias + // is silently ignored by the matcher. + s.push_str(&d.registry_name); + s.push_str("` to exempt just this package\n"); + s.push_str("to bypass persistently: shorten `minimumReleaseAge` in .npmrc (`0` turns it off), or add `"); + s.push_str(&d.registry_name); s.push_str("` to `minimumReleaseAgeExclude`"); s } @@ -419,10 +458,14 @@ fn format_undated_help(d: &UndatedDetails) -> String { "to proceed: unset `registry-supports-time-field` if it is on (it suppresses the \ full-packument fetch that carries `time`), check the registry config in .npmrc, add `", ); - s.push_str(&d.name); + // `registry_name`, not `name`: an exclude entry naming an `npm:` alias + // matches nothing. (Shortening is NOT offered here — unlike the ordinary + // age gate, no window admits an undated version, so only an exemption or + // turning the window off can help.) + s.push_str(&d.registry_name); s.push_str( "` to `minimumReleaseAgeExclude`, or set `minimumReleaseAge=0` to turn the window off \ - for this project", + for this project (`--minimum-release-age=0` for this run alone)", ); s } diff --git a/vendor/aube/crates/aube-resolver/src/tests.rs b/vendor/aube/crates/aube-resolver/src/tests.rs index 840fa563e..b3d90f197 100644 --- a/vendor/aube/crates/aube-resolver/src/tests.rs +++ b/vendor/aube/crates/aube-resolver/src/tests.rs @@ -199,6 +199,7 @@ fn classify_registry_error_prefers_git_over_http_url() { fn age_gate_help_lists_gated_versions_and_bypass() { let err = Error::AgeGate(Box::new(AgeGateDetails { name: "lodash".into(), + registry_name: "lodash".into(), range: "^4".into(), minutes: 60, importer: "packages/app".into(), @@ -209,8 +210,88 @@ fn age_gate_help_lists_gated_versions_and_bypass() { assert!(help.contains("importer: packages/app")); assert!(help.contains("chain: parent@1.0.0 > lodash")); assert!(help.contains("blocked by age gate: 4.17.21, 4.17.20")); - assert!(help.contains("minimumReleaseAgeStrict=false")); + // The one-shot flags lead: this error usually interrupts a single command, + // where a config edit is the wrong shape of remedy. + // Assert the FLAG is offered and that turning it off is reachable, without + // pinning one literal spelling of the value — the remedy legitimately moved + // from `=0` to `=` when shortening became the leading advice. + assert!( + help.contains("--minimum-release-age="), + "the one-shot window flag must be offered, got: {help}" + ); + assert!( + help.contains("`0` turns it off"), + "turning the window off must stay reachable from the help, got: {help}" + ); + assert!( + help.contains("--minimum-release-age-exclude=lodash"), + "the per-package one-shot exemption must be offered, got: {help}" + ); assert!(help.contains("minimumReleaseAgeExclude")); + // Guard, not decoration. An earlier revision of this test asserted the + // help DID offer `--no-minimum-release-age-strict`; when that flag was + // removed the suite stayed green while the advice became unrunnable + // (`error: unexpected argument`). Every remedy printed here has to be one + // the CLI accepts, so assert the removed spelling is ABSENT. + assert!( + !help.contains("--no-minimum-release-age-strict"), + "the removed strictness flag must never be advertised, got: {help}" + ); + // Strictness is not offered as a remedy either: nub enforces the window, so + // the way out is a shorter window or an exemption, never a window that is + // silently ignored. + assert!( + !help.contains("minimumReleaseAgeStrict=false"), + "loosening strictness must not be recommended, got: {help}" + ); + // Shortening is the proportionate remedy and must be offered, not just the + // all-or-nothing `0`: the flag takes a duration. + assert!( + help.contains("--minimum-release-age="), + "shortening the window must be offered, not only turning it off: {help}" + ); +} + +/// An `npm:`-aliased dep is the one case where the name the human reads and the +/// name `minimumReleaseAgeExclude` matches differ. The exclude remedies must +/// print the REGISTRY name: an entry naming the alias is accepted by clap and +/// by the config parser, then silently matches nothing, so the user follows +/// nub's own advice and stays blocked with no indication why. +#[test] +fn exclude_remedies_name_the_registry_identity_not_the_alias() { + let err = Error::AgeGate(Box::new(AgeGateDetails { + name: "foo".into(), + registry_name: "real-pkg".into(), + range: "^1".into(), + minutes: 60, + importer: ".".into(), + ancestors: vec![], + gated: vec!["1.2.3".into()], + })); + let help = err.help().expect("help set").to_string(); + assert!( + help.contains("--minimum-release-age-exclude=real-pkg"), + "the one-shot exemption must name the matcher's identity, got: {help}" + ); + assert!( + !help.contains("--minimum-release-age-exclude=foo"), + "naming the alias hands the user an entry that matches nothing: {help}" + ); + + // Same contract on the undated path, which shares the field. + let undated = Error::ReleaseAgeMissingTime(Box::new(UndatedDetails { + name: "foo".into(), + registry_name: "real-pkg".into(), + range: "^1".into(), + importer: ".".into(), + ancestors: vec![], + undated: vec!["1.2.3".into()], + })); + let help = undated.help().expect("help set").to_string(); + assert!( + help.contains("`real-pkg` to `minimumReleaseAgeExclude`"), + "the undated remedy must name the matcher's identity too, got: {help}" + ); } /// A registry that publishes no `time` data blocks the whole range, but for a @@ -222,6 +303,7 @@ fn age_gate_help_lists_gated_versions_and_bypass() { fn release_age_missing_time_names_the_registry_as_the_cause() { let err = Error::ReleaseAgeMissingTime(Box::new(UndatedDetails { name: "lodash".into(), + registry_name: "lodash".into(), range: "^4".into(), importer: ".".into(), ancestors: vec![], diff --git a/vendor/aube/crates/aube/src/commands/add/manifest.rs b/vendor/aube/crates/aube/src/commands/add/manifest.rs index aeb69ab34..cd2cc85a3 100644 --- a/vendor/aube/crates/aube/src/commands/add/manifest.rs +++ b/vendor/aube/crates/aube/src/commands/add/manifest.rs @@ -488,7 +488,8 @@ pub(super) async fn update_manifest_for_add( to proceed: unset `registry-supports-time-field` if it is on (it \ suppresses the full-packument fetch that carries `time`), check the \ registry config in .npmrc, add `{}` to `minimumReleaseAgeExclude`, or \ - set `minimumReleaseAge=0` to turn the window off for this project", + set `minimumReleaseAge=0` to turn the window off for this project \ + (`--minimum-release-age=0` for this run alone)", spec.name ), "cannot check the publish age of {}@{effective_range} — the registry served no publish time for any matching version", @@ -502,8 +503,24 @@ pub(super) async fn update_manifest_for_add( let (minutes, strict) = minimum_release_age .as_ref() .map_or((0, false), |m| (m.minutes, m.strict)); + // Same remedies the resolver's `format_age_gate_help` offers on + // the install path — `add` used to send the user away with no + // way out at all. return Err(miette!( code = aube_codes::errors::ERR_AUBE_NO_MATURE_MATCHING_VERSION, + // `spec.name` is already the REGISTRY identity here — an + // aliased add carries the user-facing key in `spec.alias` + // and writes `npm:{spec.name}@…` to the manifest — so it is + // the right thing to name in an exclude remedy, unlike the + // resolver's `task.name` (see `AgeGateDetails::registry_name`). + help = format!( + "to bypass for this run: `--minimum-release-age=` to shorten \ + the window (`0` turns it off), or \ + `--minimum-release-age-exclude={0}` to exempt just this package\n\ + to bypass persistently: shorten `minimumReleaseAge` in .npmrc (`0` \ + turns it off), or add `{0}` to `minimumReleaseAgeExclude`", + spec.name + ), "no version of {} matching {effective_range} is older than {minutes} minute(s){}", spec.name, if strict {