use super::ensure_installed;
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)]
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<()> {
run_args.network.install_overrides();
run_args.lockfile.install_overrides();
run_args.virtual_store.install_overrides();
let RunArgs {
script,
args,
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 => prompt_for_script()?,
};
let node_args = node_args_from_run_flags(inspect, inspect_brk);
run_script_with(
&script, &args, &node_args, no_install, if_present, parallel, silent, &filter,
)
.await
}
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<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!(
"aube run: script name required when stdin is not a TTY. Available scripts: {}",
names.join(", ")
));
}
let mut picker = demand::Select::new("Select a script to run")
.description("package.json scripts")
.filterable(true);
for (name, cmd) in &scripts {
let label = format!("{name}: {cmd}");
picker = picker.option(demand::DemandOption::new(name.clone()).label(&label));
}
match picker.run() {
Ok(name) => Ok(name),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => std::process::exit(130),
Err(e) => Err(e)
.into_diagnostic()
.wrap_err("failed to read script selection"),
}
}
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<()> {
let silent = super::global_output_flags().silent;
run_script_with(
script,
args,
&[],
no_install,
if_present,
false,
silent,
filter,
)
.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,
) -> miette::Result<()> {
let initial_cwd = 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,
)
.await;
}
let manifest = load_manifest(&cwd)?;
if !manifest.scripts.contains_key(script) {
ensure_installed(no_install).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(());
}
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(no_install).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,
) -> miette::Result<()> {
let (_root, matched) = super::select_workspace_packages(cwd, filter, "run")?;
ensure_installed(no_install).await?;
if parallel {
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!(
"aube run: package {name} has no `{script}` script"
)))
}
})
.collect::<miette::Result<Vec<_>>>()?;
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());
for (pkg, bin_path) in runnable {
let name = pkg
.name
.clone()
.unwrap_or_else(|| pkg.dir.display().to_string());
if !silent {
tracing::info!("aube run: {name} -> {script} (parallel)");
}
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();
task_names.push(name);
tasks.push(tokio::spawn(async move {
if let Some(bin_path) = bin_path {
super::exec::exec_bin_status_with_node_args(
&dir, &bin_path, &script, &args, &node_args, false,
)
.await
} else {
exec_script_status_chain(
&dir,
&manifest,
&script,
&args,
&node_args,
enable_pre_post_scripts,
)
.await
}
}));
}
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!(
"aube run: `{script}` failed in {name} (exit {code})"
));
}
}
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 {
std::process::exit(code);
}
if let Some(e) = first_err {
return Err(e);
}
return Ok(());
}
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}");
}
super::exec::exec_bin_with_node_args(
&pkg.dir, &bin_path, script, args, node_args, false,
)
.await?;
continue;
}
if if_present {
continue;
}
return Err(miette!("aube run: package {name} has no `{script}` script"));
}
if !silent {
tracing::info!("aube run: {name} -> {script}");
}
exec_script_chain(
&pkg.dir,
&pkg.manifest,
script,
args,
node_args,
enable_pre_post_scripts,
)
.await?;
}
Ok(())
}
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 npmrc_entries = aube_registry::config::load_npmrc_entries(cwd);
let aube_config_entries = crate::commands::config::load_user_aube_config_entries();
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 = aube_settings::ResolveCtx {
npmrc: &npmrc_entries,
aube_config: &aube_config_entries,
workspace_yaml: &raw_workspace,
env: &env_snapshot,
cli: &[],
};
let enable_pre_post_scripts = aube_settings::resolved::enable_pre_post_scripts(&ctx);
super::configure_script_settings(&ctx);
Ok(enable_pre_post_scripts)
}
pub(crate) async fn exec_optional(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
) -> miette::Result<bool> {
if !manifest.scripts.contains_key(script) {
return Ok(false);
}
exec_script(cwd, manifest, script, args).await?;
Ok(true)
}
pub(crate) async fn exec_script(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
) -> miette::Result<()> {
exec_script_with_node_args(cwd, manifest, script, args, &[]).await
}
fn build_script_command(
cwd: &Path,
manifest: &PackageJson,
script: &str,
cmd: &str,
args: &[String],
node_args: &[String],
) -> tokio::process::Command {
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 new_path = aube_scripts::prepend_path(&bin_dir);
let mut command = aube_scripts::spawn_shell(&shell_cmd);
command
.env("PATH", &new_path)
.current_dir(cwd)
.env("npm_lifecycle_event", script)
.stderr(aube_scripts::child_stderr());
if let Some(ref name) = manifest.name {
command.env("npm_package_name", name);
}
if let Some(ref version) = manifest.version {
command.env("npm_package_version", version);
}
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);
}
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<()> {
if enable_pre_post_scripts {
let pre = format!("pre{script}");
exec_optional(cwd, manifest, &pre, &[]).await?;
}
exec_script_with_node_args(cwd, manifest, script, args, node_args).await?;
if enable_pre_post_scripts {
let post = format!("post{script}");
exec_optional(cwd, manifest, &post, &[]).await?;
}
Ok(())
}
async fn exec_script_with_node_args(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
node_args: &[String],
) -> miette::Result<()> {
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);
let status = command
.status()
.await
.into_diagnostic()
.wrap_err("failed to execute script")?;
if !status.success() {
std::process::exit(aube_scripts::exit_code_from_status(status));
}
Ok(())
}
pub(crate) async fn exec_script_status(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
) -> miette::Result<std::process::ExitStatus> {
exec_script_status_with_node_args(cwd, manifest, script, args, &[]).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,
) -> 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, &[]).await?;
if !status.success() {
return Ok(status);
}
}
}
let status = exec_script_status_with_node_args(cwd, manifest, script, args, node_args).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, &[]).await;
}
}
Ok(status)
}
async fn exec_script_status_with_node_args(
cwd: &Path,
manifest: &PackageJson,
script: &str,
args: &[String],
node_args: &[String],
) -> miette::Result<std::process::ExitStatus> {
let cmd = manifest
.scripts
.get(script)
.ok_or_else(|| miette!("script not found: {script}"))?;
build_script_command(cwd, manifest, script, cmd, args, node_args)
.status()
.await
.into_diagnostic()
.wrap_err("failed to execute script")
}
#[cfg(test)]
mod tests {
use super::{inject_node_args, node_args_from_run_flags};
#[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"
);
}
}