use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::{IsTerminal, Write};
use std::path::Path;
const INTERACTIVE_TTY_ERROR: &str = "approve-builds needs stdin and stderr to be TTYs for the interactive picker; pass `--all` or name packages positionally to approve non-interactively";
#[derive(Debug, Args)]
pub struct ApproveBuildsArgs {
#[arg(long)]
pub all: bool,
#[arg(short = 'g', long)]
pub global: bool,
#[arg(value_name = "PKG")]
pub packages: Vec<String>,
}
pub async fn run(args: ApproveBuildsArgs) -> miette::Result<()> {
if args.global {
return run_global(args);
}
let cwd = crate::dirs::project_root()?;
let _lock = super::take_project_lock(&cwd)?;
run_project(&cwd, args.all, args.packages)
}
fn run_project(cwd: &Path, all: bool, packages: Vec<String>) -> miette::Result<()> {
let ignored = super::ignored_builds::collect_ignored(cwd)?;
if ignored.is_empty() {
println!("No ignored builds to approve.");
return Ok(());
}
let selected = select_project(&ignored, all, packages)?;
if selected.is_empty() {
println!("No packages selected.");
return Ok(());
}
let written = aube_manifest::workspace::add_to_allow_builds(cwd, &selected)
.into_diagnostic()
.wrap_err("failed to update workspace yaml")?;
let rel = written
.strip_prefix(cwd)
.unwrap_or(written.as_path())
.display();
println!("Approved {} package(s) in {rel}:", selected.len());
for name in &selected {
println!(" {name}");
}
println!("Run `aube install` (or `aube rebuild`) to execute their scripts.");
Ok(())
}
fn run_global(args: ApproveBuildsArgs) -> miette::Result<()> {
let global_ignored = collect_global_ignored()?;
if global_ignored.is_empty() {
println!("No ignored builds to approve.");
return Ok(());
}
let selected = if args.all {
if !args.packages.is_empty() {
return Err(miette!(
"`--all` and positional package names are mutually exclusive"
));
}
global_ignored
.iter()
.map(|entry| {
(
entry.install_dir.clone(),
entry.ignored.iter().map(|i| i.name.clone()).collect(),
)
})
.collect()
} else if !args.packages.is_empty() {
select_global_packages(&global_ignored, args.packages)?
} else {
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return Err(miette!(INTERACTIVE_TTY_ERROR));
}
pick_global_interactively(&global_ignored)?
};
if selected.is_empty() {
println!("No packages selected.");
return Ok(());
}
let mut approved = 0usize;
let mut written_dirs = 0usize;
for (install_dir, names) in selected {
let written = aube_manifest::workspace::add_to_allow_builds(&install_dir, &names)
.into_diagnostic()
.wrap_err("failed to update global install workspace yaml")?;
written_dirs += 1;
approved += names.len();
println!(
"Approved {} package(s) in {}:",
names.len(),
written.display()
);
for name in &names {
println!(" {name}");
}
}
println!("Approved {approved} package(s) across {written_dirs} global install(s).");
println!("Run `aube -C <global-install-dir> install` (or `rebuild`) to execute their scripts.");
Ok(())
}
fn select_project(
ignored: &[super::ignored_builds::IgnoredEntry],
all: bool,
packages: Vec<String>,
) -> miette::Result<Vec<String>> {
if all {
if !packages.is_empty() {
return Err(miette!(
"`--all` and positional package names are mutually exclusive"
));
}
return Ok(ignored.iter().map(|e| e.name.clone()).collect());
}
if !packages.is_empty() {
let known: HashSet<&str> = ignored.iter().map(|e| e.name.as_str()).collect();
let unknown: Vec<&str> = packages
.iter()
.filter(|p| !known.contains(p.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
return Err(miette!(
"not in the ignored-builds set: {}. Run `aube ignored-builds` to see candidates.",
unknown.join(", ")
));
}
return Ok(dedupe(packages));
}
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return Err(miette!(INTERACTIVE_TTY_ERROR));
}
pick_interactively(ignored)
}
#[derive(Debug)]
struct GlobalIgnored {
install_dir: std::path::PathBuf,
aliases: Vec<String>,
ignored: Vec<super::ignored_builds::IgnoredEntry>,
}
fn collect_global_ignored() -> miette::Result<Vec<GlobalIgnored>> {
let layout = super::global::GlobalLayout::resolve()?;
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for info in super::global::scan_packages(&layout.pkg_dir) {
if !seen.insert(info.install_dir.clone()) {
continue;
}
let ignored = super::ignored_builds::collect_ignored(&info.install_dir)?;
if ignored.is_empty() {
continue;
}
out.push(GlobalIgnored {
install_dir: info.install_dir,
aliases: info.aliases,
ignored,
});
}
out.sort_by(|a, b| a.install_dir.cmp(&b.install_dir));
Ok(out)
}
fn select_global_packages(
global_ignored: &[GlobalIgnored],
packages: Vec<String>,
) -> miette::Result<BTreeMap<std::path::PathBuf, Vec<String>>> {
let wanted = dedupe(packages);
let known: HashSet<&str> = global_ignored
.iter()
.flat_map(|entry| entry.ignored.iter().map(|ignored| ignored.name.as_str()))
.collect();
let unknown: Vec<&str> = wanted
.iter()
.filter(|name| !known.contains(name.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
return Err(miette!(
"not in the ignored-builds set: {}. Run `aube ignored-builds -g` to see candidates.",
unknown.join(", ")
));
}
let wanted: HashSet<&str> = wanted.iter().map(String::as_str).collect();
let mut selected = BTreeMap::new();
for entry in global_ignored {
let names: Vec<String> = entry
.ignored
.iter()
.filter(|ignored| wanted.contains(ignored.name.as_str()))
.map(|ignored| ignored.name.clone())
.collect();
if !names.is_empty() {
selected.insert(entry.install_dir.clone(), names);
}
}
Ok(selected)
}
fn dedupe(packages: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
packages
.into_iter()
.filter(|p| seen.insert(p.clone()))
.collect()
}
fn pick_interactively(
ignored: &[super::ignored_builds::IgnoredEntry],
) -> miette::Result<Vec<String>> {
print_suspicion_summary(ignored);
let mut picker = demand::MultiSelect::new("Choose which packages to allow building")
.description("Space to toggle, Enter to confirm")
.min(1);
for entry in ignored {
let label = format_picker_label(&entry.name, &entry.version, &entry.suspicions);
picker = picker.option(demand::DemandOption::new(entry.name.clone()).label(&label));
}
picker
.run()
.into_diagnostic()
.wrap_err("failed to read approve-builds selection")
}
fn format_picker_label(
name: &str,
version: &str,
suspicions: &[aube_scripts::Suspicion],
) -> String {
if suspicions.is_empty() {
return format!("{name}@{version}");
}
let first = suspicions[0].kind.category();
let extra = suspicions.len() - 1;
if extra == 0 {
format!("{name}@{version} ⚠ suspicious: {first}")
} else {
format!("{name}@{version} ⚠ suspicious: {first} +{extra} more")
}
}
fn print_suspicion_summary(ignored: &[super::ignored_builds::IgnoredEntry]) {
let flagged: Vec<&super::ignored_builds::IgnoredEntry> = ignored
.iter()
.filter(|e| !e.suspicions.is_empty())
.collect();
if flagged.is_empty() {
return;
}
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"⚠ {} package(s) have lifecycle scripts that matched dangerous-shape heuristics:",
flagged.len()
);
for entry in flagged {
let _ = writeln!(stderr, " {}@{}", entry.name, entry.version);
for sus in &entry.suspicions {
let _ = writeln!(stderr, " • {} — {}", sus.hook, sus.kind.description());
}
}
let _ = writeln!(
stderr,
" Inspect each script in `node_modules/.aube/<dep_path>/node_modules/<name>/package.json` before approving."
);
}
fn pick_global_interactively(
global_ignored: &[GlobalIgnored],
) -> miette::Result<BTreeMap<std::path::PathBuf, Vec<String>>> {
for entry in global_ignored {
print_suspicion_summary(&entry.ignored);
}
let mut picker = demand::MultiSelect::new("Choose which global packages to allow building")
.description("Space to toggle, Enter to confirm")
.min(1);
for (idx, entry) in global_ignored.iter().enumerate() {
let aliases = entry.aliases.join(", ");
for ignored in &entry.ignored {
let value = format!("{idx}:{}", ignored.name);
let base = format_picker_label(&ignored.name, &ignored.version, &ignored.suspicions);
let label = format!("{aliases}: {base}");
picker = picker.option(demand::DemandOption::new(value).label(&label));
}
}
let picked: Vec<String> = picker
.run()
.into_diagnostic()
.wrap_err("failed to read approve-builds selection")?;
let mut selected: BTreeMap<std::path::PathBuf, Vec<String>> = BTreeMap::new();
for item in picked {
let Some((idx, name)) = item.split_once(':') else {
continue;
};
let Ok(idx) = idx.parse::<usize>() else {
continue;
};
let Some(entry) = global_ignored.get(idx) else {
continue;
};
selected
.entry(entry.install_dir.clone())
.or_default()
.push(name.to_string());
}
Ok(selected)
}
#[cfg(test)]
mod tests {
use super::format_picker_label;
use aube_scripts::{Suspicion, SuspicionKind};
#[test]
fn label_for_clean_package_is_bare_spec() {
assert_eq!(
format_picker_label("esbuild", "0.20.2", &[]),
"esbuild@0.20.2"
);
}
#[test]
fn label_for_single_suspicion_shows_category() {
let s = vec![Suspicion {
kind: SuspicionKind::ShellPipe,
hook: "postinstall",
}];
assert_eq!(
format_picker_label("lodash", "1.0.0", &s),
"lodash@1.0.0 ⚠ suspicious: curl|sh"
);
}
#[test]
fn label_for_multiple_suspicions_shows_first_plus_count() {
let s = vec![
Suspicion {
kind: SuspicionKind::ShellPipe,
hook: "postinstall",
},
Suspicion {
kind: SuspicionKind::SecretEnvRead,
hook: "postinstall",
},
Suspicion {
kind: SuspicionKind::ExfilEndpoint,
hook: "postinstall",
},
];
assert_eq!(
format_picker_label("evil-pkg", "9.9.9", &s),
"evil-pkg@9.9.9 ⚠ suspicious: curl|sh +2 more"
);
}
}