use super::ensure_installed_in;
use aube_manifest::PackageJson;
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use std::io::IsTerminal;
use std::path::Path;
#[derive(Debug, Args)]
pub struct RunArgs {
pub script: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub args: Vec<String>,
#[arg(long, hide = true)]
pub complete: bool,
#[arg(long)]
pub if_present: bool,
#[arg(long, value_name = "[[HOST:]PORT]", num_args = 0..=1, require_equals = true, default_missing_value = "")]
pub inspect: Option<String>,
#[arg(long, value_name = "[[HOST:]PORT]", num_args = 0..=1, require_equals = true, default_missing_value = "")]
pub inspect_brk: Option<String>,
#[arg(long)]
pub no_bail: bool,
#[arg(long)]
pub no_install: bool,
#[arg(long, overrides_with = "sort")]
pub no_sort: bool,
#[arg(long)]
pub parallel: bool,
#[arg(long)]
pub report_summary: bool,
#[arg(long)]
pub reporter_hide_prefix: bool,
#[arg(long, value_name = "PACKAGE")]
pub resume_from: Option<String>,
#[arg(long)]
pub reverse: bool,
#[arg(long, overrides_with = "no_sort")]
pub sort: bool,
#[arg(short = 's')]
pub silent: bool,
#[arg(long, value_name = "N")]
pub workspace_concurrency: Option<usize>,
#[command(flatten)]
pub lockfile: crate::cli_args::LockfileArgs,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
#[command(flatten)]
pub virtual_store: crate::cli_args::VirtualStoreArgs,
}
#[derive(Debug, Args)]
pub struct ScriptArgs {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub args: Vec<String>,
#[arg(long)]
pub no_install: bool,
#[command(flatten)]
pub lockfile: crate::cli_args::LockfileArgs,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
#[command(flatten)]
pub virtual_store: crate::cli_args::VirtualStoreArgs,
}
pub async fn run(
run_args: RunArgs,
filter: aube_workspace::selector::EffectiveFilter,
) -> miette::Result<Option<i32>> {
run_args.network.install_overrides();
run_args.lockfile.install_overrides();
run_args.virtual_store.install_overrides();
let RunArgs {
script,
args,
complete: _,
no_install,
no_sort,
if_present,
inspect,
inspect_brk,
parallel,
no_bail,
report_summary: _,
reporter_hide_prefix,
resume_from,
reverse,
silent,
sort: _,
workspace_concurrency,
lockfile: _,
network: _,
virtual_store: _,
} = run_args;
let silent = silent || super::global_output_flags().silent;
let script = match script {
Some(s) => s,
None => match prompt_for_script()? {
Some(s) => s,
None => return Ok(Some(130)),
},
};
let node_args = node_args_from_run_flags(inspect, inspect_brk);
crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await?;
let recursive = RecursiveOpts {
sort: !no_sort,
reverse,
resume_from,
workspace_concurrency,
reporter_hide_prefix,
no_bail,
};
run_script_with(
&script, &args, &node_args, no_install, if_present, parallel, silent, &filter, recursive,
None,
)
.await
}
#[derive(Debug, Clone)]
pub(crate) struct RecursiveOpts {
pub sort: bool,
pub reverse: bool,
pub resume_from: Option<String>,
pub workspace_concurrency: Option<usize>,
pub reporter_hide_prefix: bool,
pub no_bail: bool,
}
impl Default for RecursiveOpts {
fn default() -> Self {
Self {
sort: true,
reverse: false,
resume_from: None,
workspace_concurrency: None,
reporter_hide_prefix: false,
no_bail: false,
}
}
}
fn node_args_from_run_flags(inspect: Option<String>, inspect_brk: Option<String>) -> Vec<String> {
let mut args = Vec::with_capacity(2);
if let Some(value) = inspect {
args.push(node_arg("--inspect", &value));
}
if let Some(value) = inspect_brk {
args.push(node_arg("--inspect-brk", &value));
}
args
}
fn node_arg(flag: &str, value: &str) -> String {
if value.is_empty() {
flag.to_string()
} else {
format!("{flag}={value}")
}
}
fn prompt_for_script() -> miette::Result<Option<String>> {
let initial_cwd = crate::dirs::cwd()?;
let cwd = crate::dirs::find_project_root(&initial_cwd).ok_or_else(|| {
miette!(
"no package.json found in {} or any parent directory",
initial_cwd.display()
)
})?;
let scripts = read_scripts_in_order(&cwd)?;
if scripts.is_empty() {
return Err(miette!(
"no scripts defined in {}",
cwd.join("package.json").display()
));
}
if !std::io::stdin().is_terminal() {
let names: Vec<&str> = scripts.iter().map(|(n, _)| n.as_str()).collect();
return Err(miette!(
"{}: script name required when stdin is not a TTY. Available scripts: {}",
aube_util::cmd("run"),
names.join(", ")
));
}
let mut picker = demand::Select::new("Select a script to run")
.description("package.json scripts")
.filterable(true)
.filtering(true);
let terminal = terminal_width();
let widest_name = scripts
.iter()
.map(|(name, _)| console::measure_text_width(name))
.max()
.unwrap_or(0);
let name_column = name_column_width(widest_name, terminal);
let command_column = command_column_width(name_column, terminal);
for (name, cmd) in &scripts {
let ellipsis = if name_column > 0 { "…" } else { "" };
let label = console::truncate_str(name, name_column, ellipsis);
let mut option = demand::DemandOption::new(name.clone()).label(&label);
if command_column > 0 {
let cmd = single_line(cmd);
option = option.description(&console::truncate_str(&cmd, command_column, "…"));
}
picker = picker.option(option);
}
match picker.run() {
Ok(name) => Ok(Some(name)),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => Ok(None),
Err(e) => Err(e)
.into_diagnostic()
.wrap_err("failed to read script selection"),
}
}
fn name_column_width(widest_name: usize, terminal: usize) -> usize {
widest_name.min(terminal.saturating_sub(2))
}
fn command_column_width(name_column: usize, terminal: usize) -> usize {
const MIN_COMMAND_COLUMN: usize = 12;
let row_overhead = 2 + name_column + 2;
match terminal.saturating_sub(row_overhead) {
w if w >= MIN_COMMAND_COLUMN => w,
_ => 0,
}
}
fn terminal_width() -> usize {
console::Term::stderr()
.size_checked()
.map(|(_rows, cols)| cols as usize)
.or_else(|| {
std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
})
.filter(|cols| *cols > 0)
.unwrap_or(80)
}
fn single_line(cmd: &str) -> String {
cmd.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn print_script_completions(dir: Option<&Path>) {
let Ok(cwd) = crate::dirs::cwd() else {
return;
};
let start = match dir {
Some(dir) => match cwd.join(dir).canonicalize() {
Ok(resolved)
if resolved.is_dir() && resolved.join("package.json").try_exists().is_ok() =>
{
resolved
}
_ => return,
},
None => cwd,
};
let Some(root) = crate::dirs::find_project_root(&start) else {
return;
};
let Ok(scripts) = read_scripts_in_order(&root) else {
return;
};
let mut out = String::new();
for (name, cmd) in &scripts {
if name.contains(['\n', '\r']) {
continue;
}
out.push_str(&completion_line(name, cmd));
out.push('\n');
}
let _ = std::io::Write::write_all(&mut std::io::stdout(), out.as_bytes());
}
fn completion_line(name: &str, cmd: &str) -> String {
let name = name.replace(':', "\\:");
if name.ends_with('\\') {
return name;
}
format!("{name}:{}", single_line(cmd))
}
fn read_scripts_in_order(cwd: &Path) -> miette::Result<Vec<(String, String)>> {
let path = cwd.join("package.json");
let bytes = std::fs::read(&path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.into_diagnostic()
.wrap_err_with(|| format!("failed to parse {}", path.display()))?;
let Some(serde_json::Value::Object(obj)) = value.get("scripts") else {
return Ok(Vec::new());
};
Ok(obj
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect())
}
pub(crate) async fn run_script(
script: &str,
args: &[String],
no_install: bool,
if_present: bool,
filter: &aube_workspace::selector::EffectiveFilter,
) -> miette::Result<Option<i32>> {
let silent = super::global_output_flags().silent;
run_script_with(
script,
args,
&[],
no_install,
if_present,
false,
silent,
filter,
RecursiveOpts::default(),
None,
)
.await
}
pub(crate) async fn run_script_in(
base_dir: std::path::PathBuf,
script: &str,
args: &[String],
no_install: bool,
if_present: bool,
filter: &aube_workspace::selector::EffectiveFilter,
) -> miette::Result<Option<i32>> {
crate::runtime::ensure_for_cwd(&base_dir).await?;
let silent = super::global_output_flags().silent;
run_script_with(
script,
args,
&[],
no_install,
if_present,
false,
silent,
filter,
RecursiveOpts::default(),
Some(base_dir),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_script_with(
script: &str,
args: &[String],
node_args: &[String],
no_install: bool,
if_present: bool,
parallel: bool,
silent: bool,
filter: &aube_workspace::selector::EffectiveFilter,
recursive: RecursiveOpts,
base_dir: Option<std::path::PathBuf>,
) -> miette::Result<Option<i32>> {
let initial_cwd = match base_dir {
Some(dir) => dir,
None => crate::dirs::cwd()?,
};
let cwd = match crate::dirs::find_project_root(&initial_cwd) {
Some(p) => p,
None if !filter.is_empty() => {
crate::dirs::find_workspace_root(&initial_cwd).ok_or_else(|| {
miette!(
"no project (package.json) or workspace root \
(aube-workspace.yaml / pnpm-workspace.yaml) found in {} \
or any parent directory",
initial_cwd.display()
)
})?
}
None => {
return Err(miette!(
"no package.json found in {} or any parent directory",
initial_cwd.display()
));
}
};
let enable_pre_post_scripts = configure_script_settings_for_project(&cwd)?;
if !filter.is_empty() {
return run_script_filtered(
&cwd,
script,
args,
node_args,
no_install,
if_present,
parallel,
silent,
filter,
enable_pre_post_scripts,
recursive,
)
.await;
}
let manifest = load_manifest(&cwd)?;
if !manifest.scripts.contains_key(script) {
ensure_installed_in(no_install, Some(&cwd)).await?;
let bin_path = super::project_modules_dir(&cwd).join(".bin").join(script);
if bin_path.exists() {
return super::exec::exec_bin_with_node_args(
&cwd, &bin_path, script, args, node_args, false,
)
.await;
}
if if_present {
return Ok(None);
}
let mut names: Vec<&str> = manifest.scripts.keys().map(String::as_str).collect();
names.sort_unstable();
let hint = if names.is_empty() {
"no scripts defined in package.json".to_string()
} else {
format!("available scripts: {}", names.join(", "))
};
return Err(miette!("script not found: {script}\n {hint}"));
}
ensure_installed_in(no_install, Some(&cwd)).await?;
exec_script_chain(
&cwd,
&manifest,
script,
args,
node_args,
enable_pre_post_scripts,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_script_filtered(
cwd: &Path,
script: &str,
args: &[String],
node_args: &[String],
no_install: bool,
if_present: bool,
parallel: bool,
silent: bool,
filter: &aube_workspace::selector::EffectiveFilter,
enable_pre_post_scripts: bool,
recursive: RecursiveOpts,
) -> miette::Result<Option<i32>> {
let (_root, matched) = super::select_workspace_packages(cwd, filter, "run")?;
let matched = order_matched_packages(matched, &recursive)?;
ensure_installed_in(no_install, Some(cwd)).await?;
if let Some(concurrency) = effective_concurrency(parallel, recursive.workspace_concurrency) {
return run_filtered_parallel(
script,
args,
node_args,
if_present,
silent,
enable_pre_post_scripts,
matched,
concurrency,
recursive.reporter_hide_prefix,
recursive.reverse,
)
.await;
}
let mut first_exit: Option<i32> = None;
for pkg in &matched {
let name = pkg
.name
.as_deref()
.unwrap_or_else(|| pkg.dir.to_str().unwrap_or("(unnamed)"));
if !pkg.manifest.scripts.contains_key(script) {
let bin_path = super::project_modules_dir(&pkg.dir)
.join(".bin")
.join(script);
if bin_path.exists() {
if !silent {
tracing::info!("aube run: {name} -> {script}");
}
if let Some(code) = super::exec::exec_bin_with_node_args(
&pkg.dir, &bin_path, script, args, node_args, false,
)
.await?
{
if !recursive.no_bail {
return Ok(Some(code));
}
first_exit.get_or_insert(code);
}
continue;
}
if if_present {
continue;
}
return Err(miette!(
"{}: package {name} has no `{script}` script",
aube_util::cmd("run")
));
}
if !silent {
tracing::info!("aube run: {name} -> {script}");
}
if let Some(code) = exec_script_chain(
&pkg.dir,
&pkg.manifest,
script,
args,
node_args,
enable_pre_post_scripts,
)
.await?
{
if !recursive.no_bail {
return Ok(Some(code));
}
first_exit.get_or_insert(code);
}
}
Ok(first_exit)
}
pub(crate) fn order_matched_packages(
mut matched: Vec<aube_workspace::selector::SelectedPackage>,
recursive: &RecursiveOpts,
) -> miette::Result<Vec<aube_workspace::selector::SelectedPackage>> {
if recursive.sort {
matched = aube_workspace::topo::topological_sort(matched);
}
if recursive.reverse {
matched.reverse();
}
if let Some(name) = recursive.resume_from.as_deref() {
let idx = matched
.iter()
.position(|p| p.name.as_deref() == Some(name))
.ok_or_else(|| {
miette!(
"--resume-from package `{name}` is not in the matched \
workspace set"
)
})?;
matched.drain(..idx);
}
Ok(matched)
}
pub(crate) fn effective_concurrency(
parallel: bool,
workspace_concurrency: Option<usize>,
) -> Option<usize> {
match (parallel, workspace_concurrency) {
(_, Some(0)) => Some(
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.max(1),
),
(_, Some(n)) => Some(n.min(tokio::sync::Semaphore::MAX_PERMITS)),
(true, None) => Some(tokio::sync::Semaphore::MAX_PERMITS),
(false, None) => None,
}
}
#[allow(clippy::too_many_arguments)]
async fn run_filtered_parallel(
script: &str,
args: &[String],
node_args: &[String],
if_present: bool,
silent: bool,
enable_pre_post_scripts: bool,
matched: Vec<aube_workspace::selector::SelectedPackage>,
concurrency: usize,
reporter_hide_prefix: bool,
reverse: bool,
) -> miette::Result<Option<i32>> {
use std::sync::Arc;
use tokio::sync::Semaphore;
let runnable: Vec<_> = matched
.into_iter()
.filter_map(|pkg| {
if pkg.manifest.scripts.contains_key(script) {
Some(Ok((pkg, None)))
} else {
let bin_path = super::project_modules_dir(&pkg.dir)
.join(".bin")
.join(script);
if bin_path.exists() {
return Some(Ok((pkg, Some(bin_path))));
}
if if_present {
return None;
}
let name = pkg
.name
.clone()
.unwrap_or_else(|| pkg.dir.display().to_string());
Some(Err(miette!(
"{}: package {name} has no `{script}` script",
aube_util::cmd("run")
)))
}
})
.collect::<miette::Result<Vec<_>>>()?;
let runnable_pkgs: Vec<aube_workspace::selector::SelectedPackage> =
runnable.iter().map(|(p, _)| p.clone()).collect();
let prereqs = aube_workspace::topo::compute_prereq_indices(&runnable_pkgs);
let prereqs = if reverse {
aube_workspace::topo::transpose_prereqs(&prereqs)
} else {
prereqs
};
let senders: Vec<tokio::sync::watch::Sender<bool>> = (0..runnable.len())
.map(|_| tokio::sync::watch::channel(false).0)
.collect();
let prereq_rxs_per_task: Vec<Vec<tokio::sync::watch::Receiver<bool>>> = (0..runnable.len())
.map(|i| prereqs[i].iter().map(|&j| senders[j].subscribe()).collect())
.collect();
let sem = Arc::new(Semaphore::new(concurrency));
let mut tasks: Vec<tokio::task::JoinHandle<miette::Result<std::process::ExitStatus>>> =
Vec::with_capacity(runnable.len());
let mut task_names: Vec<String> = Vec::with_capacity(runnable.len());
let mut senders_iter = senders.into_iter();
let mut prereq_rxs_iter = prereq_rxs_per_task.into_iter();
for (index, (pkg, bin_path)) in runnable.into_iter().enumerate() {
let name = pkg
.name
.clone()
.unwrap_or_else(|| pkg.dir.display().to_string());
if !silent {
tracing::info!("aube run: {name} -> {script} (parallel)");
}
let output_mode = if reporter_hide_prefix {
super::run_output::OutputMode::NoPrefix
} else {
super::run_output::OutputMode::prefix(pkg.name.as_deref(), index)
};
let prereq_rxs = prereq_rxs_iter.next().expect("one rx vec per package");
let done_tx = senders_iter.next().expect("one sender per package");
let script = script.to_string();
let args = args.to_vec();
let node_args = node_args.to_vec();
let dir = pkg.dir.clone();
let manifest = pkg.manifest.clone();
let sem = Arc::clone(&sem);
task_names.push(name);
tasks.push(tokio::spawn(async move {
for mut rx in prereq_rxs {
while !*rx.borrow_and_update() {
if rx.changed().await.is_err() {
break;
}
}
}
let _permit = sem
.acquire_owned()
.await
.map_err(|e| miette!("workspace concurrency semaphore closed: {e}"))?;
let result = if let Some(bin_path) = bin_path {
super::exec::exec_bin_status_with_node_args(
&dir,
&bin_path,
&script,
&args,
&node_args,
false,
&output_mode,
)
.await
} else {
exec_script_status_chain(
&dir,
&manifest,
&script,
&args,
&node_args,
enable_pre_post_scripts,
&output_mode,
)
.await
};
let _ = done_tx.send(true);
result
}));
}
let mut first_err: Option<miette::Report> = None;
let mut first_exit: Option<i32> = None;
for (t, name) in tasks.into_iter().zip(task_names) {
match t.await {
Ok(Ok(status)) => {
if !status.success() && first_exit.is_none() {
let code = aube_scripts::exit_code_from_status(status);
first_exit = Some(code);
first_err = Some(miette!(
"{}: `{script}` failed in {name} (exit {code})",
aube_util::cmd("run")
));
}
}
Ok(Err(e)) if first_err.is_none() => first_err = Some(e),
Ok(Err(_)) => {}
Err(e) if first_err.is_none() => first_err = Some(miette!("task panicked: {e}")),
Err(_) => {}
}
}
if let Some(code) = first_exit {
return Ok(Some(code));
}
if let Some(e) = first_err {
return Err(e);
}
Ok(None)
}
pub(crate) fn load_manifest(cwd: &Path) -> miette::Result<PackageJson> {
PackageJson::from_path(&cwd.join("package.json"))
.map_err(miette::Report::new)
.wrap_err("failed to read package.json")
}
fn configure_script_settings_for_project(cwd: &Path) -> miette::Result<bool> {
let files = crate::commands::FileSources::load(cwd);
let (_, raw_workspace) = aube_manifest::workspace::load_both(cwd)
.into_diagnostic()
.wrap_err("failed to load workspace config")?;
let env_snapshot = aube_settings::values::capture_env();
let ctx = files.ctx(&raw_workspace, &env_snapshot, &[]);
let enable_pre_post_scripts = aube_settings::resolved::enable_pre_post_scripts(&ctx);
super::configure_script_settings(&ctx, Some("run-script"));
Ok(enable_pre_post_scripts)
}
pub(crate) async fn exec_optional(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
) -> miette::Result<Option<Option<i32>>> {
if !manifest.scripts.contains_key(script) {
return Ok(None);
}
Ok(Some(exec_script(cwd, manifest, script, args).await?))
}
pub(crate) async fn exec_script(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
) -> miette::Result<Option<i32>> {
exec_script_with_node_args(cwd, manifest, script, args, &[]).await
}
async fn build_script_command(
cwd: &Path,
manifest: &PackageJson,
script: &str,
cmd: &str,
args: &[String],
node_args: &[String],
) -> miette::Result<tokio::process::Command> {
let lifecycle_script = cmd.to_string();
let cmd = inject_node_args(cmd, node_args);
let shell_cmd = if args.is_empty() {
cmd
} else {
let mut buf =
String::with_capacity(cmd.len() + args.iter().map(|a| a.len() + 3).sum::<usize>());
buf.push_str(&cmd);
for a in args {
buf.push(' ');
buf.push_str(&aube_scripts::shell_quote_arg(a));
}
buf
};
let bin_dir = super::project_modules_dir(cwd).join(".bin");
let node_gyp_bin_dir = super::install::node_gyp_bootstrap::lazy_shim_bin_dir(&bin_dir)?;
let runtime_bin_dirs = crate::runtime::path_entries();
let path = std::env::var_os("PATH").unwrap_or_default();
let mut entries =
Vec::with_capacity(2 + usize::from(node_gyp_bin_dir.is_some()) + runtime_bin_dirs.len());
entries.push(bin_dir);
if let Some(dir) = node_gyp_bin_dir {
entries.push(dir);
}
entries.extend(runtime_bin_dirs);
entries.extend(std::env::split_paths(&path));
let new_path = std::env::join_paths(entries).unwrap_or(path);
let script_dir = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
crate::dirs::cwd()?.join(cwd)
};
let node_gyp_project_dir =
crate::dirs::find_workspace_root(&script_dir).unwrap_or_else(|| script_dir.clone());
let mut command = aube_scripts::spawn_shell(&shell_cmd);
command
.env("PATH", &new_path)
.current_dir(cwd)
.env(
"AUBE_NODE_GYP_EXE",
std::env::current_exe().into_diagnostic()?,
)
.env("AUBE_NODE_GYP_PROJECT_DIR", node_gyp_project_dir)
.env("npm_lifecycle_event", script)
.env("npm_command", "run-script")
.stderr(aube_scripts::child_stderr());
aube_scripts::apply_npm_manifest_env(&mut command, manifest, &script_dir, &lifecycle_script);
if std::env::var_os("INIT_CWD").is_none() {
let init_cwd = crate::dirs::cwd().ok().unwrap_or_else(|| cwd.to_path_buf());
command.env("INIT_CWD", init_cwd);
}
Ok(command)
}
fn inject_node_args(cmd: &str, node_args: &[String]) -> String {
if node_args.is_empty() {
return cmd.to_string();
}
let trimmed = cmd.trim_start();
let leading_len = cmd.len() - trimmed.len();
let Some(rest) = trimmed.strip_prefix("node") else {
return cmd.to_string();
};
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return cmd.to_string();
}
let mut out =
String::with_capacity(cmd.len() + node_args.iter().map(|arg| arg.len() + 1).sum::<usize>());
out.push_str(&cmd[..leading_len + 4]);
for arg in node_args {
out.push(' ');
out.push_str(&aube_scripts::shell_quote_arg(arg));
}
out.push_str(rest);
out
}
pub(crate) async fn exec_script_chain(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
node_args: &[String],
enable_pre_post_scripts: bool,
) -> miette::Result<Option<i32>> {
if enable_pre_post_scripts {
let pre = format!("pre{script}");
if let Some(Some(code)) = exec_optional(cwd, manifest, &pre, &[]).await? {
return Ok(Some(code));
}
}
if let Some(code) = exec_script_with_node_args(cwd, manifest, script, args, node_args).await? {
return Ok(Some(code));
}
if enable_pre_post_scripts {
let post = format!("post{script}");
if let Some(Some(code)) = exec_optional(cwd, manifest, &post, &[]).await? {
return Ok(Some(code));
}
}
Ok(None)
}
async fn exec_script_with_node_args(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
node_args: &[String],
) -> miette::Result<Option<i32>> {
let cmd = manifest
.scripts
.get(script)
.ok_or_else(|| miette!("script not found: {script}"))?;
let mut command = build_script_command(cwd, manifest, script, cmd, args, node_args).await?;
let status = command
.status()
.await
.into_diagnostic()
.wrap_err("failed to execute script")?;
if !status.success() {
return Ok(Some(aube_scripts::exit_code_from_status(status)));
}
Ok(None)
}
pub(crate) async fn exec_script_status(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
output_mode: &super::run_output::OutputMode,
) -> miette::Result<std::process::ExitStatus> {
exec_script_status_with_node_args(cwd, manifest, script, args, &[], output_mode).await
}
pub(crate) async fn exec_script_status_chain(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
node_args: &[String],
enable_pre_post_scripts: bool,
output_mode: &super::run_output::OutputMode,
) -> miette::Result<std::process::ExitStatus> {
if enable_pre_post_scripts {
let pre = format!("pre{script}");
if manifest.scripts.contains_key(&pre) {
let status = exec_script_status(cwd, manifest, &pre, &[], output_mode).await?;
if !status.success() {
return Ok(status);
}
}
}
let status =
exec_script_status_with_node_args(cwd, manifest, script, args, node_args, output_mode)
.await?;
if !status.success() {
return Ok(status);
}
if enable_pre_post_scripts {
let post = format!("post{script}");
if manifest.scripts.contains_key(&post) {
return exec_script_status(cwd, manifest, &post, &[], output_mode).await;
}
}
Ok(status)
}
async fn exec_script_status_with_node_args(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
node_args: &[String],
output_mode: &super::run_output::OutputMode,
) -> miette::Result<std::process::ExitStatus> {
let cmd = manifest
.scripts
.get(script)
.ok_or_else(|| miette!("script not found: {script}"))?;
let command = build_script_command(cwd, manifest, script, cmd, args, node_args).await?;
super::run_output::run_command(command, output_mode).await
}
#[cfg(test)]
mod tests {
use super::{
RecursiveOpts, command_column_width, completion_line, effective_concurrency,
inject_node_args, name_column_width, node_args_from_run_flags, order_matched_packages,
};
use aube_manifest::PackageJson;
use aube_workspace::selector::SelectedPackage;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn pkg(name: &str, deps: &[&str]) -> SelectedPackage {
let manifest = PackageJson {
name: Some(name.to_string()),
dependencies: deps
.iter()
.map(|d| ((*d).to_string(), "*".to_string()))
.collect::<BTreeMap<_, _>>(),
..PackageJson::default()
};
SelectedPackage {
name: Some(name.to_string()),
version: None,
private: false,
dir: PathBuf::from(name),
manifest,
}
}
fn order_names(out: &[SelectedPackage]) -> Vec<&str> {
out.iter().map(|p| p.name.as_deref().unwrap()).collect()
}
#[test]
fn no_parallel_no_concurrency_is_sequential() {
assert_eq!(effective_concurrency(false, None), None);
}
#[test]
fn parallel_alone_uses_semaphore_max_permits() {
assert_eq!(
effective_concurrency(true, None),
Some(tokio::sync::Semaphore::MAX_PERMITS)
);
}
#[test]
fn explicit_concurrency_above_max_permits_is_clamped() {
assert_eq!(
effective_concurrency(true, Some(usize::MAX)),
Some(tokio::sync::Semaphore::MAX_PERMITS)
);
}
#[test]
fn concurrency_overrides_unbounded_parallel() {
assert_eq!(effective_concurrency(true, Some(4)), Some(4));
}
#[test]
fn concurrency_alone_implies_parallel() {
assert_eq!(effective_concurrency(false, Some(3)), Some(3));
}
#[test]
fn concurrency_one_explicit_takes_parallel_path() {
assert_eq!(effective_concurrency(false, Some(1)), Some(1));
assert_eq!(effective_concurrency(true, Some(1)), Some(1));
}
#[test]
fn concurrency_zero_picks_cpu_count() {
let n = effective_concurrency(false, Some(0)).expect("Some on explicit cap");
assert!(n >= 1, "available_parallelism floor is 1");
}
#[test]
fn order_matched_applies_topo_then_reverse_then_resume() {
let opts = RecursiveOpts {
sort: true,
reverse: true,
resume_from: Some("lib".to_string()),
..RecursiveOpts::default()
};
let out = order_matched_packages(
vec![
pkg("app", &["lib"]),
pkg("lib", &["core"]),
pkg("core", &[]),
],
&opts,
)
.unwrap();
assert_eq!(order_names(&out), vec!["lib", "core"]);
}
#[test]
fn order_matched_resume_from_unknown_package_errors() {
let opts = RecursiveOpts {
sort: false,
resume_from: Some("nonexistent".to_string()),
..RecursiveOpts::default()
};
let err = order_matched_packages(vec![pkg("a", &[])], &opts).unwrap_err();
assert!(err.to_string().contains("nonexistent"));
}
#[test]
fn order_matched_no_sort_preserves_input_order() {
let opts = RecursiveOpts {
sort: false,
..RecursiveOpts::default()
};
let out =
order_matched_packages(vec![pkg("app", &["lib"]), pkg("lib", &[])], &opts).unwrap();
assert_eq!(order_names(&out), vec!["app", "lib"]);
}
#[test]
fn node_args_from_flags_supports_optional_values() {
assert_eq!(
node_args_from_run_flags(Some(String::new()), Some("0.0.0.0:9230".to_string())),
vec![
"--inspect".to_string(),
"--inspect-brk=0.0.0.0:9230".to_string()
]
);
}
#[test]
fn inject_node_args_only_touches_direct_node_commands() {
let args = vec!["--inspect".to_string()];
assert_eq!(
inject_node_args("node test.js", &args),
format!(
"node {} test.js",
aube_scripts::shell_quote_arg("--inspect")
)
);
assert_eq!(inject_node_args("tsx test.ts", &args), "tsx test.ts");
assert_eq!(
inject_node_args("node-gyp rebuild", &args),
"node-gyp rebuild"
);
}
#[test]
fn completion_line_escapes_colons_in_the_script_name() {
assert_eq!(
completion_line("test:unit", "vitest run --reporter=x:y"),
"test\\:unit:vitest run --reporter=x:y"
);
}
#[test]
fn completion_probe_leaves_the_working_directory_alone() {
let before = std::env::current_dir().unwrap();
super::print_script_completions(Some(std::path::Path::new("..")));
assert_eq!(std::env::current_dir().unwrap(), before);
}
#[test]
fn completion_line_drops_the_description_after_a_trailing_backslash() {
assert_eq!(completion_line("weird\\", "echo hi"), "weird\\");
assert_eq!(
completion_line("mid\\slash", "echo mid"),
"mid\\slash:echo mid"
);
}
#[test]
fn completion_line_folds_a_multiline_command_onto_one_line() {
assert_eq!(
completion_line("deploy", "node deploy.mjs \\\n --yes\n"),
"deploy:node deploy.mjs \\ --yes"
);
}
#[test]
fn command_column_takes_what_the_name_column_leaves() {
assert_eq!(command_column_width(6, 80), 70);
}
#[test]
fn command_column_collapses_when_the_row_would_wrap() {
assert_eq!(command_column_width(60, 64), 0);
assert_eq!(command_column_width(6, 12), 0);
}
#[test]
fn name_column_stays_inside_the_terminal() {
assert_eq!(name_column_width(100, 64), 62);
assert_eq!(name_column_width(6, 64), 6);
assert_eq!(name_column_width(6, 2), 0);
}
}