Skip to main content

aube/
lib.rs

1//! aube's command layer as a library.
2//!
3//! The `aube` / `aubr` / `aubx` binaries are thin wrappers over
4//! [`cli_main`]; everything else lives here so the command layer can be
5//! embedded by other tools — e.g. constructing
6//! [`embed::InstallOptions`] and calling [`embed::install`] in-process instead
7//! of shelling out to the CLI. Embedding hosts should use [`embed`], which is
8//! the stable facade over the lower-level [`commands`] modules. [`cli_args`]
9//! and [`cli_main`] remain public for hosts that wrap aube's complete CLI.
10//!
11//! The library makes no global-allocator choice — the mimalloc opt-in
12//! lives in `src/main.rs` so embedders keep control of their own
13//! allocator.
14
15mod argv;
16pub mod cli_args;
17pub mod command_effects;
18pub mod commands;
19mod dep_chain;
20mod deprecations;
21mod dirs;
22pub mod embed;
23mod engines;
24mod patches;
25mod pnpmfile;
26mod process_guard;
27mod progress;
28mod runtime;
29mod self_version;
30mod startup;
31mod state;
32mod tool_shims;
33mod update_check;
34mod version;
35
36use argv::{extract_config_overrides, lift_per_subcommand_flags, rewrite_multicall_argv};
37use miette::{Context, IntoDiagnostic};
38use startup::{
39    ColorMode, PackageManagerGuard, ci_renders_ansi, command_needs_package_manager_guard,
40    compute_effective_filter, diag_config_from_flag, enforce_package_manager_guardrails,
41    env_disables_color, init_logging, load_startup_settings, raise_nofile_limit,
42    resolve_color_mode, resolve_loglevel, startup_cwd,
43};
44#[cfg(test)]
45use startup::{PackageManagerGuardMode, PackageManagerStrictMode, package_manager_guard_mode};
46use std::ffi::OsString;
47use std::path::PathBuf;
48
49#[derive(usage_rs::Cli)]
50#[allow(dead_code)]
51#[usage(
52    name = aube_util::prog(),
53    name_spec = "aube",
54    about = "A fast Node.js package manager",
55    view("aubr", root = "run", globals),
56    view("aubx", root = "dlx", globals)
57)]
58pub(crate) struct Cli {
59    /// Change to directory before running (like `make -C` or `mise --cd`)
60    #[usage(
61        short = 'C',
62        long = "dir",
63        long = "cd",
64        long = "prefix",
65        global,
66        value_name = "DIR"
67    )]
68    dir: Option<std::path::PathBuf>,
69
70    /// Scope command execution to workspace packages matching PATTERN.
71    ///
72    /// Supports exact names (`my-pkg`), globs (`@scope/*`, `*-plugin`),
73    /// paths (`./packages/api`), graph selectors (`pkg...`, `...pkg`),
74    /// git-ref selectors (`[origin/main]`), and exclusions (`!pkg`).
75    /// Repeatable; matches are OR-ed.
76    ///
77    /// Currently honored by `run`, `test`, `start`, `stop`, `restart`,
78    /// `install`, `exec`, `list`, `publish`, `deploy`, `add`, `remove`,
79    /// `update`, `why`, and implicit-script invocations.
80    #[usage(short = 'F', long, global, value_name = "WORKSPACE")]
81    filter: Vec<String>,
82
83    /// Run the command across every workspace package.
84    ///
85    /// Equivalent to `--filter=*`; if `--filter` is also given,
86    /// `--recursive` is a no-op and the explicit filter wins. Honored
87    /// by the same commands as `--filter`.
88    #[usage(short = 'r', long, global)]
89    recursive: bool,
90
91    /// Enable verbose/debug logging (shortcut for `--loglevel debug`)
92    #[usage(short, long, global)]
93    verbose: bool,
94
95    /// Print version and check for updates.
96    ///
97    /// Manual flag so we can run the async update notifier alongside
98    /// the version print — clap's auto `Action::Version` exits inside
99    /// `parse_from`, before the tokio runtime is built.
100    #[usage(short = 'V', long = "version", global)]
101    version: bool,
102
103    /// Group workspace command output after each package finishes.
104    ///
105    /// Accepted for pnpm compatibility; aube's workspace fanout is
106    /// currently sequential, so output is already grouped.
107    #[usage(long, global, conflicts = "--stream", hide)]
108    aggregate_output: bool,
109
110    /// Force colored output even when stderr is not a TTY.
111    ///
112    /// Overrides `NO_COLOR` / `CLICOLOR=0`. Mutually exclusive with
113    /// `--no-color`.
114    #[usage(long, global, conflicts = "--no-color")]
115    color: bool,
116
117    /// Enable cold-install deep diagnostics. Modes:
118    ///   summary  — sum_ms / mean / max / %wall table at end
119    ///   trace    — summary + critical path + starvation + what-if + lifecycle
120    ///   live     — like trace, plus print every span >= 100ms to stderr live
121    ///   full     — like trace, plus write JSONL trace to a file (defaults to ./aube-diag.jsonl)
122    ///
123    /// Quick form: `--diag` with no value defaults to `trace`.
124    /// Output file path can be set via `--diag-file`. Threshold for live
125    /// mode via `--diag-threshold-ms`.
126    #[usage(long, global, value_name = "MODE", default_missing = "trace")]
127    diag: Option<String>,
128
129    /// Path for `--diag full` JSONL trace (default: ./aube-diag.jsonl)
130    #[usage(long, global, value_name = "PATH")]
131    diag_file: Option<PathBuf>,
132
133    /// Live-mode threshold: only print spans whose duration is >= N ms (default 100).
134    #[usage(long, global, value_name = "MS")]
135    diag_threshold_ms: Option<u64>,
136
137    /// Error when a workspace selector matches no packages.
138    ///
139    /// Accepted globally; selected commands already fail on empty matches.
140    #[usage(long, global)]
141    fail_if_no_match: bool,
142
143    /// Production-only variant of `--filter`.
144    ///
145    /// Same selector grammar as `--filter`, but graph walks (`pkg...`,
146    /// `...pkg`) only follow `dependencies` / `optionalDependencies` /
147    /// `peerDependencies` edges — `devDependencies` (and packages
148    /// reachable solely through them) are skipped. Non-graph forms
149    /// (exact name, glob, path, `[git-ref]`) behave identically to
150    /// `--filter`. Repeatable; can be combined with `--filter`.
151    #[usage(long, global, value_name = "PATTERN")]
152    filter_prod: Vec<String>,
153
154    /// Ignore workspace discovery for commands that support workspace fanout.
155    ///
156    /// Parsed for pnpm compatibility.
157    #[usage(long, global, hide)]
158    ignore_workspace: bool,
159
160    /// Include the workspace root in recursive workspace operations.
161    ///
162    /// Parsed for pnpm compatibility.
163    #[usage(long, global, hide)]
164    include_workspace_root: bool,
165
166    /// Set the log level. Logs at or above this level are shown.
167    #[usage(long, global, value_name = "LEVEL", value_enum)]
168    loglevel: Option<LogLevel>,
169
170    /// Disable colored output.
171    ///
172    /// Overrides `FORCE_COLOR` / `CLICOLOR_FORCE` and sets `NO_COLOR=1`
173    /// so downstream libraries (miette, clx, child processes) all see
174    /// the same choice.
175    #[usage(long, global)]
176    no_color: bool,
177
178    /// Output format: default, append-only, ndjson, silent.
179    ///
180    /// `default` renders the progress UI when stderr is a TTY;
181    /// `append-only` disables the progress UI in favor of plain
182    /// line-at-a-time logs; `ndjson` swaps the tracing fmt layer for
183    /// the JSON formatter (one JSON object per log event on stderr)
184    /// and is what tooling wrappers should consume; `silent`
185    /// suppresses all non-error output (alias for `--loglevel silent`).
186    #[usage(long, global, value_name = "NAME", value_enum)]
187    reporter: Option<ReporterType>,
188
189    /// Suppress all non-error output (alias for `--loglevel silent`)
190    #[usage(long, global)]
191    silent: bool,
192
193    /// Stream workspace command output as each child process writes it.
194    ///
195    /// Accepted for pnpm compatibility; aube's workspace fanout is
196    /// currently sequential.
197    #[usage(long, global, conflicts = "--aggregate-output", hide)]
198    stream: bool,
199
200    /// Route lifecycle and workspace command output through stderr.
201    ///
202    /// Accepted for pnpm compatibility.
203    #[usage(long, global, hide)]
204    use_stderr: bool,
205
206    /// Prefer workspace packages when resolving dependencies.
207    ///
208    /// Parsed for pnpm compatibility; aube already resolves workspace
209    /// packages when a workspace is present.
210    #[usage(long, global, hide)]
211    workspace_packages: bool,
212
213    /// Run from the workspace root regardless of the current package.
214    #[usage(long, global)]
215    workspace_root: bool,
216
217    /// Automatically answer yes to prompts.
218    ///
219    /// Parsed for pnpm compatibility; aube does not currently prompt
220    /// on these paths.
221    #[usage(short = 'y', long, global, hide)]
222    yes: bool,
223
224    #[usage(subcommand)]
225    command: Option<Commands>,
226}
227
228#[cfg(test)]
229impl Cli {
230    fn try_parse_test_from<I, S>(argv: I) -> Result<Self, String>
231    where
232        I: IntoIterator<Item = S>,
233        S: Into<OsString>,
234    {
235        let raw: Vec<OsString> = argv.into_iter().map(Into::into).collect();
236        let argv: Vec<&std::ffi::OsStr> = raw.iter().map(OsString::as_os_str).collect();
237        Self::parse_from_argv(&argv)
238            .map_err(|error| render_cli_error(argv.get(1..).unwrap_or_default(), &error))
239    }
240}
241
242fn render_cli_error(argv: &[&std::ffi::OsStr], error: &usage_rs::Error<'_, '_>) -> String {
243    if let usage_rs::Error::MissingFlagValue { flag } = error
244        && flag.require_equals
245        && flag.longs.contains(&"allow-build")
246    {
247        return "error: equal sign is needed when assigning values to '--allow-build=<PKG>'"
248            .to_owned();
249    }
250    if let usage_rs::Error::InvalidValue(detail) = error
251        && detail
252            .reason
253            .starts_with("The --allow-build flag is missing a package name.")
254    {
255        return detail
256            .reason
257            .replacen(" Please specify", "\nPlease specify", 1);
258    }
259    let runtime_spec = Cli::runtime_app().spec();
260    usage_rs::render_failure(&runtime_spec, argv, error)
261}
262
263fn is_allow_build_compat_error(error: &usage_rs::Error<'_, '_>) -> bool {
264    matches!(
265        error,
266        usage_rs::Error::MissingFlagValue { flag }
267            if flag.require_equals && flag.longs.contains(&"allow-build")
268    ) || matches!(
269        error,
270        usage_rs::Error::InvalidValue(detail)
271            if detail.reason.starts_with("The --allow-build flag is missing a package name.")
272    )
273}
274
275#[derive(Copy, Clone, Debug, PartialEq, Eq, usage_rs::ValueEnum, strum::EnumString)]
276#[strum(serialize_all = "kebab-case")]
277pub(crate) enum LogLevel {
278    Trace,
279    Debug,
280    Info,
281    Warn,
282    Error,
283    Silent,
284}
285
286#[derive(Copy, Clone, Debug, PartialEq, Eq, usage_rs::ValueEnum, strum::EnumString)]
287#[strum(serialize_all = "kebab-case")]
288pub(crate) enum ReporterType {
289    Default,
290    AppendOnly,
291    Ndjson,
292    Silent,
293}
294
295impl LogLevel {
296    fn filter(self) -> &'static str {
297        match self {
298            LogLevel::Trace => "trace",
299            LogLevel::Debug => "debug",
300            LogLevel::Info => "info",
301            LogLevel::Warn => "warn",
302            LogLevel::Error => "error",
303            LogLevel::Silent => "off",
304        }
305    }
306}
307
308/// Redirects stderr (fd 2) to `/dev/null` for its lifetime, restoring the
309/// original on drop. Used by `--silent` to suppress the ~230 direct
310/// `eprintln!` calls scattered across command implementations without
311/// rewriting them all. The guard must be dropped *before* `main` returns
312/// so that any `miette` error report bubbled up through `?` is printed to
313/// the real stderr. Stdout is left alone — `aube --silent config get foo`
314/// should still emit data to a pipe.
315struct SilentStderrGuard {
316    saved: libc::c_int,
317}
318
319impl SilentStderrGuard {
320    fn install() -> Option<Self> {
321        unsafe {
322            let saved = libc::dup(2);
323            if saved < 0 {
324                return None;
325            }
326            let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
327            if devnull < 0 {
328                libc::close(saved);
329                return None;
330            }
331            if libc::dup2(devnull, 2) < 0 {
332                libc::close(devnull);
333                libc::close(saved);
334                return None;
335            }
336            libc::close(devnull);
337            Some(Self { saved })
338        }
339    }
340}
341
342impl Drop for SilentStderrGuard {
343    fn drop(&mut self) {
344        unsafe {
345            libc::dup2(self.saved, 2);
346            libc::close(self.saved);
347        }
348    }
349}
350
351// Commands are listed in alphabetical order; validated by
352// `cli_ordering_tests::test_cli_ordering`. Per-command arg fields are
353// similarly sorted: positional first, then short flags by short option,
354// then long-only flags alphabetically. The `External` catch-all is last
355// because clap's external_subcommand must come after named variants; it
356// has no fixed name so the sort check skips it.
357#[derive(usage_rs::Subcommands)]
358enum Commands {
359    /// Bootstrap aube's cached node-gyp and print the executable path.
360    #[usage(name = "__node-gyp-bootstrap", hide)]
361    NodeGypBootstrap { project_dir: PathBuf },
362    /// Manage package access and visibility on the registry
363    Access(commands::access::AccessArgs),
364    /// Emit shell activation code for runtime tool shims
365    Activate(commands::activate::ActivateArgs),
366    /// Add a dependency
367    #[usage(alias = "a")]
368    Add(commands::add::AddArgs),
369    /// Approve ignored dependency build scripts.
370    ///
371    /// Writes entries under `allowBuilds` in `aube-workspace.yaml` (or
372    /// `pnpm-workspace.yaml` if present).
373    ApproveBuilds(commands::approve_builds::ApproveBuildsArgs),
374    /// Check installed packages against the registry advisory DB
375    Audit(commands::audit::AuditArgs),
376    /// Print the path to `node_modules/.bin`
377    Bin(commands::bin::BinArgs),
378    /// Open package bug tracker URLs
379    #[usage(alias = "issues", after_long_help = commands::bugs::AFTER_LONG_HELP)]
380    Bugs(commands::bugs::BugsArgs),
381    /// Inspect and manage the packument metadata cache
382    Cache(commands::cache::CacheArgs),
383    /// Print a file from the global store by integrity or hex hash
384    CatFile(commands::cat_file::CatFileArgs),
385    /// Print the cached package index JSON for `<name>@<version>`
386    CatIndex(commands::cat_index::CatIndexArgs),
387    /// Verify installed packages can resolve their declared deps.
388    ///
389    /// Walks the `node_modules/` symlink tree and confirms every
390    /// dependency in each `package.json` resolves to a real entry.
391    Check(commands::check::CheckArgs),
392    /// Clean install: delete node_modules, then install with frozen lockfile.
393    ///
394    /// Use in CI to guarantee a reproducible install from the committed lockfile.
395    #[usage(alias = "clean-install", alias("ic", "install-clean"))]
396    Ci(commands::ci::CiArgs),
397    /// Remove `node_modules` across every workspace project.
398    ///
399    /// `--lockfile` / `-l` also deletes lockfiles. A `clean` script in
400    /// the root `package.json` overrides the built-in.
401    Clean(commands::clean::CleanArgs),
402    /// Generate shell completions (bash, zsh, fish)
403    Completion(commands::completion::CompletionArgs),
404    /// Read and write settings in `.npmrc`
405    #[usage(alias = "c")]
406    Config(commands::config::ConfigArgs),
407    /// Scaffold a project from a `create-*` starter kit (via dlx)
408    Create(commands::create::CreateArgs),
409    /// Re-resolve the lockfile to collapse duplicate versions
410    Dedupe(commands::dedupe::DedupeArgs),
411    /// Deploy a workspace package into a target directory with deps inlined
412    Deploy(commands::deploy::DeployArgs),
413    /// Mark published versions of a package as deprecated on the registry
414    Deprecate(commands::deprecate::DeprecateArgs),
415    /// Report deprecated packages in the resolved dependency graph
416    Deprecations(commands::deprecations::DeprecationsArgs),
417    /// Diagnostic trace analysis (compare/analyze JSONL traces)
418    Diag(commands::diag::DiagArgs),
419    /// Manage package distribution tags on the registry
420    #[usage(alias = "dist-tags")]
421    DistTag(commands::dist_tag::DistTagArgs),
422    /// Fetch a package into a throwaway environment and run its binary
423    Dlx(commands::dlx::DlxArgs),
424    /// Run broad install-health diagnostics
425    Doctor(commands::doctor::DoctorArgs),
426    /// Execute a locally installed binary
427    #[usage(alias = "x")]
428    Exec(commands::exec::ExecArgs),
429    /// Download lockfile dependencies into the store without linking node_modules
430    Fetch(commands::fetch::FetchArgs),
431    /// List packages whose cached index references a given file hash
432    FindHash(commands::find_hash::FindHashArgs),
433    /// Alias for `config get` (hidden; prefer `config get`)
434    #[usage(hide)]
435    Get(commands::config::GetArgs),
436    /// Print packages whose install scripts were skipped by `pnpm.allowBuilds`
437    IgnoredBuilds(commands::ignored_builds::IgnoredBuildsArgs),
438    /// Convert a supported lockfile into aube-lock.yaml
439    Import(commands::import::ImportArgs),
440    /// Create a `package.json` in the current directory
441    Init(commands::init::InitArgs),
442    /// Install all dependencies
443    #[usage(alias = "i")]
444    Install(commands::install::InstallArgs),
445    /// Install dependencies, then run the `test` script (pnpm compat alias).
446    ///
447    /// Hidden from help because `aube test` already auto-installs.
448    #[usage(alias = "it", hide)]
449    InstallTest(commands::run::InstallTestArgs),
450    /// Alias for `list --long` (hidden; prefer `list --long`)
451    #[usage(hide)]
452    La(commands::list::LaArgs),
453    /// Report the licenses of installed dependencies
454    Licenses(commands::licenses::LicensesArgs),
455    /// Link a local package globally, or into the current project
456    #[usage(alias = "ln")]
457    Link(commands::link::LinkArgs),
458    /// Print the resolved dependency tree
459    #[usage(alias = "ls", after_long_help = commands::list::AFTER_LONG_HELP)]
460    List(commands::list::ListArgs),
461    /// Alias for `list --long` (hidden; prefer `list --long`)
462    #[usage(hide)]
463    Ll(commands::list::LlArgs),
464    /// Store a registry auth token in the user's ~/.npmrc
465    #[usage(alias = "adduser")]
466    Login(commands::login::LoginArgs),
467    /// Remove a registry auth token from the user's ~/.npmrc
468    Logout(commands::logout::LogoutArgs),
469    /// Run Node.js through aube's project runtime resolver
470    Node(commands::node::NodeArgs),
471    /// Report dependencies whose installed version lags behind the registry
472    Outdated(commands::outdated::OutdatedArgs),
473    /// Manage package owners (not implemented — use `npm owner`)
474    #[usage(hide)]
475    Owner(commands::npm_fallback::OwnerArgs),
476    /// Create a publishable `.tgz` tarball from the current project
477    Pack(commands::pack::PackArgs),
478    /// Extract a package into an edit directory so it can be patched
479    Patch(commands::patch::PatchArgs),
480    /// Generate a `.patch` file from a `aube patch` edit directory
481    PatchCommit(commands::patch_commit::PatchCommitArgs),
482    /// Remove patch entries from `pnpm.patchedDependencies`
483    PatchRemove(commands::patch_remove::PatchRemoveArgs),
484    /// Inspect peer-dependency resolution from the lockfile
485    Peers(commands::peers::PeersArgs),
486    /// Manage package.json entries (not implemented — use `npm pkg`)
487    #[usage(hide)]
488    Pkg(commands::npm_fallback::PkgArgs),
489    /// Print the current package prefix directory
490    Prefix(commands::prefix::PrefixArgs),
491    /// Remove extraneous packages from project `node_modules`.
492    ///
493    /// Reads the lockfile, computes the packages still reachable from each
494    /// importer, and removes stale top-level links, stale virtual-store entries,
495    /// and dangling .bin links. Does not modify package.json or the lockfile.
496    Prune(commands::prune::PruneArgs),
497    /// Publish the current package to the registry
498    #[cfg(feature = "publish")]
499    Publish(commands::publish::PublishArgs),
500    /// Alias for `clean` — remove `node_modules` across every workspace project.
501    ///
502    /// A `purge` script in the root `package.json` overrides the built-in.
503    Purge(commands::clean::PurgeArgs),
504    /// Query packages in the resolved dependency graph
505    Query(commands::query::QueryArgs),
506    /// Re-run root lifecycle scripts and allowlisted dependency builds
507    #[usage(alias = "rb")]
508    Rebuild(commands::rebuild::RebuildArgs),
509    /// Run a supported command across workspace packages
510    #[usage(alias("multi", "m"))]
511    Recursive(commands::recursive::RecursiveArgs),
512    /// Remove a dependency
513    #[usage(alias = "rm", alias("uninstall", "un", "uni"))]
514    Remove(commands::remove::RemoveArgs),
515    /// Restart a package (shortcut for `run restart`; falls back to `stop` + `start`)
516    Restart(commands::run::RestartArgs),
517    /// Print the path to `node_modules`
518    Root(commands::root::RootArgs),
519    /// Run a script defined in package.json
520    #[usage(alias = "run-script")]
521    Run(commands::run::RunArgs),
522    /// Manage the project's Node.js runtime (pin, install, inspect)
523    #[usage(alias = "rt")]
524    Runtime(commands::runtime::RuntimeArgs),
525    /// Generate a Software Bill of Materials (CycloneDX or SPDX)
526    Sbom(commands::sbom::SbomArgs),
527    /// Search the registry for packages (not implemented — use `npm search`)
528    #[usage(hide)]
529    Search(commands::npm_fallback::SearchArgs),
530    /// Alias for `config set` (hidden; prefer `config set`)
531    #[usage(hide)]
532    Set(commands::config::SetArgs),
533    /// Set a `package.json` script (not implemented — use `npm set-script`)
534    #[usage(hide, name = "set-script")]
535    SetScript(commands::npm_fallback::SetScriptArgs),
536    /// Show the companies sponsoring aube and the jdx.dev open source tools
537    Sponsors(commands::sponsors::SponsorsArgs),
538    /// Stage packages for publishing (not implemented — use `npm stage`)
539    Stage(commands::npm_fallback::StageArgs),
540    /// Start a package (shortcut for `run start`)
541    Start(commands::run::StartArgs),
542    /// Stop a package (shortcut for `run stop`)
543    Stop(commands::run::StopArgs),
544    /// Manage the global store
545    Store(commands::store::StoreArgs),
546    /// Run the `test` script (shortcut for `run test`)
547    #[usage(alias = "t")]
548    Test(commands::run::TestArgs),
549    /// Manage registry auth tokens (not implemented — use `npm token`)
550    #[usage(hide)]
551    Token(commands::npm_fallback::TokenArgs),
552    /// Inspect npm package publishing trust
553    Trust(commands::trust::TrustArgs),
554    /// Clear an existing deprecation on the registry
555    Undeprecate(commands::undeprecate::UndeprecateArgs),
556    /// Unlink a package (remove linked entries from node_modules)
557    #[usage(alias = "dislink")]
558    Unlink(commands::unlink::UnlinkArgs),
559    /// Remove a package (or a single version) from the registry
560    Unpublish(commands::unpublish::UnpublishArgs),
561    /// Update dependencies
562    #[usage(alias("up", "upgrade"))]
563    Update(commands::update::UpdateArgs),
564    /// Bump the version in package.json (and optionally create a git commit + tag)
565    Version(commands::version::VersionArgs),
566    /// Print package metadata from the registry
567    #[usage(alias("info", "show"), alias = "v", after_long_help = commands::view::AFTER_LONG_HELP)]
568    View(commands::view::ViewArgs),
569    /// Report the current registry user (not implemented — use `npm whoami`)
570    #[usage(hide)]
571    Whoami(commands::npm_fallback::WhoamiArgs),
572    /// Print reverse dependency chains explaining why a package is installed
573    #[usage(alias = "w", after_long_help = commands::why::AFTER_LONG_HELP)]
574    Why(commands::why::WhyArgs),
575    #[usage(external_subcommand)]
576    External(Vec<String>),
577}
578
579/// Library entry point. An embedder calls this with its own `&'static
580/// Embedder` (and optional setting defaults); the `aube` binary passes
581/// `&aube_util::AUBE` and no defaults, reproducing standalone behavior.
582/// This is the whole embedding API: register-then-run in one call, so a host
583/// never has to separately wire identity and defaults.
584///
585/// **Returns the exit code; it does not terminate the process.** It parses
586/// argv, runs the selected command, renders any diagnostic to stderr, and
587/// hands back the code the binary's `main` should exit with. Returning rather
588/// than calling `std::process::exit` keeps it embed-safe: a host that drives
589/// it in-process is not hard-killed by a non-zero result or an error. The
590/// standalone binary does `std::process::exit(cli_main(..))`.
591///
592/// `#[must_use]`: the `i32` is the exit code, not a side effect. An
593/// embedder migrating off the old `process::exit` entrypoint that drops
594/// it would silently exit 0 on every failure — Rust won't warn on an
595/// ignored return — so the lint nudges them to
596/// `std::process::exit(cli_main(..))`.
597#[must_use]
598pub fn cli_main(embedder: &'static aube_util::Embedder) -> i32 {
599    cli_main_with_defaults(embedder, Vec::new())
600}
601
602/// The root command metadata for the embeddable command layer.
603///
604/// This preserves the command-surface entry point for embedders while exposing
605/// usage-rs metadata instead of a clap command builder.
606pub fn command() -> &'static usage_rs::Command<'static> {
607    Cli::command()
608}
609
610/// The static parser and portable metadata for the embeddable command layer.
611pub fn spec() -> &'static usage_rs::spec::Spec<'static> {
612    Cli::spec()
613}
614
615pub fn usage_kdl() -> String {
616    let overlays = command_effects::overlays();
617    Cli::app().overlay(&overlays).to_kdl()
618}
619
620pub fn usage_kdl_for(name: &'static str) -> String {
621    let overlays = command_effects::overlays();
622    Cli::app().name(name).bin(name).overlay(&overlays).to_kdl()
623}
624
625pub fn completion_app(name: &'static str) -> usage_rs::complete::App<'static> {
626    let mut app = Cli::app()
627        .name(name)
628        .bin(name)
629        .completion_app()
630        .completions(&commands::completion::COMPLETIONS);
631    if let Some(view) = Cli::spec().views.iter().find(|view| view.bin == name) {
632        app = app.project(view.root);
633    }
634    app
635}
636
637fn print_subcommand_help(name: &str) -> miette::Result<()> {
638    let command = Cli::command()
639        .subcommands
640        .iter()
641        .copied()
642        .find(|command| command.name == name)
643        .ok_or_else(|| miette::miette!("unknown subcommand {name:?}"))?;
644    let page = usage_rs::help::render(Cli::spec(), command, true)
645        .ok_or_else(|| miette::miette!("failed to render help for {name:?}"))?;
646    print!("{page}");
647    Ok(())
648}
649
650/// [`cli_main`] plus embedder-supplied setting defaults. The `defaults` are
651/// `(canonical_setting_name, raw_value)` pairs registered at the lowest
652/// precedence tier — below every user- and project-level source — for the
653/// genuinely user-overridable knobs an embedder wants to re-default.
654/// (Embedder-*fixed* behavior lives on [`aube_util::Embedder`] itself, not
655/// here.) Standalone aube passes an empty vec, so per-setting built-in
656/// defaults apply unchanged.
657///
658/// `#[must_use]` for the same reason as [`cli_main`]: the returned `i32`
659/// is the exit code the embedder must hand to `std::process::exit`.
660#[must_use]
661pub fn cli_main_with_defaults(
662    embedder: &'static aube_util::Embedder,
663    defaults: Vec<(String, String)>,
664) -> i32 {
665    // Register the binary's embedder profile before anything reads branding,
666    // and its setting defaults before anything resolves settings. Both are
667    // idempotent — a no-op if already set (e.g. a test harness that
668    // registered one first).
669    aube_util::set_embedder(embedder);
670    aube_settings::set_embedder_defaults(defaults);
671
672    // Two-phase wrapper: `inner_main` runs the real CLI and returns
673    // `Result<i32, miette::Report>` — the command's exit code on Ok. On
674    // Err we render via miette's fancy handler (matching the previous
675    // `Termination` behavior), then look up the diagnostic's `code()`
676    // against `aube_codes::exit::EXIT_TABLE` to pick a bespoke exit code.
677    // Codes outside the table fall through to `EXIT_GENERIC` (1).
678    //
679    // Chain a panic hook that flushes the diag buffer before the
680    // default hook prints the panic. Without this, a debug-build panic
681    // (release uses `panic = "abort"` so the hook would not run anyway)
682    // would lose the BufWriter's 64 KiB tail and any unflushed events.
683    let prev_hook = std::panic::take_hook();
684    std::panic::set_hook(Box::new(move |info| {
685        aube_util::diag::flush();
686        prev_hook(info);
687    }));
688    let result = inner_main();
689    aube_util::diag::flush();
690    // Drain any in-flight slow-metadata group whose debounce window
691    // hasn't fired yet. install pipelines also flush at end-of-resolve
692    // (for in-progress UX), but non-install commands — `aube add`,
693    // `aube audit`, `aube deprecate`, `aube deprecations`, `aube view`,
694    // etc. — never hit that path and would otherwise silently lose
695    // their slow-fetch warnings to the accumulator.
696    aube_registry::slow_metadata::flush_summary();
697    // Return the exit code rather than terminating: only the binary's
698    // `main` calls `std::process::exit`, so a host embedding the command
699    // layer in-process isn't hard-killed by a non-zero result or an
700    // error. The diagnostic still renders to stderr here (matching the
701    // previous `Termination` behavior); only the exit itself moves out.
702    match result {
703        Ok(code) => code,
704        Err(report) => {
705            eprintln!("{report:?}");
706            report_exit_code(&report)
707        }
708    }
709}
710
711/// Resolve a diagnostic's exit code by walking its `code()` chain.
712/// Falls back to `EXIT_GENERIC` (1) when no `code` is set or the
713/// reported code has no entry in `aube_codes::exit::EXIT_TABLE`.
714fn report_exit_code(report: &miette::Report) -> i32 {
715    if let Some(code) = report.code() {
716        let code = code.to_string();
717        if let Some(exit) = aube_codes::exit::exit_code_for(&code) {
718            return exit;
719        }
720    }
721    aube_codes::exit::EXIT_GENERIC
722}
723
724fn inner_main() -> miette::Result<i32> {
725    let mut argv: Vec<OsString> = std::env::args_os().collect();
726    let invoked_as_aubr = argv
727        .first()
728        .is_some_and(|arg| crate::tool_shims::stem_of_argv0(arg) == "aubr");
729    if argv.get(1).and_then(|arg| arg.to_str()) == Some("__complete_word__") {
730        let name = argv
731            .first()
732            .and_then(|arg| std::path::Path::new(arg).file_stem())
733            .and_then(|name| name.to_str())
734            .and_then(|name| match name {
735                "aubr" => Some("aubr"),
736                "aubx" => Some("aubx"),
737                _ => None,
738            })
739            .unwrap_or("aube");
740        let runtime = tokio::runtime::Builder::new_current_thread()
741            .enable_all()
742            .build()
743            .into_diagnostic()
744            .wrap_err("failed to build completion runtime")?;
745        if let Some(answer) = runtime.block_on(completion_app(name).completion_request(&argv[1..]))
746        {
747            print!("{answer}");
748        }
749        return Ok(0);
750    }
751    // pnpm-compat: pull `--config.<key>[=<value>]` out of argv before
752    // clap parses it. Stripping here means the rest of the binary sees
753    // a clean argv, and the parsed pairs feed every `ResolveCtx::cli`
754    // through the process-global slot in `aube_settings`.
755    let config_overrides = extract_config_overrides(&mut argv);
756    aube_settings::set_global_cli_overrides(config_overrides);
757    // Shell activation prepends aube's shim dir so `node` / `pnpm` /
758    // `yarn` resolve to this binary. Once aube is running, scrub that
759    // directory before any runtime probe or child spawn can recursively
760    // rediscover the shim as the "real" tool.
761    tool_shims::sanitize_process_path();
762    let argv = lift_per_subcommand_flags(rewrite_multicall_argv(argv));
763    let argv: Vec<&std::ffi::OsStr> = argv.iter().map(OsString::as_os_str).collect();
764    let cli = match Cli::parse_from_argv(&argv) {
765        Ok(cli) => cli,
766        Err(usage_rs::Error::Version { .. }) => {
767            println!(
768                "{} {}",
769                aube_util::embedder().name,
770                env!("CARGO_PKG_VERSION")
771            );
772            return Ok(0);
773        }
774        Err(usage_rs::Error::Help { cmd, long }) => {
775            let runtime_spec = Cli::runtime_app().spec();
776            if let Some(page) = usage_rs::help::render(&runtime_spec, cmd, long) {
777                print!("{page}");
778            }
779            return Ok(0);
780        }
781        Err(error) => {
782            let raw_compat_error = is_allow_build_compat_error(&error);
783            let message = render_cli_error(argv.get(1..).unwrap_or_default(), &error);
784            if raw_compat_error {
785                eprintln!("{message}");
786                return Ok(aube_codes::exit::EXIT_GENERIC);
787            }
788            return Err(miette::miette!("{message}"));
789        }
790    };
791
792    // Shell-completion probes return here — ahead of every piece of startup
793    // that would either cost a TAB press real time or swallow the candidate
794    // list outright:
795    //
796    //   - `useStderr` dup2s stdout onto stderr, and `usage` reads the
797    //     child's stdout, so the completions would vanish;
798    //   - `self_version::maybe_switch` can download and re-exec a
799    //     different aube for a keypress;
800    //   - `enforce_package_manager_guardrails` hard-errors in a project
801    //     that pins a different package manager.
802    //
803    // None of those are things a completion helper should do.
804    //
805    // `-C` is honored explicitly, since the chdir that normally applies it
806    // happens further down: completing `aube -C packages/api run <TAB>`
807    // has to offer that package's scripts, not the shell's cwd's.
808    if let Some(Commands::Run(args)) = cli.command.as_ref()
809        && args.complete
810    {
811        commands::run::print_script_completions(cli.dir.as_deref());
812        return Ok(0);
813    }
814    // `--color` / `--no-color` take effect before anything else touches
815    // color state: we translate the flags into the env vars that miette,
816    // clx, `supports-color`, and spawned child processes all already
817    // consult, so the choice is consistent across every output path and
818    // inherits into `run` / `exec` / lifecycle scripts. The explicit flag
819    // wins over whatever was in the environment — that's what pnpm does.
820    //
821    // This has to happen *before* we build the Tokio runtime: the Rust
822    // 2024 contract on `std::env::set_var` requires that no other
823    // threads exist, and a multi-threaded runtime spawns its worker
824    // pool during `build()`. So we keep `main` synchronous, mutate env
825    // here, and only then enter the async body.
826    let color_mode = resolve_color_mode(&cli);
827    if matches!(color_mode, ColorMode::Never) {
828        // SAFETY: single-threaded `main` — no other threads exist yet.
829        unsafe {
830            std::env::set_var("NO_COLOR", "1");
831            std::env::remove_var("FORCE_COLOR");
832            std::env::remove_var("CLICOLOR_FORCE");
833        }
834    } else if matches!(color_mode, ColorMode::Always) {
835        // SAFETY: single-threaded `main` — no other threads exist yet.
836        unsafe {
837            std::env::set_var("FORCE_COLOR", "1");
838            std::env::set_var("CLICOLOR_FORCE", "1");
839            std::env::remove_var("NO_COLOR");
840        }
841    } else if ci_renders_ansi() && !env_disables_color() {
842        // Auto + a CI runner whose log viewer renders ANSI, and the
843        // user hasn't opted out via NO_COLOR / CLICOLOR=0: stderr isn't
844        // a TTY so console/clx would default to plain text. Flip color
845        // on for stderr only via console's per-stream override — that's
846        // the stream the install progress heartbeat writes to.
847        // Deliberately *not* setting FORCE_COLOR / CLICOLOR_FORCE:
848        // those are process-wide and would also colorize stdout (e.g.
849        // `aube view --json > out.json` baking escapes into the file)
850        // and propagate into lifecycle scripts.
851        console::set_colors_enabled_stderr(true);
852    }
853
854    // `--use-stderr` / `.npmrc` `useStderr=true`: redirect stdout to stderr
855    // so all output goes through a single fd. Resolved here (single-threaded)
856    // before the tokio runtime spawns workers.
857    //
858    // Skip when `--silent` is active: the SilentStderrGuard later redirects
859    // fd 2 to /dev/null, and if we dup2 first, fd 1 would capture the real
860    // stderr and escape silencing.
861    let is_silent = cli.silent || matches!(cli.reporter, Some(ReporterType::Silent));
862    if !is_silent {
863        let use_stderr_active = cli.use_stderr
864            || startup_cwd(&cli).ok().is_some_and(|cwd| {
865                let files = commands::FileSources::load(&cwd);
866                let ws = std::collections::BTreeMap::new();
867                let env_snap = aube_settings::values::capture_env();
868                aube_settings::resolved::use_stderr(&files.ctx(&ws, &env_snap, &[]))
869            });
870        if use_stderr_active {
871            // SAFETY: single-threaded `main` — no other threads exist yet.
872            // `dup2(stderr, stdout)` makes fd 1 point at the same file as fd 2.
873            unsafe {
874                libc::dup2(2, 1);
875            }
876        }
877    }
878
879    /*
880     * High core boxes don't need 64-128 worker threads for an I/O
881     * pipeline. Default worker_threads = num_cpus and
882     * max_blocking_threads = 512 are both wasteful. Cap workers at
883     * 8 (install semaphore already gates network).
884     *
885     * Blocking pool sits at 128, raised from 64 after diag traces
886     * showed AdaptiveLimit running 100+ concurrent tarball imports
887     * (each holding a blocking slot for gzip + tar + CAS write)
888     * while the linker is also fanning out hardlinks on the same
889     * pool. 64 was saturating, queueing late tarballs behind
890     * earlier finishers. 128 covers worst case fat tarball
891     * pipeline plus linker plus side effects.
892     *
893     * AUBE_TOKIO_WORKERS / AUBE_TOKIO_BLOCKING for benchmarking.
894     */
895    let parse_env = |key: &str, default: usize| -> usize {
896        std::env::var(key)
897            .ok()
898            .and_then(|s| s.parse::<usize>().ok())
899            .filter(|n| *n > 0)
900            .unwrap_or(default)
901    };
902    let cpu_count = std::thread::available_parallelism()
903        .map(|n| n.get())
904        .unwrap_or(4);
905    let workers = parse_env("AUBE_TOKIO_WORKERS", cpu_count.min(8));
906    let blocking = parse_env("AUBE_TOKIO_BLOCKING", 128);
907    // Every aubr invocation starts on the lightweight runtime. If its
908    // synchronous freshness probe finds that dependencies need installing,
909    // auto_install lazily creates a multi-thread runtime for that work only.
910    let current_thread_run = aubr_uses_current_thread(invoked_as_aubr, &cli);
911    let mut runtime_builder = if current_thread_run {
912        tokio::runtime::Builder::new_current_thread()
913    } else {
914        let mut builder = tokio::runtime::Builder::new_multi_thread();
915        builder.worker_threads(workers);
916        builder
917    };
918    let runtime = runtime_builder
919        .max_blocking_threads(blocking)
920        .enable_all()
921        .build()
922        .into_diagnostic()
923        .wrap_err("failed to build tokio runtime")?;
924    let exit_code = if current_thread_run {
925        runtime.block_on(commands::with_lazy_install_runtime(
926            commands::LazyInstallRuntime::new(workers, blocking),
927            async_main(cli, invoked_as_aubr),
928        ))?
929    } else {
930        runtime.block_on(async_main(cli, invoked_as_aubr))?
931    };
932    drop(runtime);
933    // Return the command's exit code rather than terminating here: a
934    // non-zero result (e.g. `run`/`exec` propagating a child's status)
935    // must travel back up to the binary's `main`, which owns the single
936    // `std::process::exit`. Exiting here would hard-kill a host that
937    // embeds the command layer in-process. `None` means "no explicit
938    // code" — the normal success exit of 0.
939    Ok(exit_code.unwrap_or(0))
940}
941
942fn aubr_uses_current_thread(invoked_as_aubr: bool, cli: &Cli) -> bool {
943    invoked_as_aubr && matches!(cli.command.as_ref(), Some(Commands::Run(_)))
944}
945
946async fn async_main(cli: Cli, invoked_as_aubr: bool) -> miette::Result<Option<i32>> {
947    // Default log level is `warn` so routine install output doesn't collide
948    // with the clx progress display. `-v` / `--verbose` and `--loglevel debug`
949    // turn on debug logging, and in that mode we also force clx into Text
950    // output so the progress UI never renders over the log lines. `--silent`
951    // (and `--loglevel silent`) turn logging off entirely and disable the
952    // progress UI.
953    // `--reporter=silent` is equivalent to `--silent`; all other reporter
954    // values leave the log level alone and only affect output routing.
955    if let Some(dir) = &cli.dir {
956        std::env::set_current_dir(dir)
957            .into_diagnostic()
958            .wrap_err_with(|| format!("failed to change directory to {}", dir.display()))?;
959    }
960
961    let print_top_level_version = should_print_top_level_version(&cli);
962    if cli.workspace_root && !print_top_level_version {
963        let start = std::env::current_dir()
964            .into_diagnostic()
965            .wrap_err("failed to read current dir")?;
966        let root = commands::find_workspace_root(&start)?;
967        if root != start {
968            std::env::set_current_dir(&root)
969                .into_diagnostic()
970                .wrap_err_with(|| format!("failed to change directory to {}", root.display()))?;
971        }
972        crate::dirs::set_cwd(&root)?;
973    }
974
975    let settings = load_startup_settings()?;
976    let effective_level = resolve_loglevel(&cli, settings.loglevel.as_deref());
977    init_logging(&cli, effective_level);
978
979    // `--silent` suppresses non-error stderr output from every command,
980    // including the ~230 direct `eprintln!` calls in command bodies. The
981    // guard restores fd 2 on drop (before main returns), so miette still
982    // prints error reports to the real stderr. We also register the
983    // saved fd with aube-scripts so child processes spawned via
984    // `aube_scripts::child_stderr()` (lifecycle scripts, `aube run`,
985    // `aube exec`, `aube dlx`) keep writing to the real terminal — only
986    // aube's own output is silenced, matching pnpm `--loglevel silent`.
987    // Install it before self-version handling, which can emit download
988    // progress even for a top-level version request.
989    let _silent_guard = matches!(effective_level, LogLevel::Silent)
990        .then(SilentStderrGuard::install)
991        .flatten();
992    if let Some(ref guard) = _silent_guard {
993        aube_scripts::set_saved_stderr_fd(guard.saved);
994    }
995
996    if print_top_level_version {
997        self_version::maybe_switch(&settings).await?;
998        println!("{}", crate::version::VERSION_LONG.as_str());
999        let cwd =
1000            crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from("."));
1001        update_check::check_and_notify(&cwd).await;
1002        return Ok(None);
1003    }
1004
1005    // Skip diag init for the `diag` subcommand itself — the analyzer
1006    // would otherwise truncate the JSONL file it's about to read.
1007    if !matches!(cli.command, Some(Commands::Diag(_))) {
1008        match diag_config_from_flag(&cli) {
1009            Some(cfg_opt) => aube_util::diag::init_with_config(cfg_opt),
1010            None => aube_util::diag::init(),
1011        }
1012    }
1013    raise_nofile_limit();
1014
1015    commands::set_skip_auto_install_on_package_manager_mismatch(false);
1016    if command_needs_package_manager_guard(cli.command.as_ref()) {
1017        // Self-version switch first: when the project pins aube and
1018        // the pinned version resolves, this re-execs and never
1019        // returns. The guard below then only sees matching (or
1020        // policy-softened) states.
1021        self_version::maybe_switch(&settings).await?;
1022        let guard = enforce_package_manager_guardrails(&settings, cli.command.as_ref())?;
1023        commands::set_skip_auto_install_on_package_manager_mismatch(
1024            guard == PackageManagerGuard::WarnRunOnly,
1025        );
1026    }
1027
1028    // `--recursive` / `-r` is sugar for `--filter=*`. When a filter is
1029    // already set, `-r` is a no-op — the explicit scope wins.
1030    let effective_filter = compute_effective_filter(&cli);
1031
1032    commands::set_global_output_flags(commands::GlobalOutputFlags {
1033        ndjson: matches!(cli.reporter, Some(ReporterType::Ndjson)),
1034        silent: matches!(effective_level, LogLevel::Silent),
1035    });
1036
1037    match cli.command {
1038        Some(Commands::NodeGypBootstrap { project_dir }) => {
1039            let binary = embed::bootstrap_node_gyp(&project_dir).await?;
1040            println!("{}", binary.display());
1041        }
1042        Some(Commands::Access(args)) => commands::access::run(args).await?,
1043        Some(Commands::Activate(args)) => commands::activate::run(args)?,
1044        Some(Commands::Add(args)) => {
1045            commands::add::run(args, effective_filter.clone()).await?;
1046        }
1047        Some(Commands::ApproveBuilds(args)) => commands::approve_builds::run(args).await?,
1048        Some(Commands::Audit(args)) => {
1049            if let Some(code) = commands::audit::run(args).await? {
1050                return Ok(Some(code));
1051            }
1052        }
1053        Some(Commands::Bin(args)) => commands::bin::run(args).await?,
1054        Some(Commands::Bugs(args)) => commands::bugs::run(args).await?,
1055        Some(Commands::Cache(args)) => commands::cache::run(args).await?,
1056        Some(Commands::CatFile(args)) => commands::cat_file::run(args).await?,
1057        Some(Commands::CatIndex(args)) => commands::cat_index::run(args).await?,
1058        Some(Commands::Check(args)) => {
1059            if let Some(code) = commands::check::run(args).await? {
1060                return Ok(Some(code));
1061            }
1062        }
1063        Some(Commands::Ci(args)) => commands::ci::run(args).await?,
1064        Some(Commands::Clean(args)) => {
1065            if let Some(code) = commands::clean::run(args).await? {
1066                return Ok(Some(code));
1067            }
1068        }
1069        Some(Commands::Completion(args)) => commands::completion::run(args).await?,
1070        Some(Commands::Config(args)) => commands::config::run(args).await?,
1071        Some(Commands::Create(args)) => {
1072            if let Some(code) = commands::create::run(args).await? {
1073                return Ok(Some(code));
1074            }
1075        }
1076        Some(Commands::Dedupe(args)) => commands::dedupe::run(args).await?,
1077        Some(Commands::Deploy(args)) => {
1078            commands::deploy::run(args, effective_filter.clone()).await?
1079        }
1080        Some(Commands::Deprecate(args)) => commands::deprecate::run(args).await?,
1081        Some(Commands::Deprecations(args)) => {
1082            if let Some(code) = commands::deprecations::run(args).await? {
1083                return Ok(Some(code));
1084            }
1085        }
1086        Some(Commands::Diag(args)) => commands::diag::run(args).await?,
1087        Some(Commands::DistTag(args)) => commands::dist_tag::run(args).await?,
1088        Some(Commands::Dlx(args)) => {
1089            if let Some(code) = commands::dlx::run(args).await? {
1090                return Ok(Some(code));
1091            }
1092        }
1093        Some(Commands::Doctor(args)) => {
1094            if let Some(code) = commands::doctor::run(args).await? {
1095                return Ok(Some(code));
1096            }
1097        }
1098        Some(Commands::Exec(args)) => {
1099            if let Some(code) = commands::exec::run(args, effective_filter.clone()).await? {
1100                return Ok(Some(code));
1101            }
1102        }
1103        Some(Commands::Fetch(args)) => commands::fetch::run(args).await?,
1104        Some(Commands::FindHash(args)) => commands::find_hash::run(args).await?,
1105        Some(Commands::Get(args)) => commands::config::get(args)?,
1106        Some(Commands::IgnoredBuilds(args)) => commands::ignored_builds::run(args).await?,
1107        Some(Commands::Import(args)) => commands::import::run(args).await?,
1108        Some(Commands::Init(args)) => commands::init::run(args).await?,
1109        Some(Commands::Install(args)) => {
1110            run_install_command(args, effective_filter.clone(), cli.workspace_root).await?;
1111        }
1112        Some(Commands::InstallTest(args)) => {
1113            if let Some(code) = commands::install_test::run(args.into_inner()).await? {
1114                return Ok(Some(code));
1115            }
1116        }
1117        Some(Commands::La(commands::list::LaArgs { mut args }))
1118        | Some(Commands::Ll(commands::list::LlArgs { mut args })) => {
1119            args.long = true;
1120            commands::list::run(args, effective_filter.clone()).await?;
1121        }
1122        Some(Commands::Licenses(args)) => commands::licenses::run(args).await?,
1123        Some(Commands::Link(args)) => commands::link::run(args).await?,
1124        Some(Commands::List(args)) => commands::list::run(args, effective_filter.clone()).await?,
1125        Some(Commands::Login(args)) => commands::login::run(args).await?,
1126        Some(Commands::Logout(args)) => commands::logout::run(args).await?,
1127        Some(Commands::Node(args)) => {
1128            if let Some(code) = commands::node::run(args).await? {
1129                return Ok(Some(code));
1130            }
1131        }
1132        Some(Commands::Outdated(args)) => {
1133            if let Some(code) = commands::outdated::run(args, effective_filter.clone()).await? {
1134                return Ok(Some(code));
1135            }
1136        }
1137        Some(Commands::Owner(args)) => {
1138            return Ok(Some(commands::npm_fallback::run("owner", &args)?));
1139        }
1140        Some(Commands::Pack(args)) => commands::pack::run(args).await?,
1141        Some(Commands::Patch(args)) => commands::patch::run(args).await?,
1142        Some(Commands::PatchCommit(args)) => commands::patch_commit::run(args).await?,
1143        Some(Commands::PatchRemove(args)) => commands::patch_remove::run(args).await?,
1144        Some(Commands::Peers(args)) => {
1145            if let Some(code) = commands::peers::run(args).await? {
1146                return Ok(Some(code));
1147            }
1148        }
1149        Some(Commands::Pkg(args)) => {
1150            return Ok(Some(commands::npm_fallback::run("pkg", &args)?));
1151        }
1152        Some(Commands::Prefix(args)) => commands::prefix::run(args).await?,
1153        Some(Commands::Prune(args)) => commands::prune::run(args).await?,
1154        #[cfg(feature = "publish")]
1155        Some(Commands::Publish(args)) => {
1156            commands::publish::run(args, effective_filter.clone()).await?
1157        }
1158        Some(Commands::Purge(args)) => {
1159            if let Some(code) = commands::clean::run_purge(args.inner).await? {
1160                return Ok(Some(code));
1161            }
1162        }
1163        Some(Commands::Query(args)) => commands::query::run(args, effective_filter.clone()).await?,
1164        Some(Commands::Rebuild(args)) => {
1165            commands::rebuild::run(args, effective_filter.clone()).await?
1166        }
1167        Some(Commands::Remove(args)) => {
1168            commands::remove::run(args, effective_filter.clone()).await?
1169        }
1170        Some(Commands::Recursive(args)) => {
1171            let argv = commands::recursive::argv(
1172                args,
1173                commands::recursive::RecursiveGlobals {
1174                    filters: effective_filter.clone(),
1175                    color: cli.color,
1176                    no_color: cli.no_color,
1177                },
1178            )?;
1179            // The reconstructed argv may carry pre-subcommand-positioned
1180            // flags that moved off `global` (e.g. `--registry`,
1181            // `--frozen-lockfile`). Run the same lift-pass we use on the
1182            // outer argv so the nested clap parse sees them after the
1183            // subcommand.
1184            let nested_argv: Vec<OsString> =
1185                lift_per_subcommand_flags(argv.into_iter().map(OsString::from).collect());
1186            let nested_refs: Vec<&std::ffi::OsStr> =
1187                nested_argv.iter().map(OsString::as_os_str).collect();
1188            let nested = Cli::parse_from_argv(&nested_refs).map_err(|error| {
1189                miette::miette!(
1190                    "{}",
1191                    usage_rs::render_failure(
1192                        Cli::spec(),
1193                        nested_refs.get(1..).unwrap_or_default(),
1194                        &error,
1195                    )
1196                )
1197            })?;
1198            let nested_filter = compute_effective_filter(&nested);
1199            match nested.command {
1200                Some(Commands::Add(args)) => {
1201                    commands::add::run(args, nested_filter).await?;
1202                }
1203                Some(Commands::Deploy(args)) => commands::deploy::run(args, nested_filter).await?,
1204                Some(Commands::Exec(args)) => {
1205                    if let Some(code) = commands::exec::run(args, nested_filter).await? {
1206                        return Ok(Some(code));
1207                    }
1208                }
1209                Some(Commands::Install(args)) => {
1210                    run_install_command(args, nested_filter, nested.workspace_root).await?;
1211                }
1212                Some(Commands::List(args)) => commands::list::run(args, nested_filter).await?,
1213                Some(Commands::La(commands::list::LaArgs { mut args }))
1214                | Some(Commands::Ll(commands::list::LlArgs { mut args })) => {
1215                    args.long = true;
1216                    commands::list::run(args, nested_filter).await?;
1217                }
1218                Some(Commands::Outdated(args)) => {
1219                    if let Some(code) = commands::outdated::run(args, nested_filter).await? {
1220                        return Ok(Some(code));
1221                    }
1222                }
1223                #[cfg(feature = "publish")]
1224                Some(Commands::Publish(args)) => {
1225                    commands::publish::run(args, nested_filter).await?
1226                }
1227                Some(Commands::Rebuild(args)) => {
1228                    commands::rebuild::run(args, nested_filter).await?
1229                }
1230                Some(Commands::Remove(args)) => commands::remove::run(args, nested_filter).await?,
1231                Some(Commands::Restart(args)) => {
1232                    if let Some(code) =
1233                        commands::restart::run(args.into_inner(), nested_filter).await?
1234                    {
1235                        return Ok(Some(code));
1236                    }
1237                }
1238                Some(Commands::Run(args)) => {
1239                    if let Some(code) = commands::run::run(args, nested_filter).await? {
1240                        return Ok(Some(code));
1241                    }
1242                }
1243                Some(Commands::Start(args)) => {
1244                    if let Some(code) =
1245                        run_script_lifecycle("start", args.into_inner(), &nested_filter).await?
1246                    {
1247                        return Ok(Some(code));
1248                    }
1249                }
1250                Some(Commands::Stop(args)) => {
1251                    if let Some(code) =
1252                        run_script_lifecycle("stop", args.into_inner(), &nested_filter).await?
1253                    {
1254                        return Ok(Some(code));
1255                    }
1256                }
1257                Some(Commands::Test(args)) => {
1258                    if let Some(code) =
1259                        run_script_lifecycle("test", args.into_inner(), &nested_filter).await?
1260                    {
1261                        return Ok(Some(code));
1262                    }
1263                }
1264                Some(Commands::Update(args)) => {
1265                    if let Some(code) = commands::update::run(args, nested_filter).await? {
1266                        return Ok(Some(code));
1267                    }
1268                }
1269                Some(Commands::Why(args)) => commands::why::run(args, nested_filter).await?,
1270                Some(Commands::External(args)) => {
1271                    let script = &args[0];
1272                    let script_args: Vec<String> = args[1..].to_vec();
1273                    if let Some(code) = commands::run::run_script(
1274                        script,
1275                        &script_args,
1276                        false,
1277                        false,
1278                        &nested_filter,
1279                    )
1280                    .await?
1281                    {
1282                        return Ok(Some(code));
1283                    }
1284                }
1285                Some(_) | None => {
1286                    return Err(miette::miette!(
1287                        code = aube_codes::errors::ERR_AUBE_RECURSIVE_NOT_SUPPORTED,
1288                        "{} recursive: command does not support recursive execution",
1289                        aube_util::embedder().name,
1290                    ));
1291                }
1292            }
1293        }
1294        Some(Commands::Restart(args)) => {
1295            if let Some(code) =
1296                commands::restart::run(args.into_inner(), effective_filter.clone()).await?
1297            {
1298                return Ok(Some(code));
1299            }
1300        }
1301        Some(Commands::Root(args)) => commands::root::run(args).await?,
1302        Some(Commands::Run(args)) => {
1303            if let Some(code) = commands::run::run_with_process_replacement(
1304                args,
1305                effective_filter.clone(),
1306                invoked_as_aubr,
1307            )
1308            .await?
1309            {
1310                return Ok(Some(code));
1311            }
1312        }
1313        Some(Commands::Runtime(args)) => commands::runtime::run(args).await?,
1314        Some(Commands::Sbom(args)) => commands::sbom::run(args).await?,
1315        Some(Commands::Search(args)) => {
1316            return Ok(Some(commands::npm_fallback::run("search", &args)?));
1317        }
1318        Some(Commands::Set(args)) => commands::config::set(args)?,
1319        Some(Commands::SetScript(args)) => {
1320            return Ok(Some(commands::npm_fallback::run("set-script", &args)?));
1321        }
1322        Some(Commands::Sponsors(args)) => commands::sponsors::run(args).await?,
1323        Some(Commands::Stage(args)) => {
1324            return Ok(Some(commands::npm_fallback::run("stage", &args)?));
1325        }
1326        Some(Commands::Start(args)) => {
1327            if let Some(code) =
1328                run_script_lifecycle("start", args.into_inner(), &effective_filter).await?
1329            {
1330                return Ok(Some(code));
1331            }
1332        }
1333        Some(Commands::Stop(args)) => {
1334            if let Some(code) =
1335                run_script_lifecycle("stop", args.into_inner(), &effective_filter).await?
1336            {
1337                return Ok(Some(code));
1338            }
1339        }
1340        Some(Commands::Store(args)) => commands::store::run(args).await?,
1341        Some(Commands::Test(args)) => {
1342            if let Some(code) =
1343                run_script_lifecycle("test", args.into_inner(), &effective_filter).await?
1344            {
1345                return Ok(Some(code));
1346            }
1347        }
1348        Some(Commands::Token(args)) => {
1349            return Ok(Some(commands::npm_fallback::run("token", &args)?));
1350        }
1351        Some(Commands::Trust(args)) => commands::trust::run(args).await?,
1352        Some(Commands::Undeprecate(args)) => commands::undeprecate::run(args).await?,
1353        Some(Commands::Unlink(args)) => commands::unlink::run(args).await?,
1354        Some(Commands::Unpublish(args)) => commands::unpublish::run(args).await?,
1355        Some(Commands::Update(args)) => {
1356            if let Some(code) = commands::update::run(args, effective_filter.clone()).await? {
1357                return Ok(Some(code));
1358            }
1359        }
1360        Some(Commands::Version(args)) => commands::version::run(args).await?,
1361        Some(Commands::View(args)) => commands::view::run(args).await?,
1362        Some(Commands::Whoami(args)) => {
1363            return Ok(Some(commands::npm_fallback::run("whoami", &args)?));
1364        }
1365        Some(Commands::Why(args)) => commands::why::run(args, effective_filter.clone()).await?,
1366        Some(Commands::External(args)) => {
1367            // Implicit run: `aube dev` = `aube run dev`.
1368            //
1369            // External is clap's catch-all, so a typo like `aube fooefjwol`
1370            // lands here too. If the name isn't an actual script in the
1371            // local `package.json` (or there's no `package.json` at all),
1372            // print `aube --help` and bail instead of routing it into the
1373            // script runner and surfacing a confusing "script not found"
1374            // or "failed to read package.json" — the user typed something
1375            // we don't recognize and help is the most useful reply.
1376            //
1377            // The pre-check only fires when *no* workspace filter is
1378            // active: `-r` / `-F` fan implicit scripts out across
1379            // sub-packages, and the script may live in one of the
1380            // matched workspaces while the root `package.json` has no
1381            // `scripts` entry at all. In that mode we hand off to
1382            // `run_script` unchanged and let the filtered runner
1383            // produce its own per-package diagnostics.
1384            let script = &args[0];
1385            let script_args: Vec<String> = args[1..].to_vec();
1386            if effective_filter.is_empty() {
1387                let initial_cwd = crate::dirs::cwd()?;
1388                let script_exists = crate::dirs::find_project_root(&initial_cwd)
1389                    .and_then(|cwd| {
1390                        aube_manifest::PackageJson::from_path(&cwd.join("package.json")).ok()
1391                    })
1392                    .map(|m| m.scripts.contains_key(script))
1393                    .unwrap_or(false);
1394                if !script_exists {
1395                    if let Some(page) = usage_rs::help::render(Cli::spec(), Cli::command(), true) {
1396                        print!("{page}");
1397                    }
1398                    eprintln!();
1399                    return Err(miette::miette!(
1400                        code = aube_codes::errors::ERR_AUBE_UNKNOWN_COMMAND,
1401                        "unknown command: {script}"
1402                    ));
1403                }
1404            }
1405            if let Some(code) =
1406                commands::run::run_script(script, &script_args, false, false, &effective_filter)
1407                    .await?
1408            {
1409                return Ok(Some(code));
1410            }
1411        }
1412        None => {
1413            // Bare `aube` prints `--help` and exits 0, matching pnpm.
1414            // pnpm's bare invocation does not run an install; users who
1415            // want that behavior should type `aube install` explicitly.
1416            if let Some(page) = usage_rs::help::render(Cli::spec(), Cli::command(), true) {
1417                print!("{page}");
1418            }
1419        }
1420    }
1421
1422    Ok(None)
1423}
1424
1425fn should_print_top_level_version(cli: &Cli) -> bool {
1426    cli.version && !matches!(cli.command, Some(Commands::Node(_)))
1427}
1428
1429/// Run a lifecycle script (`start` / `stop` / `test` / `restart`).
1430///
1431/// `ScriptArgs` carries the moved-off-global `LockfileArgs` /
1432/// `NetworkArgs` / `VirtualStoreArgs` flattens for these commands, so we
1433/// drain them into the process-global slots before delegating to the
1434/// shared `run_script` helper. Auto-install (triggered by `run_script`
1435/// when the named script doesn't exist locally) reads the slots through
1436/// `ensure_installed`.
1437async fn run_script_lifecycle(
1438    name: &str,
1439    args: commands::run::ScriptArgs,
1440    filter: &aube_workspace::selector::EffectiveFilter,
1441) -> miette::Result<Option<i32>> {
1442    args.network.install_overrides();
1443    args.lockfile.install_overrides();
1444    args.virtual_store.install_overrides();
1445    commands::run::run_script(name, &args.args, args.no_install, false, filter).await
1446}
1447
1448async fn run_install_command(
1449    args: commands::install::InstallArgs,
1450    filter: aube_workspace::selector::EffectiveFilter,
1451    workspace_root_already: bool,
1452) -> miette::Result<()> {
1453    // `-w` on install is a short alias for the global
1454    // `--workspace-root` flag. Handle the chdir here when the global
1455    // flag wasn't already set.
1456    if args.workspace_root_short && !workspace_root_already {
1457        let start = std::env::current_dir()
1458            .into_diagnostic()
1459            .wrap_err("failed to read current dir")?;
1460        let root = commands::find_workspace_root(&start)?;
1461        if root != start {
1462            std::env::set_current_dir(&root)
1463                .into_diagnostic()
1464                .wrap_err_with(|| format!("failed to change directory to {}", root.display()))?;
1465        }
1466        crate::dirs::set_cwd(&root)?;
1467    }
1468    args.network.install_overrides();
1469    args.lockfile.install_overrides();
1470    args.virtual_store.install_overrides();
1471    let global_frozen = args.lockfile.frozen_override();
1472    let global_gvs = args.virtual_store.flags();
1473    // Match `install::run`'s precedence so settings here resolve from
1474    // the same root the install will operate against. Workspace-first
1475    // means `aube install` from inside a member loads `.npmrc` /
1476    // workspace yaml from the workspace root, not the member; without
1477    // this the two diverged when both roots existed.
1478    let cwd = crate::dirs::workspace_or_project_root()?;
1479    let files = commands::FileSources::load(&cwd);
1480    let raw_ws = aube_manifest::workspace::load_raw(&cwd)
1481        .into_diagnostic()
1482        .wrap_err("failed to load workspace config")?;
1483    let env = aube_settings::values::capture_env();
1484    let cli_flags = args.to_cli_flag_bag(global_frozen, global_gvs);
1485    let ctx = files.ctx(&raw_ws, &env, &cli_flags);
1486    let yaml_prefer_frozen = aube_settings::resolved::prefer_frozen_lockfile(&ctx);
1487    let mut opts = args.into_options(global_frozen, yaml_prefer_frozen, cli_flags, env);
1488    opts.workspace_filter = filter;
1489    commands::install::run(opts).await?;
1490    Ok(())
1491}
1492
1493#[cfg(test)]
1494mod cli_spec_tests {
1495    use super::*;
1496
1497    #[test]
1498    fn every_aubr_run_uses_current_thread_runtime() {
1499        let aubr = Cli::try_parse_test_from(["aubr", "--no-install", "build"])
1500            .expect("aubr --no-install should parse");
1501        assert!(aubr_uses_current_thread(true, &aubr));
1502
1503        let installing =
1504            Cli::try_parse_test_from(["aubr", "build"]).expect("aubr script should parse");
1505        assert!(aubr_uses_current_thread(true, &installing));
1506        assert!(!aubr_uses_current_thread(false, &aubr));
1507    }
1508
1509    #[test]
1510    fn install_accepts_subcommand_registry_flag() {
1511        let cli = Cli::try_parse_test_from([
1512            "aube",
1513            "install",
1514            "--registry",
1515            "https://registry.example.com/",
1516        ])
1517        .expect("install --registry should parse");
1518
1519        let Some(Commands::Install(install_args)) = cli.command else {
1520            panic!("expected install subcommand");
1521        };
1522        assert_eq!(
1523            install_args.network.registry.as_deref(),
1524            Some("https://registry.example.com/")
1525        );
1526    }
1527
1528    #[test]
1529    fn top_level_version_keeps_the_manual_async_path() {
1530        let cli = Cli::try_parse_test_from(["aube", "--version"])
1531            .expect("the manual version flag should bind instead of exiting in the parser");
1532        assert!(cli.version);
1533    }
1534
1535    #[test]
1536    fn install_rejects_incompatible_lockfile_modes() {
1537        assert!(
1538            Cli::try_parse_test_from(["aube", "install", "--fix-lockfile", "--frozen-lockfile"])
1539                .is_err(),
1540            "cross-flatten relationships should be enforced by usage"
1541        );
1542    }
1543
1544    #[test]
1545    fn virtual_store_overrides_conflict() {
1546        for enable in ["--enable-global-virtual-store", "--enable-gvs"] {
1547            for disable in ["--disable-global-virtual-store", "--disable-gvs"] {
1548                assert!(
1549                    Cli::try_parse_test_from(["aube", "install", enable, disable]).is_err(),
1550                    "{enable} and {disable} should remain mutually exclusive"
1551                );
1552            }
1553        }
1554    }
1555
1556    #[test]
1557    fn pre_subcommand_registry_lifts_to_install() {
1558        // pnpm-compat: `--registry=URL install` continues to parse via
1559        // `lift_per_subcommand_flags`, which shifts the flag past the
1560        // subcommand before clap sees argv.
1561        let argv = lift_per_subcommand_flags(
1562            [
1563                "aube",
1564                "--registry",
1565                "https://registry.example.com/",
1566                "install",
1567            ]
1568            .into_iter()
1569            .map(OsString::from)
1570            .collect(),
1571        );
1572        let cli = Cli::try_parse_test_from(argv)
1573            .expect("pre-subcommand --registry should still parse via the rewriter");
1574        let Some(Commands::Install(install_args)) = cli.command else {
1575            panic!("expected install subcommand");
1576        };
1577        assert_eq!(
1578            install_args.network.registry.as_deref(),
1579            Some("https://registry.example.com/")
1580        );
1581    }
1582
1583    #[test]
1584    fn dlx_accepts_allow_build_before_command() {
1585        let cli =
1586            Cli::try_parse_test_from(["aube", "dlx", "--allow-build=esbuild", "vite", "--version"])
1587                .expect("dlx --allow-build should parse");
1588
1589        let Some(Commands::Dlx(dlx_args)) = cli.command else {
1590            panic!("expected dlx subcommand");
1591        };
1592        assert_eq!(dlx_args.allow_build, ["esbuild"]);
1593        assert_eq!(dlx_args.params, ["vite", "--version"]);
1594    }
1595
1596    #[test]
1597    fn dlx_rejects_empty_allow_build_value() {
1598        let err = match Cli::try_parse_test_from(["aube", "dlx", "--allow-build=", "vite"]) {
1599            Ok(_) => panic!("empty --allow-build should fail"),
1600            Err(err) => err,
1601        };
1602        assert!(
1603            err.to_string()
1604                .contains("The --allow-build flag is missing a package name"),
1605            "{err}"
1606        );
1607    }
1608
1609    #[test]
1610    fn add_accepts_dangerously_allow_all_builds_after_package() {
1611        let cli = Cli::try_parse_test_from([
1612            "aube",
1613            "add",
1614            "--global",
1615            "opencode-ai@1.17.13",
1616            "--dangerously-allow-all-builds",
1617        ])
1618        .expect("add --dangerously-allow-all-builds should parse after a package");
1619
1620        let Some(Commands::Add(add_args)) = cli.command else {
1621            panic!("expected add subcommand");
1622        };
1623        assert!(add_args.global);
1624        assert!(add_args.dangerously_allow_all_builds);
1625        assert_eq!(add_args.packages, ["opencode-ai@1.17.13"]);
1626    }
1627
1628    #[test]
1629    fn add_rejects_deny_build_with_dangerously_allow_all_builds() {
1630        let err = match Cli::try_parse_test_from([
1631            "aube",
1632            "add",
1633            "some-package",
1634            "--deny-build=some-package",
1635            "--dangerously-allow-all-builds",
1636        ]) {
1637            Ok(_) => panic!("deny-build should conflict with dangerously-allow-all-builds"),
1638            Err(err) => err,
1639        };
1640
1641        assert!(
1642            err.to_string()
1643                .contains("'--deny-build' cannot be used with '--dangerously-allow-all-builds'"),
1644            "{err}"
1645        );
1646    }
1647
1648    #[test]
1649    fn lifter_does_not_eat_lifted_flag_as_kept_flag_value() {
1650        // Regression: `aube --dir /tmp --frozen-lockfile install` would
1651        // previously lose `--frozen-lockfile` if `--dir`'s value was
1652        // omitted because the rewriter unconditionally consumed the next
1653        // token as the kept flag's value.
1654        let argv = lift_per_subcommand_flags(
1655            ["aube", "--dir", "--frozen-lockfile", "install"]
1656                .into_iter()
1657                .map(OsString::from)
1658                .collect(),
1659        );
1660        // After the lift, `--frozen-lockfile` should sit after `install`,
1661        // NOT have been consumed as `--dir`'s value.
1662        let strs: Vec<&str> = argv.iter().filter_map(|t| t.to_str()).collect();
1663        let install_idx = strs
1664            .iter()
1665            .position(|s| *s == "install")
1666            .expect("install subcommand should survive the lift");
1667        assert!(
1668            strs[install_idx + 1..].contains(&"--frozen-lockfile"),
1669            "--frozen-lockfile should land after the subcommand: {strs:?}"
1670        );
1671    }
1672
1673    #[test]
1674    fn short_command_aliases_parse() {
1675        let cli = Cli::try_parse_test_from(["aube", "a", "react"]).expect("a should parse as add");
1676        assert!(matches!(cli.command, Some(Commands::Add(_))));
1677
1678        let cli = Cli::try_parse_test_from(["aube", "x", "vitest", "--run"])
1679            .expect("x should parse as exec");
1680        let Some(Commands::Exec(args)) = cli.command else {
1681            panic!("x should dispatch to exec");
1682        };
1683        assert_eq!(args.bin, "vitest");
1684        assert_eq!(args.args, vec!["--run"]);
1685
1686        let cli = Cli::try_parse_test_from(["aube", "w", "react"]).expect("w should parse as why");
1687        assert!(matches!(cli.command, Some(Commands::Why(_))));
1688    }
1689
1690    #[test]
1691    fn bin_accepts_workspace_root_flag() {
1692        // pnpm parity: `aube bin -w` / `--workspace-root` prints the
1693        // workspace-root bin dir. See discussion #988.
1694        for flag in ["-w", "--workspace-root", "--workspace"] {
1695            let cli = Cli::try_parse_test_from(["aube", "bin", flag])
1696                .unwrap_or_else(|e| panic!("`aube bin {flag}` should parse: {e}"));
1697            let Some(Commands::Bin(args)) = cli.command else {
1698                panic!("`aube bin {flag}` should dispatch to bin");
1699            };
1700            assert!(args.workspace_root, "{flag} should set workspace_root");
1701            assert!(!args.global);
1702        }
1703    }
1704
1705    #[test]
1706    fn bin_global_conflicts_with_workspace_root() {
1707        assert!(
1708            Cli::try_parse_test_from(["aube", "bin", "-g", "-w"]).is_err(),
1709            "`aube bin -g -w` should be rejected as conflicting"
1710        );
1711    }
1712
1713    #[test]
1714    fn node_subcommand_forwards_version_flag() {
1715        let cli = Cli::try_parse_test_from(rewrite_multicall_argv(vec![
1716            OsString::from("aube"),
1717            OsString::from("node"),
1718            OsString::from("--version"),
1719        ]))
1720        .expect("node --version parses");
1721        assert!(!should_print_top_level_version(&cli));
1722        let Some(Commands::Node(args)) = cli.command else {
1723            panic!("expected node subcommand");
1724        };
1725        assert_eq!(args.args, vec![OsString::from("--version")]);
1726    }
1727
1728    #[test]
1729    fn node_subcommand_forwards_help_flag() {
1730        let cli = Cli::try_parse_test_from(rewrite_multicall_argv(vec![
1731            OsString::from("aube"),
1732            OsString::from("node"),
1733            OsString::from("--help"),
1734        ]))
1735        .expect("node --help parses");
1736        let Some(Commands::Node(args)) = cli.command else {
1737            panic!("expected node subcommand");
1738        };
1739        assert_eq!(args.args, vec![OsString::from("--help")]);
1740    }
1741}
1742
1743#[cfg(test)]
1744mod multicall_tests {
1745    use super::*;
1746
1747    fn os(strs: &[&str]) -> Vec<OsString> {
1748        strs.iter().map(OsString::from).collect()
1749    }
1750
1751    fn temp_shim(name: &str) -> tempfile::TempDir {
1752        let dir = tempfile::tempdir().expect("temp dir should be created");
1753        std::fs::write(dir.path().join(name), "#!/tmp/aube.exe\n").expect("shim should be written");
1754        dir
1755    }
1756
1757    #[test]
1758    fn aube_passes_through_unchanged() {
1759        assert_eq!(
1760            rewrite_multicall_argv(os(&["aube", "install"])),
1761            os(&["aube", "install"])
1762        );
1763    }
1764
1765    #[test]
1766    fn aubr_is_left_for_the_executable_view() {
1767        assert_eq!(
1768            rewrite_multicall_argv(os(&["aubr", "build"])),
1769            os(&["aubr", "build"])
1770        );
1771    }
1772
1773    #[test]
1774    fn aubx_is_left_for_the_executable_view() {
1775        assert_eq!(
1776            rewrite_multicall_argv(os(&["aubx", "cowsay", "hi"])),
1777            os(&["aubx", "cowsay", "hi"])
1778        );
1779    }
1780
1781    #[test]
1782    fn executable_views_dispatch_without_argv_rewriting() {
1783        let cli = Cli::try_parse_test_from(["aubr", "build"])
1784            .expect("aubr should promote the run command");
1785        let Some(Commands::Run(run)) = cli.command else {
1786            panic!("expected run");
1787        };
1788        assert_eq!(run.script.as_deref(), Some("build"));
1789
1790        let raw = os(&["aubx", "cowsay"]);
1791        let argv: Vec<&std::ffi::OsStr> = raw.iter().map(OsString::as_os_str).collect();
1792        let cli = Cli::parse_from_argv(&argv).expect("aubx should promote the dlx command");
1793        assert!(matches!(cli.command, Some(Commands::Dlx(_))));
1794
1795        let raw = os(&["aubr", "--version"]);
1796        let argv: Vec<&std::ffi::OsStr> = raw.iter().map(OsString::as_os_str).collect();
1797        let cli = Cli::parse_from_argv(&argv).expect("the global manual version flag should bind");
1798        assert!(cli.version);
1799        assert!(matches!(cli.command, Some(Commands::Run(_))));
1800    }
1801
1802    #[test]
1803    fn executable_view_flags_are_not_lifted_past_forwarded_positionals() {
1804        let argv = os(&["aubr", "--registry", "https://registry.test", "build"]);
1805        assert_eq!(lift_per_subcommand_flags(argv.clone()), argv);
1806
1807        let argv = os(&["aubx", "--registry", "https://registry.test", "vite"]);
1808        assert_eq!(lift_per_subcommand_flags(argv.clone()), argv);
1809    }
1810
1811    #[test]
1812    fn node_shim_rewrites_to_node_subcommand() {
1813        assert_eq!(
1814            rewrite_multicall_argv(os(&["node", "--version"])),
1815            os(&["aube", "node", "--", "--version"])
1816        );
1817        assert_eq!(
1818            rewrite_multicall_argv(os(&["aube", "node", "--version"])),
1819            os(&["aube", "node", "--", "--version"])
1820        );
1821        assert_eq!(
1822            rewrite_multicall_argv(os(&["aube", "__aube-shim", "node", "--version"])),
1823            os(&["aube", "node", "--", "--version"])
1824        );
1825    }
1826
1827    #[test]
1828    fn package_manager_version_flags_rewrite_to_aube_version() {
1829        assert_eq!(
1830            rewrite_multicall_argv(os(&["pnpm", "--version"])),
1831            os(&["aube", "--version"])
1832        );
1833        assert_eq!(
1834            rewrite_multicall_argv(os(&["npm", "-v"])),
1835            os(&["aube", "--version"])
1836        );
1837        assert_eq!(
1838            rewrite_multicall_argv(os(&["npx", "-v"])),
1839            os(&["aube", "--version"])
1840        );
1841        assert_eq!(
1842            rewrite_multicall_argv(os(&["pnpx", "--version"])),
1843            os(&["aube", "--version"])
1844        );
1845    }
1846
1847    #[test]
1848    fn pnpm_and_pnpx_shims_rewrite_to_aube_surfaces() {
1849        assert_eq!(
1850            rewrite_multicall_argv(os(&["pnpm", "install", "--frozen-lockfile"])),
1851            os(&["aube", "install", "--frozen-lockfile"])
1852        );
1853        assert_eq!(
1854            rewrite_multicall_argv(os(&["pnpx", "cowsay", "hi"])),
1855            os(&["aube", "dlx", "cowsay", "hi"])
1856        );
1857        assert_eq!(
1858            rewrite_multicall_argv(os(&[
1859                "aube",
1860                "__aube-shim",
1861                "pnpm",
1862                "install",
1863                "--frozen-lockfile",
1864            ])),
1865            os(&["aube", "install", "--frozen-lockfile"])
1866        );
1867    }
1868
1869    #[test]
1870    fn dispatcher_marker_rejects_unknown_tool_names() {
1871        assert_eq!(
1872            rewrite_multicall_argv(os(&["aube", "__aube-shim", "shell", "arg"])),
1873            os(&["aube", "__aube-shim", "shell", "arg"])
1874        );
1875    }
1876
1877    #[test]
1878    fn npm_install_rewrites_to_install_or_add() {
1879        assert_eq!(
1880            rewrite_multicall_argv(os(&["npm", "install"])),
1881            os(&["aube", "install"])
1882        );
1883        assert_eq!(
1884            rewrite_multicall_argv(os(&["npm", "i", "-D", "vitest"])),
1885            os(&["aube", "add", "-D", "vitest"])
1886        );
1887        assert_eq!(
1888            rewrite_multicall_argv(os(&["npm", "install", "--registry", "https://r.test"])),
1889            os(&["aube", "install", "--registry", "https://r.test"])
1890        );
1891        assert_eq!(
1892            rewrite_multicall_argv(os(&["npm", "install", "--workspace", "app"])),
1893            os(&["aube", "install", "--workspace", "app"])
1894        );
1895        assert_eq!(
1896            rewrite_multicall_argv(os(&["npm", "install", "--"])),
1897            os(&["aube", "install", "--"])
1898        );
1899        assert_eq!(
1900            rewrite_multicall_argv(os(&["npm", "install", "--", "vitest"])),
1901            os(&["aube", "add", "--", "vitest"])
1902        );
1903    }
1904
1905    #[test]
1906    fn npm_common_commands_rewrite_to_aube() {
1907        assert_eq!(
1908            rewrite_multicall_argv(os(&["npm", "ci"])),
1909            os(&["aube", "ci"])
1910        );
1911        assert_eq!(
1912            rewrite_multicall_argv(os(&["npm", "run", "build"])),
1913            os(&["aube", "run", "build"])
1914        );
1915        assert_eq!(
1916            rewrite_multicall_argv(os(&["npm", "rm", "react"])),
1917            os(&["aube", "remove", "react"])
1918        );
1919        assert_eq!(
1920            rewrite_multicall_argv(os(&["npx", "vite", "--version"])),
1921            os(&["aube", "dlx", "vite", "--version"])
1922        );
1923    }
1924
1925    #[test]
1926    fn yarn_common_commands_rewrite_to_aube() {
1927        assert_eq!(
1928            rewrite_multicall_argv(os(&["yarn"])),
1929            os(&["aube", "install"])
1930        );
1931        assert_eq!(
1932            rewrite_multicall_argv(os(&["yarn", "add", "react"])),
1933            os(&["aube", "add", "react"])
1934        );
1935        assert_eq!(
1936            rewrite_multicall_argv(os(&["yarnpkg", "remove", "react"])),
1937            os(&["aube", "remove", "react"])
1938        );
1939    }
1940
1941    #[test]
1942    fn absolute_path_and_exe_suffix_are_handled() {
1943        // Executable views consume argv0 themselves, including paths and Windows suffixes.
1944        assert_eq!(
1945            rewrite_multicall_argv(os(&["/usr/local/bin/aubr", "test"])),
1946            os(&["/usr/local/bin/aubr", "test"])
1947        );
1948        assert_eq!(
1949            rewrite_multicall_argv(os(&["aubx.exe", "pkg"])),
1950            os(&["aubx.exe", "pkg"])
1951        );
1952    }
1953
1954    #[test]
1955    fn bare_shim_invocation_passes_through_to_subcommand() {
1956        assert_eq!(rewrite_multicall_argv(os(&["aubr"])), os(&["aubr"]));
1957    }
1958
1959    #[test]
1960    fn version_flag_short_circuits_to_top_level() {
1961        // The executable view parser recognizes root version requests without argv surgery.
1962        assert_eq!(
1963            rewrite_multicall_argv(os(&["aubr", "--version"])),
1964            os(&["aubr", "--version"])
1965        );
1966        assert_eq!(
1967            rewrite_multicall_argv(os(&["aubx", "--version"])),
1968            os(&["aubx", "--version"])
1969        );
1970        assert_eq!(
1971            rewrite_multicall_argv(os(&["aubr", "-V"])),
1972            os(&["aubr", "-V"])
1973        );
1974        assert_eq!(
1975            rewrite_multicall_argv(os(&["aubx.exe", "-V"])),
1976            os(&["aubx.exe", "-V"])
1977        );
1978    }
1979
1980    #[test]
1981    fn npm_interpreter_shim_path_is_dropped() {
1982        let dir = temp_shim("aube");
1983        let shim = dir.path().join("aube");
1984        let shim_os = shim.clone().into_os_string();
1985        assert_eq!(
1986            rewrite_multicall_argv(vec![
1987                OsString::from("aube.exe"),
1988                shim.into_os_string(),
1989                OsString::from("--version"),
1990            ]),
1991            vec![shim_os, OsString::from("--version")]
1992        );
1993    }
1994
1995    #[test]
1996    fn npm_interpreter_shim_preserves_multicall_dispatch() {
1997        let dir = temp_shim("aubr");
1998        let shim = dir.path().join("aubr");
1999        assert_eq!(
2000            rewrite_multicall_argv(vec![
2001                OsString::from("aubr.exe"),
2002                shim.clone().into_os_string(),
2003                OsString::from("build"),
2004            ]),
2005            vec![shim.into_os_string(), OsString::from("build")]
2006        );
2007    }
2008
2009    #[test]
2010    fn extract_config_overrides_strips_equals_form() {
2011        let mut argv = os(&["aube", "install", "--config.strict-dep-builds=true"]);
2012        let parsed = extract_config_overrides(&mut argv);
2013        assert_eq!(argv, os(&["aube", "install"]));
2014        assert_eq!(
2015            parsed,
2016            vec![("strict-dep-builds".to_string(), "true".to_string())]
2017        );
2018    }
2019
2020    #[test]
2021    fn extract_config_overrides_strips_bool_form() {
2022        let mut argv = os(&["aube", "--config.strictDepBuilds", "install"]);
2023        let parsed = extract_config_overrides(&mut argv);
2024        assert_eq!(argv, os(&["aube", "install"]));
2025        assert_eq!(
2026            parsed,
2027            vec![("strictDepBuilds".to_string(), "true".to_string())]
2028        );
2029    }
2030
2031    #[test]
2032    fn extract_config_overrides_handles_multiple_and_preserves_order() {
2033        let mut argv = os(&[
2034            "aube",
2035            "--config.foo=1",
2036            "install",
2037            "--config.bar=two",
2038            "--config.foo=3",
2039        ]);
2040        let parsed = extract_config_overrides(&mut argv);
2041        assert_eq!(argv, os(&["aube", "install"]));
2042        assert_eq!(
2043            parsed,
2044            vec![
2045                ("foo".to_string(), "1".to_string()),
2046                ("bar".to_string(), "two".to_string()),
2047                ("foo".to_string(), "3".to_string()),
2048            ]
2049        );
2050    }
2051
2052    #[test]
2053    fn extract_config_overrides_stops_at_double_dash() {
2054        let mut argv = os(&["aube", "exec", "--", "node", "--config.foo=should-stay"]);
2055        let parsed = extract_config_overrides(&mut argv);
2056        assert!(parsed.is_empty());
2057        assert_eq!(
2058            argv,
2059            os(&["aube", "exec", "--", "node", "--config.foo=should-stay"])
2060        );
2061    }
2062
2063    #[test]
2064    fn extract_config_overrides_preserves_argv_when_absent() {
2065        let mut argv = os(&["aube", "install", "--frozen-lockfile"]);
2066        let parsed = extract_config_overrides(&mut argv);
2067        assert!(parsed.is_empty());
2068        assert_eq!(argv, os(&["aube", "install", "--frozen-lockfile"]));
2069    }
2070}
2071
2072#[cfg(test)]
2073mod package_manager_guard_tests {
2074    use super::*;
2075
2076    #[test]
2077    fn run_like_commands_warn_instead_of_erroring() {
2078        let run = Cli::try_parse_test_from(["aube", "run", "test"]).expect("run should parse");
2079        let test = Cli::try_parse_test_from(["aube", "test"]).expect("test should parse");
2080
2081        assert_eq!(
2082            package_manager_guard_mode(run.command.as_ref()),
2083            PackageManagerGuardMode::WarnAndSkipAutoInstall
2084        );
2085        assert_eq!(
2086            package_manager_guard_mode(test.command.as_ref()),
2087            PackageManagerGuardMode::WarnAndSkipAutoInstall
2088        );
2089    }
2090
2091    #[test]
2092    fn install_still_errors_on_mismatch() {
2093        let cli = Cli::try_parse_test_from(["aube", "install"]).expect("install should parse");
2094        assert_eq!(
2095            package_manager_guard_mode(cli.command.as_ref()),
2096            PackageManagerGuardMode::Error
2097        );
2098    }
2099
2100    #[test]
2101    fn install_test_still_errors_on_mismatch() {
2102        let cli =
2103            Cli::try_parse_test_from(["aube", "install-test"]).expect("install-test should parse");
2104        assert_eq!(
2105            package_manager_guard_mode(cli.command.as_ref()),
2106            PackageManagerGuardMode::Error
2107        );
2108    }
2109
2110    #[test]
2111    fn prefix_skips_package_manager_guard() {
2112        let cli = Cli::try_parse_test_from(["aube", "prefix"]).expect("prefix should parse");
2113        assert!(!command_needs_package_manager_guard(cli.command.as_ref()));
2114    }
2115
2116    #[test]
2117    fn package_manager_strict_mode_parses_canonical_spellings() {
2118        for (input, expected) in [
2119            ("off", PackageManagerStrictMode::Off),
2120            ("warn", PackageManagerStrictMode::Warn),
2121            ("error", PackageManagerStrictMode::Error),
2122            ("  ERROR\n", PackageManagerStrictMode::Error),
2123        ] {
2124            assert_eq!(PackageManagerStrictMode::parse(input), Some(expected));
2125        }
2126    }
2127
2128    #[test]
2129    fn package_manager_strict_mode_parses_bool_back_compat() {
2130        // `true`/`false` (and the shell-style `1`/`0` admitted by the
2131        // generic bool parser) need to keep working so projects on the
2132        // pre-tri-state default don't break.
2133        for (input, expected) in [
2134            ("true", PackageManagerStrictMode::Error),
2135            ("false", PackageManagerStrictMode::Off),
2136            ("1", PackageManagerStrictMode::Error),
2137            ("0", PackageManagerStrictMode::Off),
2138        ] {
2139            assert_eq!(PackageManagerStrictMode::parse(input), Some(expected));
2140        }
2141    }
2142
2143    #[test]
2144    fn package_manager_strict_mode_returns_none_for_typos() {
2145        // Caller turns `None` into a startup warning + default. The
2146        // unit test pins the precondition: parse must NOT silently
2147        // coerce a typo to the default.
2148        assert!(PackageManagerStrictMode::parse("errror").is_none());
2149        assert!(PackageManagerStrictMode::parse("warning").is_none());
2150        assert!(PackageManagerStrictMode::parse("").is_none());
2151    }
2152}
2153
2154#[cfg(test)]
2155mod cli_ordering_tests {
2156    use super::*;
2157    use std::collections::BTreeMap;
2158
2159    /// Validate that aube's CLI commands and arguments are ordered:
2160    /// - Subcommands alphabetical by name
2161    /// - Short flags alphabetical by short option
2162    /// - Long-only flags alphabetical by long name *within each help-heading
2163    ///   bucket* (the unheaded default counts as one bucket)
2164    ///
2165    /// We can't use `clap_sort::assert_sorted` directly because flags from
2166    /// flattened `cli_args::*Args` groups carry their own `help_heading`
2167    /// (e.g. "Lockfile", "Network", "Virtual store") and clap-sort enforces
2168    /// strict alphabetical across the full long-only set, which would
2169    /// require interleaving group flags between per-command flags. The
2170    /// help-grouped layout is the whole point of the move, so we sort
2171    /// within heading buckets instead.
2172    #[test]
2173    fn test_cli_ordering() {
2174        check_command_sorted(Cli::spec().root, &[]);
2175    }
2176
2177    fn check_command_sorted(cmd: &usage_rs::spec::CommandMeta<'_>, path: &[&str]) {
2178        let mut current_path: Vec<&str> = path.to_vec();
2179        current_path.push(cmd.cmd.name);
2180
2181        // Subcommands alphabetical
2182        let names: Vec<_> = cmd.subcommands.iter().map(|sub| sub.cmd.name).collect();
2183        let mut sorted = names.clone();
2184        sorted.sort();
2185        assert!(
2186            names == sorted,
2187            "Subcommands in '{}' are not sorted alphabetically!\nActual: {:?}\nExpected: {:?}",
2188            current_path.join(" "),
2189            names,
2190            sorted,
2191        );
2192
2193        // Short flags alphabetical, long-only alphabetical within heading.
2194        let mut shorts: Vec<u8> = Vec::new();
2195        let mut by_heading: BTreeMap<Option<&str>, Vec<&str>> = BTreeMap::new();
2196        for flag in cmd.flags {
2197            if let Some(&short) = flag.flag.shorts.first() {
2198                shorts.push(short);
2199            } else if let Some(&long) = flag.flag.longs.first() {
2200                by_heading.entry(flag.help_heading).or_default().push(long);
2201            }
2202        }
2203        let mut sorted_shorts = shorts.clone();
2204        sorted_shorts.sort_by_key(|c| (c.to_ascii_lowercase(), c.is_ascii_uppercase()));
2205        assert!(
2206            shorts == sorted_shorts,
2207            "Short flags in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
2208            current_path.join(" "),
2209            shorts,
2210            sorted_shorts,
2211        );
2212        for (heading, longs) in &by_heading {
2213            let mut sorted_longs = longs.clone();
2214            sorted_longs.sort();
2215            assert!(
2216                longs == &sorted_longs,
2217                "Long-only flags under heading {:?} in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
2218                heading,
2219                current_path.join(" "),
2220                longs,
2221                sorted_longs,
2222            );
2223        }
2224        for sub in cmd.subcommands {
2225            check_command_sorted(sub, &current_path);
2226        }
2227    }
2228}