From 05a85380092465be785f3fcb21fbea1ffe399d95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 15:52:41 -0700 Subject: [PATCH] refactor(commands): return exit codes from command run() instead of process::exit The embeddable command layer (aube::commands::*::run and the script/bin helpers they share) called std::process::exit directly on a failing command or a non-zero child status. When a host embeds aube as a library and drives those run() fns in-process, those exits hard-kill the host's whole process. Thread a propagatable exit code up through the command layer instead: the converted run() / helper signatures return miette::Result> (Some(code) = "exit with this code", None = success). The dispatcher in async_main maps Some(code) to the existing Result> return, and the binary's main.rs keeps the single std::process::exit. Standalone exit codes are unchanged byte-for-byte. Converted command-level sites: run/exec (parallel-collector + leaf child-exit propagation), peers, doctor, check, dlx, create, outdated, audit (degraded=2, fix-remaining=1, advisories=1), plus the run_script / exec_bin / exec_script shared helpers and their callers (clean, restart, install_test, lifecycle, recursive, External). SIGINT exit(130) in the interactive pickers (run prompt, audit/update selectors) and the main.rs re-exec are left as genuinely-terminal paths. Add e2e tests pinning the standalone exit-code contract: run propagates a failing script's exact code, --if-present on a missing script exits 0, and a failing pre-script short-circuits with its code before the main script runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/aube/src/commands/audit.rs | 26 +++-- crates/aube/src/commands/check.rs | 9 +- crates/aube/src/commands/clean.rs | 8 +- crates/aube/src/commands/create.rs | 4 +- crates/aube/src/commands/dlx.rs | 11 +- crates/aube/src/commands/doctor.rs | 9 +- crates/aube/src/commands/exec.rs | 34 ++++-- crates/aube/src/commands/install_test.rs | 2 +- crates/aube/src/commands/outdated.rs | 36 +++--- crates/aube/src/commands/peers.rs | 11 +- crates/aube/src/commands/restart.rs | 23 +++- crates/aube/src/commands/run.rs | 110 +++++++++++------- crates/aube/src/lib.rs | 141 ++++++++++++++++++----- crates/aube/tests/e2e.rs | 69 +++++++++++ 14 files changed, 364 insertions(+), 129 deletions(-) diff --git a/crates/aube/src/commands/audit.rs b/crates/aube/src/commands/audit.rs index 2d742ced9..f2f8c190c 100644 --- a/crates/aube/src/commands/audit.rs +++ b/crates/aube/src/commands/audit.rs @@ -137,7 +137,7 @@ pub enum FixMode { Override, } -pub async fn run(args: AuditArgs) -> miette::Result<()> { +pub async fn run(args: AuditArgs) -> miette::Result> { args.network.install_overrides(); let cwd = crate::dirs::project_root()?; @@ -172,7 +172,7 @@ pub async fn run(args: AuditArgs) -> miette::Result<()> { } else { println!("No dependencies to audit."); } - return Ok(()); + return Ok(None); } let client = build_client(&cwd, args.network.registry.as_deref()); @@ -209,7 +209,10 @@ pub async fn run(args: AuditArgs) -> miette::Result<()> { "audit degraded: advisory fetch failed, vulnerability status unknown" ); } - std::process::exit(2); + // Degraded status exits 2. Return the code for the binary's + // single `std::process::exit` rather than terminating here, + // keeping the command embed-safe. + return Ok(Some(2)); } return Err(miette!("advisory fetch failed: {e}")); } @@ -263,14 +266,17 @@ pub async fn run(args: AuditArgs) -> miette::Result<()> { ) .await?; if remaining.is_empty() { - return Ok(()); + return Ok(None); } render_fix_remaining(&selected, &remaining); - std::process::exit(1); + // Some advisories remain unfixed: exit 1. Return the code for + // the binary's single `std::process::exit` rather than + // terminating here, keeping the command embed-safe. + return Ok(Some(1)); } FixMode::Override => { write_fix_overrides(&cwd, &selected, &client).await?; - return Ok(()); + return Ok(None); } } } @@ -286,10 +292,12 @@ pub async fn run(args: AuditArgs) -> miette::Result<()> { } if rows.is_empty() { - Ok(()) + Ok(None) } else { - // pnpm-compat: exit 1 when any advisory matches the threshold. - std::process::exit(1); + // pnpm-compat: exit 1 when any advisory matches the threshold. Return + // the code for the binary's single `std::process::exit` rather than + // terminating here, keeping the command embed-safe. + Ok(Some(1)) } } diff --git a/crates/aube/src/commands/check.rs b/crates/aube/src/commands/check.rs index 9461dd1ed..14c42b352 100644 --- a/crates/aube/src/commands/check.rs +++ b/crates/aube/src/commands/check.rs @@ -48,7 +48,7 @@ pub struct CheckArgs { pub json: bool, } -pub async fn run(args: CheckArgs) -> miette::Result<()> { +pub async fn run(args: CheckArgs) -> miette::Result> { // `project_root_or_cwd` falls back to the current directory when // nothing above it has a `package.json`. `run_report` is already a // no-op when `node_modules/.aube/` doesn't exist, so running @@ -65,9 +65,12 @@ pub async fn run(args: CheckArgs) -> miette::Result<()> { } if !report.issues.is_empty() { - std::process::exit(1); + // Exit 1 when the store check found broken links. Return the code + // for the binary's single `std::process::exit` rather than + // terminating here, keeping the command embed-safe. + return Ok(Some(1)); } - Ok(()) + Ok(None) } /// Result of scanning the virtual store. diff --git a/crates/aube/src/commands/clean.rs b/crates/aube/src/commands/clean.rs index 71f79479f..c962d2d4d 100644 --- a/crates/aube/src/commands/clean.rs +++ b/crates/aube/src/commands/clean.rs @@ -44,15 +44,15 @@ fn lockfile_names() -> [&'static str; 6] { ] } -pub async fn run(args: CleanArgs) -> miette::Result<()> { +pub async fn run(args: CleanArgs) -> miette::Result> { run_as("clean", args).await } -pub async fn run_purge(args: CleanArgs) -> miette::Result<()> { +pub async fn run_purge(args: CleanArgs) -> miette::Result> { run_as("purge", args).await } -async fn run_as(invoked_as: &str, args: CleanArgs) -> miette::Result<()> { +async fn run_as(invoked_as: &str, args: CleanArgs) -> miette::Result> { let cwd = crate::dirs::project_root()?; let _lock = super::take_project_lock(&cwd)?; @@ -154,5 +154,5 @@ async fn run_as(invoked_as: &str, args: CleanArgs) -> miette::Result<()> { } } - Ok(()) + Ok(None) } diff --git a/crates/aube/src/commands/create.rs b/crates/aube/src/commands/create.rs index 488a55b7d..00fcff200 100644 --- a/crates/aube/src/commands/create.rs +++ b/crates/aube/src/commands/create.rs @@ -32,7 +32,7 @@ pub struct CreateArgs { /// Scaffold a project from a `create-*` starter kit. Matches pnpm/npm /// semantics: `aube create foo` runs the `create-foo` package via dlx, /// `aube create @scope/foo` runs `@scope/create-foo`, etc. -pub async fn run(args: CreateArgs) -> miette::Result<()> { +pub async fn run(args: CreateArgs) -> miette::Result> { args.network.install_overrides(); let CreateArgs { params, network } = args; @@ -47,7 +47,7 @@ pub async fn run(args: CreateArgs) -> miette::Result<()> { .print_help() .map_err(|e| miette!("failed to render help: {e}"))?; println!(); - return Ok(()); + return Ok(None); } let (template, rest) = params.split_first().expect("checked non-empty above"); diff --git a/crates/aube/src/commands/dlx.rs b/crates/aube/src/commands/dlx.rs index e1f233c07..820e59f39 100644 --- a/crates/aube/src/commands/dlx.rs +++ b/crates/aube/src/commands/dlx.rs @@ -79,7 +79,7 @@ pub struct DlxArgs { /// an already-removed scratch dir. /// 3. Exec `/node_modules/.bin/` from the user's original cwd. /// 4. tempfile removes the scratch dir on drop. -pub async fn run(args: DlxArgs) -> miette::Result<()> { +pub async fn run(args: DlxArgs) -> miette::Result> { args.network.install_overrides(); args.lockfile.install_overrides(); args.virtual_store.install_overrides(); @@ -104,7 +104,7 @@ pub async fn run(args: DlxArgs) -> miette::Result<()> { .print_help() .map_err(|e| miette!("failed to render help: {e}"))?; println!(); - return Ok(()); + return Ok(None); } // When only `-p` is given, dlx needs at least one arg to serve as the @@ -285,9 +285,12 @@ pub async fn run(args: DlxArgs) -> miette::Result<()> { drop(tmp); if !status.success() { - std::process::exit(aube_scripts::exit_code_from_status(status)); + // Propagate the dlx binary's non-zero exit up to the binary's single + // `std::process::exit` rather than terminating here, keeping the + // command embed-safe. The scratch project (`tmp`) is already dropped. + return Ok(Some(aube_scripts::exit_code_from_status(status))); } - Ok(()) + Ok(None) } fn dlx_manifest(install_specs: &[String], allow_build: &[String]) -> serde_json::Value { diff --git a/crates/aube/src/commands/doctor.rs b/crates/aube/src/commands/doctor.rs index baa575582..e9bf21219 100644 --- a/crates/aube/src/commands/doctor.rs +++ b/crates/aube/src/commands/doctor.rs @@ -41,7 +41,7 @@ pub struct DoctorArgs { pub json: bool, } -pub async fn run(args: DoctorArgs) -> miette::Result<()> { +pub async fn run(args: DoctorArgs) -> miette::Result> { let cwd = crate::dirs::cwd()?; let project_root = crate::dirs::find_project_root(&cwd); let anchor = project_root.clone().unwrap_or_else(|| cwd.clone()); @@ -67,9 +67,12 @@ pub async fn run(args: DoctorArgs) -> miette::Result<()> { crate::update_check::check_and_notify(&anchor).await; if !report.errors.is_empty() { - std::process::exit(1); + // Exit 1 when the doctor found errors. Return the code for the + // binary's single `std::process::exit` rather than terminating + // here, keeping the command embed-safe. + return Ok(Some(1)); } - Ok(()) + Ok(None) } #[derive(Debug, Default)] diff --git a/crates/aube/src/commands/exec.rs b/crates/aube/src/commands/exec.rs index 0ac634ced..dfc55264d 100644 --- a/crates/aube/src/commands/exec.rs +++ b/crates/aube/src/commands/exec.rs @@ -75,7 +75,7 @@ pub struct ExecArgs { pub async fn run( exec_args: ExecArgs, filter: aube_workspace::selector::EffectiveFilter, -) -> miette::Result<()> { +) -> miette::Result> { exec_args.network.install_overrides(); exec_args.lockfile.install_overrides(); exec_args.virtual_store.install_overrides(); @@ -129,7 +129,7 @@ async fn run_filtered( parallel: bool, filter: &aube_workspace::selector::EffectiveFilter, recursive: super::run::RecursiveOpts, -) -> miette::Result<()> { +) -> miette::Result> { let (_root, matched) = super::select_workspace_packages(cwd, filter, "exec")?; let matched = super::run::order_matched_packages(matched, &recursive)?; @@ -150,9 +150,13 @@ async fn run_filtered( for pkg in matched { let bin_path = super::project_modules_dir(&pkg.dir).join(".bin").join(bin); - exec_bin(&pkg.dir, &bin_path, bin, args, shell_mode).await?; + // Sequential fanout bails on the first non-zero exit, matching the + // previous behavior where the inner `exec` terminated the process. + if let Some(code) = exec_bin(&pkg.dir, &bin_path, bin, args, shell_mode).await? { + return Ok(Some(code)); + } } - Ok(()) + Ok(None) } #[allow(clippy::too_many_arguments)] @@ -164,7 +168,7 @@ async fn run_filtered_parallel( concurrency: usize, reporter_hide_prefix: bool, reverse: bool, -) -> miette::Result<()> { +) -> miette::Result> { use std::sync::Arc; use tokio::sync::Semaphore; @@ -265,12 +269,15 @@ async fn run_filtered_parallel( } } if let Some(code) = first_exit { - std::process::exit(code); + // Propagate the first non-zero child exit up to the binary's single + // `std::process::exit` rather than terminating here, so an embedder + // driving aube in-process isn't hard-killed by a failing task. + return Ok(Some(code)); } if let Some(e) = first_err { return Err(e); } - Ok(()) + Ok(None) } pub(crate) async fn exec_bin( @@ -279,10 +286,15 @@ pub(crate) async fn exec_bin( bin: &str, args: &[String], shell_mode: bool, -) -> miette::Result<()> { +) -> miette::Result> { exec_bin_with_node_args(cwd, bin_path, bin, args, &[], shell_mode).await } +/// Run a project-local binary. On a non-zero child exit, returns +/// `Ok(Some(code))` so the caller can propagate the code up to the binary's +/// single `std::process::exit` instead of terminating in place — keeping the +/// command layer embed-safe for a host driving aube as a library. +/// `Ok(None)` means the binary succeeded. pub(crate) async fn exec_bin_with_node_args( cwd: &Path, bin_path: &Path, @@ -290,7 +302,7 @@ pub(crate) async fn exec_bin_with_node_args( args: &[String], node_args: &[String], shell_mode: bool, -) -> miette::Result<()> { +) -> miette::Result> { if !shell_mode && !bin_path.exists() { return Err(miette!( "binary not found: {bin}\nTry running `{}` first, or check that the package providing '{bin}' is in your dependencies.", @@ -327,10 +339,10 @@ pub(crate) async fn exec_bin_with_node_args( .wrap_err("failed to execute binary")?; if !status.success() { - std::process::exit(aube_scripts::exit_code_from_status(status)); + return Ok(Some(aube_scripts::exit_code_from_status(status))); } - Ok(()) + Ok(None) } pub(crate) async fn exec_bin_status( diff --git a/crates/aube/src/commands/install_test.rs b/crates/aube/src/commands/install_test.rs index e910e5b69..457dcc416 100644 --- a/crates/aube/src/commands/install_test.rs +++ b/crates/aube/src/commands/install_test.rs @@ -7,7 +7,7 @@ use miette::miette; /// alone is equivalent. We emit a one-line hint pointing users at the shorter /// form and still honor the explicit install so the command behaves the way /// a pnpm muscle-memory user expects. -pub async fn run(script_args: ScriptArgs) -> miette::Result<()> { +pub async fn run(script_args: ScriptArgs) -> miette::Result> { script_args.network.install_overrides(); script_args.lockfile.install_overrides(); script_args.virtual_store.install_overrides(); diff --git a/crates/aube/src/commands/outdated.rs b/crates/aube/src/commands/outdated.rs index 65bda232d..7a5c9fed7 100644 --- a/crates/aube/src/commands/outdated.rs +++ b/crates/aube/src/commands/outdated.rs @@ -122,7 +122,7 @@ fn serialize_dep_type(dt: &DepType, s: S) -> Result miette::Result<()> { +) -> miette::Result> { args.network.install_overrides(); let mut cwd = crate::dirs::project_root()?; if !filter.is_empty() { @@ -144,15 +144,22 @@ pub async fn run( { cwd = root; } - run_one(&cwd, args, None).await?; - Ok(()) + // Match pnpm: exit 1 when any dependency is outdated so CI patterns + // like `aube outdated || exit 1` behave the same. The code is + // returned for the binary's single `std::process::exit` rather than + // exited in place, keeping the command embed-safe. + if run_one(&cwd, args, None).await? { + Ok(Some(1)) + } else { + Ok(None) + } } async fn run_filtered( cwd: &Path, args: OutdatedArgs, filter: &aube_workspace::selector::EffectiveFilter, -) -> miette::Result<()> { +) -> miette::Result> { let (root, matched) = super::select_workspace_packages(cwd, filter, "outdated")?; let manifest = super::load_manifest(&root.join("package.json"))?; let graph = match aube_lockfile::parse_lockfile(&root, &manifest) { @@ -162,7 +169,7 @@ async fn run_filtered( "No lockfile found. Run `{}` first.", aube_util::cmd("install") ); - return Ok(()); + return Ok(None); } Err(e) => return Err(miette::Report::new(e)).wrap_err("failed to parse lockfile"), }; @@ -200,9 +207,11 @@ async fn run_filtered( } } if any_drift { - std::process::exit(1); + // Return the code for the binary's single `std::process::exit` + // rather than exiting in place, keeping the command embed-safe. + return Ok(Some(1)); } - Ok(()) + Ok(None) } async fn run_one(cwd: &Path, args: OutdatedArgs, importer: Option) -> miette::Result { @@ -352,14 +361,11 @@ async fn run_graph( render_table(&rows, args.long); } - // Match pnpm: exit 1 when any dependency is outdated so CI patterns like - // `aube outdated || exit 1` and bare `aube outdated && echo ok` behave - // the same as with pnpm. `std::process::exit` is fine here because the - // command has no resources to clean up beyond what the OS handles. - if has_drift && importer.is_none() { - std::process::exit(1); - } - + // Return the drift flag to the caller. The single-project caller (`run`) + // maps `true` to exit code 1 (pnpm parity: `aube outdated || exit 1`), + // and the recursive caller (`run_filtered`) aggregates drift across + // importers — the exit decision lives at the top so the command layer + // stays embed-safe (no in-place `std::process::exit`). Ok(has_drift) } diff --git a/crates/aube/src/commands/peers.rs b/crates/aube/src/commands/peers.rs index d5a2b1e28..b3f326e7f 100644 --- a/crates/aube/src/commands/peers.rs +++ b/crates/aube/src/commands/peers.rs @@ -55,13 +55,13 @@ pub struct PeersCheckArgs { pub json: bool, } -pub async fn run(args: PeersArgs) -> miette::Result<()> { +pub async fn run(args: PeersArgs) -> miette::Result> { match args.command { PeersCommand::Check(a) => check(a).await, } } -async fn check(args: PeersCheckArgs) -> miette::Result<()> { +async fn check(args: PeersCheckArgs) -> miette::Result> { let cwd = crate::dirs::project_root()?; let manifest = super::load_manifest(&cwd.join("package.json"))?; @@ -84,9 +84,12 @@ async fn check(args: PeersCheckArgs) -> miette::Result<()> { } if !issues.is_empty() { - std::process::exit(1); + // pnpm-compat: exit 1 when any peer-dependency issue is found. + // Return the code for the binary's single `std::process::exit` + // rather than terminating here, keeping the command embed-safe. + return Ok(Some(1)); } - Ok(()) + Ok(None) } #[derive(Debug, Clone)] diff --git a/crates/aube/src/commands/restart.rs b/crates/aube/src/commands/restart.rs index b139e3c90..01fae28b5 100644 --- a/crates/aube/src/commands/restart.rs +++ b/crates/aube/src/commands/restart.rs @@ -7,7 +7,7 @@ use crate::commands::run::{ScriptArgs, run_script}; pub async fn run( script_args: ScriptArgs, filter: aube_workspace::selector::EffectiveFilter, -) -> miette::Result<()> { +) -> miette::Result> { script_args.network.install_overrides(); script_args.lockfile.install_overrides(); script_args.virtual_store.install_overrides(); @@ -38,17 +38,28 @@ pub async fn run( || manifest.scripts.contains_key("stop") || manifest.scripts.contains_key("start"); if !has_any { - return Ok(()); + return Ok(None); } ensure_installed(no_install).await?; if manifest.scripts.contains_key("restart") { - exec_script(&cwd, &manifest, "restart", args).await?; + // Propagate a non-zero `restart` exit up to the binary's single + // `std::process::exit` rather than terminating in place. + if let Some(code) = exec_script(&cwd, &manifest, "restart", args).await? { + return Ok(Some(code)); + } } else { - exec_optional(&cwd, &manifest, "stop", &[]).await?; - exec_optional(&cwd, &manifest, "start", args).await?; + // `stop` then `start`, each optional. A non-zero `stop` short-circuits + // before `start` runs — matching the previous behavior where the inner + // script runner terminated the process on the first failure. + if let Some(Some(code)) = exec_optional(&cwd, &manifest, "stop", &[]).await? { + return Ok(Some(code)); + } + if let Some(Some(code)) = exec_optional(&cwd, &manifest, "start", args).await? { + return Ok(Some(code)); + } } - Ok(()) + Ok(None) } diff --git a/crates/aube/src/commands/run.rs b/crates/aube/src/commands/run.rs index 12932c3cb..fef404043 100644 --- a/crates/aube/src/commands/run.rs +++ b/crates/aube/src/commands/run.rs @@ -130,7 +130,7 @@ pub struct ScriptArgs { pub async fn run( run_args: RunArgs, filter: aube_workspace::selector::EffectiveFilter, -) -> miette::Result<()> { +) -> miette::Result> { run_args.network.install_overrides(); run_args.lockfile.install_overrides(); run_args.virtual_store.install_overrides(); @@ -325,7 +325,7 @@ pub(crate) async fn run_script( no_install: bool, if_present: bool, filter: &aube_workspace::selector::EffectiveFilter, -) -> miette::Result<()> { +) -> miette::Result> { let silent = super::global_output_flags().silent; run_script_with( script, @@ -352,7 +352,7 @@ pub(crate) async fn run_script_with( silent: bool, filter: &aube_workspace::selector::EffectiveFilter, recursive: RecursiveOpts, -) -> miette::Result<()> { +) -> miette::Result> { let initial_cwd = crate::dirs::cwd()?; // Walk upward to the nearest `package.json` so `aube run` from a // subdirectory picks up the project root's scripts, matching pnpm. @@ -408,7 +408,7 @@ pub(crate) async fn run_script_with( .await; } if if_present { - return Ok(()); + return Ok(None); } // Old error was "script not found: foo" with no list. User // has to cat package.json to figure out what to type. npm @@ -463,7 +463,7 @@ async fn run_script_filtered( filter: &aube_workspace::selector::EffectiveFilter, enable_pre_post_scripts: bool, recursive: RecursiveOpts, -) -> miette::Result<()> { +) -> miette::Result> { // `cwd` is the nearest ancestor with a `package.json`, which in a // monorepo subpackage is the child — not the workspace root. The // shared helper walks up to the real workspace root before @@ -508,10 +508,16 @@ async fn run_script_filtered( if !silent { tracing::info!("aube run: {name} -> {script}"); } - super::exec::exec_bin_with_node_args( + // Sequential fanout bails on the first non-zero exit, matching + // the previous behavior where the inner `exec` terminated the + // process. Propagate the code instead of exiting in place. + if let Some(code) = super::exec::exec_bin_with_node_args( &pkg.dir, &bin_path, script, args, node_args, false, ) - .await?; + .await? + { + return Ok(Some(code)); + } continue; } if if_present { @@ -525,7 +531,7 @@ async fn run_script_filtered( if !silent { tracing::info!("aube run: {name} -> {script}"); } - exec_script_chain( + if let Some(code) = exec_script_chain( &pkg.dir, &pkg.manifest, script, @@ -533,9 +539,12 @@ async fn run_script_filtered( node_args, enable_pre_post_scripts, ) - .await?; + .await? + { + return Ok(Some(code)); + } } - Ok(()) + Ok(None) } /// Apply topo sort, reverse, and resume-from to a matched-package list. @@ -623,7 +632,8 @@ pub(crate) fn effective_concurrency( /// validate every package up front (no orphaned children if a /// script-name lookup errors mid-spawn), let every started task finish /// so output isn't truncated by an early bail, and report the first -/// non-zero exit's code through `std::process::exit`. Output mode per +/// non-zero exit's code by returning `Ok(Some(code))` for the caller to +/// propagate to the binary's single `std::process::exit`. Output mode per /// task: prefixed (`: `, color-rotated) by default; /// unprefixed but still piped under `--reporter-hide-prefix`. #[allow(clippy::too_many_arguments)] @@ -638,7 +648,7 @@ async fn run_filtered_parallel( concurrency: usize, reporter_hide_prefix: bool, reverse: bool, -) -> miette::Result<()> { +) -> miette::Result> { use std::sync::Arc; use tokio::sync::Semaphore; @@ -803,12 +813,15 @@ async fn run_filtered_parallel( } } if let Some(code) = first_exit { - std::process::exit(code); + // Propagate the first non-zero child exit up to the binary's single + // `std::process::exit` rather than terminating here, so an embedder + // driving aube in-process isn't hard-killed by a failing task. + return Ok(Some(code)); } if let Some(e) = first_err { return Err(e); } - Ok(()) + Ok(None) } pub(crate) fn load_manifest(cwd: &Path) -> miette::Result { @@ -829,38 +842,43 @@ fn configure_script_settings_for_project(cwd: &Path) -> miette::Result { Ok(enable_pre_post_scripts) } -/// Run `script` if it exists in `manifest.scripts`. Returns `true` if the -/// script was found and executed, `false` if it was missing. Errors only -/// when the script ran but exited non-zero (via `exit`). +/// Run `script` if it exists in `manifest.scripts`. Returns the optional +/// missing flag plus the propagated exit code: `Ok(None)` if the script was +/// missing (nothing ran), `Ok(Some(None))` if it ran and succeeded, and +/// `Ok(Some(Some(code)))` if it ran and exited non-zero. The non-zero code +/// travels up to the binary's single `std::process::exit` rather than +/// terminating here, keeping the command layer embed-safe. pub(crate) async fn exec_optional( cwd: &Path, manifest: &PackageJson, script: &str, args: &[String], -) -> miette::Result { +) -> miette::Result>> { if !manifest.scripts.contains_key(script) { - return Ok(false); + return Ok(None); } - exec_script(cwd, manifest, script, args).await?; - Ok(true) + Ok(Some(exec_script(cwd, manifest, script, args).await?)) } +/// Run a single script. Returns `Ok(Some(code))` on a non-zero child exit +/// (propagated up rather than exited in place — see +/// [`exec_script_with_node_args`]), `Ok(None)` on success. pub(crate) async fn exec_script( cwd: &Path, manifest: &PackageJson, script: &str, args: &[String], -) -> miette::Result<()> { +) -> miette::Result> { exec_script_with_node_args(cwd, manifest, script, args, &[]).await } /// Build a fully-configured `tokio::process::Command` for running a /// package.json script. Handles arg quoting, PATH prepend, npm-compat -/// env vars, and INIT_CWD. Shared between `exec_script` (which exits -/// on non-zero) and `exec_script_status` (which returns the status -/// so the parallel path can collect all outcomes). Keeping one place -/// to configure these means future security fixes land once, not -/// twice. +/// env vars, and INIT_CWD. Shared between `exec_script` (which returns a +/// propagatable exit code on non-zero) and `exec_script_status` (which +/// returns the raw status so the parallel path can collect all outcomes). +/// Keeping one place to configure these means future security fixes land +/// once, not twice. async fn build_script_command( cwd: &Path, manifest: &PackageJson, @@ -984,6 +1002,10 @@ fn inject_node_args(cmd: &str, node_args: &[String]) -> String { out } +/// Run `pre