#[cfg(not(unix))]
compile_error!(
"opi is Unix-only: it runs a script by replacing its own process with exec, \
and its interactive list drives termios directly."
);
mod audit;
mod cargo;
mod check;
mod clean;
mod cli;
mod manifest;
mod outdated;
mod project;
mod run;
mod task;
mod workflow;
mod workspace;
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use std::process::ExitCode;
use runemark::{
ColorMode, Console, DetailLevel, ErrorBlock, Finding, FindingGroup, Group, Hint, Item, Menu,
Metric, NextStep, Outcome, Report, SelectMode, Tone, Verdict,
};
use crate::cli::Invocation;
use crate::manifest::{Manifest, ManifestError};
use crate::project::Project;
use crate::task::{Task, by_group, find, suggestions};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const OUTPUT_LINES: usize = 20;
fn main() -> ExitCode {
let invocation = cli::parse(std::env::args().skip(1));
match &invocation {
Invocation::Help => {
println!("{}", cli::help(VERSION));
return ExitCode::SUCCESS;
}
Invocation::Version => {
println!("opi {VERSION}");
return ExitCode::SUCCESS;
}
_ => {}
}
let directory = match std::env::current_dir() {
Ok(directory) => directory,
Err(error) => {
let console = Console::stderr(ColorMode::Auto);
eprintln!(
"{}",
console.paint(
Tone::Error,
format!("Cannot read the current directory: {error}")
)
);
return ExitCode::FAILURE;
}
};
let npm = Manifest::discover(&directory);
let rust = cargo::discover(&directory);
if let (Err(error), None) = (&npm, &rust) {
report_error(error);
return ExitCode::FAILURE;
}
let (manifest, root) = match npm {
Ok((manifest, root)) => (manifest, root),
Err(_) => (
Manifest::default(),
rust.as_ref()
.map_or_else(|| directory.clone(), |(_, root)| root.clone()),
),
};
let project = Project::detect(&manifest, &root)
.or_named(rust.as_ref().and_then(|(rust, _)| rust.name.clone()));
let members = workspace::members(&root, &manifest);
let mut tasks = Task::from_workspace(&manifest, &members);
if let Some((rust, _)) = &rust {
tasks.extend(task::from_cargo(rust));
}
task::arrange(&mut tasks);
let rust_root = rust.as_ref().map(|(_, root)| root.clone());
match invocation {
Invocation::List => list(
&manifest,
&members,
rust_root.as_deref(),
&project,
&tasks,
&root,
),
Invocation::Health => health(&manifest, &members, rust_root.as_deref(), &project, &root),
Invocation::Clean => clean(&manifest, &members, rust_root.as_deref(), &project, &root),
Invocation::Security => security(&manifest, &members, &project, &root),
Invocation::Updates => updates(&project, &root),
Invocation::Workflow(name) => run_workflow(
&name,
&manifest,
&members,
rust_root.as_deref(),
&project,
&root,
),
Invocation::Run {
name,
args,
confirmed,
} => start(&project, &tasks, &name, &args, confirmed),
Invocation::UnknownFlag(flag) => {
let console = Console::stderr(ColorMode::Auto);
let block = ErrorBlock::new(format!("Unknown option {flag}"))
.with_remedy("Run opi --help to see the available options.");
write_block(&block, console);
ExitCode::FAILURE
}
Invocation::Help | Invocation::Version => unreachable!("handled above"),
}
}
fn list(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
tasks: &[Task],
directory: &Path,
) -> ExitCode {
let manager = project.package_manager;
if tasks.is_empty() {
let console = Console::stdout(ColorMode::Auto);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(directory)),
console.paint(Tone::Muted, manager.manager)
);
println!(
"{}",
console.paint(Tone::Muted, "This project defines no scripts.")
);
return ExitCode::SUCCESS;
}
let runs_scripts = tasks
.iter()
.any(|task| matches!(task.exec, task::Exec::Script));
if !manager.is_certain() && runs_scripts {
let console = Console::stderr(ColorMode::Auto);
eprintln!(
"{}",
console.paint(
Tone::Warning,
format!(
"No lockfile and no packageManager field — assuming {}.",
manager.manager
),
)
);
}
let mut menu = build_menu(project, tasks, directory);
let checks = check::Check::detect_all(manifest, members, rust_root, directory);
if !checks.is_empty() {
menu = menu.add_hint(Hint::new('H', "Health"));
}
menu = menu
.add_hint(Hint::new('C', "Clean"))
.add_hint(Hint::new('S', "Security"))
.add_hint(Hint::new('U', "Updates"));
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
let outcome = match menu.run(
Console::stderr(ColorMode::Auto),
SelectMode::Auto,
interactive,
) {
Ok(outcome) => outcome,
Err(error) => {
let block = ErrorBlock::new("Cannot open the terminal")
.with_explanation(format!("{error}"))
.with_remedy("Run opi <script> to start a script without the list.");
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
match outcome {
Outcome::Selected(id) => start(project, tasks, &id, &[], false),
Outcome::Cancelled => ExitCode::SUCCESS,
Outcome::Hotkey('H') => health(manifest, members, rust_root, project, directory),
Outcome::Hotkey('C') => clean(manifest, members, rust_root, project, directory),
Outcome::Hotkey('S') => security(manifest, members, project, directory),
Outcome::Hotkey('U') => updates(project, directory),
Outcome::Hotkey(_) => ExitCode::SUCCESS,
Outcome::Unavailable => {
print!("{}", menu.render(Console::stdout(ColorMode::Auto)));
ExitCode::SUCCESS
}
}
}
fn toolchains(tasks: &[Task], project: &Project) -> String {
let mut names = Vec::new();
if tasks
.iter()
.any(|task| matches!(task.exec, task::Exec::Script))
{
names.push(project.package_manager.manager.to_string());
}
if tasks
.iter()
.any(|task| matches!(&task.exec, task::Exec::Direct { program, .. } if program == "cargo"))
{
names.push("cargo".to_owned());
}
if names.is_empty() {
names.push(project.package_manager.manager.to_string());
}
names.join(" · ")
}
fn build_menu(project: &Project, tasks: &[Task], directory: &Path) -> Menu {
let mut menu = Menu::new()
.with_heading(project.display_name(directory))
.with_note(toolchains(tasks, project));
for (group, section) in by_group(tasks) {
let mut rendered = Group::new(group.label());
for task in section {
let id = match &task.workspace {
Some(member) => format!("{member}/{}", task.name),
None => task.name.clone(),
};
let mut item = Item::new(id, &task.name);
if let Some(description) = &task.description {
item = item.with_description(description);
}
rendered = rendered.add_item(item);
}
menu = menu.add_group(rendered);
}
menu
}
fn start(
project: &Project,
tasks: &[Task],
name: &str,
args: &[String],
confirmed: bool,
) -> ExitCode {
let Some(task) = find(tasks, name) else {
let console = Console::stderr(ColorMode::Auto);
let mut block = ErrorBlock::new(format!("No script named {name}"));
let close = suggestions(tasks, name);
block = if close.is_empty() {
block.with_remedy("Run opi to see the project's scripts.")
} else {
block.with_explanation(format!("Did you mean {}?", close.join(", ")))
};
for candidate in close {
block = block.add_command(format!("opi {candidate}"));
}
write_block(&block, console);
return ExitCode::FAILURE;
};
if task.confirm && !confirmed && !confirm(task) {
return ExitCode::FAILURE;
}
let manager = project.package_manager;
let error = run::execute(task, manager.manager, args);
let console = Console::stderr(ColorMode::Auto);
let block = ErrorBlock::new(format!("Cannot run {}", manager.manager))
.with_explanation(format!("{error}"))
.with_remedy(if manager.is_certain() {
format!(
"Check that {} is installed and on your PATH.",
manager.manager
)
} else {
format!(
"No lockfile or packageManager field names a package manager, so {} was assumed.",
manager.manager
)
});
write_block(&block, console);
ExitCode::FAILURE
}
fn confirm(task: &Task) -> bool {
let console = Console::stderr(ColorMode::Auto);
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
if !interactive {
let block = ErrorBlock::new(format!("{} needs confirming", task.name))
.with_explanation("This project marked it as needing a confirmation, and there is no terminal to ask in.")
.with_remedy("Run it again with --yes if that is what you mean.")
.add_command(format!("opi --yes {}", task.name));
write_block(&block, console);
return false;
}
let menu = Menu::new()
.with_heading(format!("Run {}?", task.name))
.with_note(&task.command)
.add_group(
Group::new("Confirm")
.add_item(Item::new("no", "Cancel"))
.add_item(Item::new("yes", format!("Run {}", task.name))),
);
matches!(
menu.run(console, SelectMode::Auto, true),
Ok(Outcome::Selected(choice)) if choice == "yes"
)
}
fn write_block(block: &ErrorBlock, console: Console) {
let mut stderr = io::stderr().lock();
let _ = block.write_to(console, &mut stderr);
let _ = stderr.flush();
}
fn report_error(error: &ManifestError) {
let console = Console::stderr(ColorMode::Auto);
let block = match error {
ManifestError::Missing { directory } => ErrorBlock::new("No package.json found")
.with_explanation(format!(
"Searched {} and every directory above it.",
directory.display()
))
.with_remedy("Change into a project directory and run opi again."),
ManifestError::Unreadable { path, error } => ErrorBlock::new("package.json is unreadable")
.with_explanation(format!("{}: {error}", path.display()))
.with_remedy("Check the file permissions."),
ManifestError::Malformed { path, error } => {
ErrorBlock::new("package.json is not valid JSON")
.with_explanation(format!("{}: {error}", path.display()))
.with_remedy("Fix the syntax error and run opi again.")
}
};
write_block(&block, console);
}
fn health(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let checks = check::Check::detect_all(manifest, members, rust_root, root);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "health")
);
println!();
if checks.is_empty() {
println!(
"{}",
console.paint(
Tone::Muted,
"No checks apply: this project depends on none of the tools opi knows."
)
);
return ExitCode::SUCCESS;
}
let width = checks
.iter()
.map(|check| check.label().chars().count())
.max()
.unwrap_or(0);
let reports = check::run_all(checks, |report| {
let (tone, mark) = match report {
report if report.passed() => (Tone::Success, "✓"),
report if report.unusable() => (Tone::Warning, "!"),
_ => (Tone::Error, "✗"),
};
println!(
"{} {} {} {}",
console.paint(tone, mark),
console.paint(tone, format!("{:width$}", report.check.label())),
console.paint(
Tone::Muted,
format!("{:>6.1}s", report.duration.as_secs_f64())
),
console.paint(Tone::Muted, report.check.tool),
);
});
let failed: Vec<&check::Report> = reports.iter().filter(|report| !report.passed()).collect();
let unusable = failed.iter().filter(|report| report.unusable()).count();
for report in &failed {
println!();
let tone = if report.unusable() {
Tone::Warning
} else {
Tone::Error
};
println!(
"{}",
console.paint(
tone,
format!("{} — {}", report.check.label(), report.check.tool)
)
);
let lines: Vec<&str> = report.output.lines().collect();
for line in lines.iter().take(OUTPUT_LINES) {
println!(" {line}");
}
if let Some(hidden) = lines.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(
" {}",
console.paint(
Tone::Muted,
format!(
"… {hidden} more lines — run `{}` in {} to see them all",
report.check.command_line(),
report.check.scope.as_deref().unwrap_or("the project root"),
)
)
);
}
}
println!();
if failed.is_empty() {
println!(
"{}",
console.paint(Tone::Success, format!("{} checks passed.", reports.len()))
);
ExitCode::SUCCESS
} else {
let note = if unusable > 0 {
format!(
"{} of {} checks failed, {unusable} could not run.",
failed.len(),
reports.len()
)
} else {
format!("{} of {} checks failed.", failed.len(), reports.len())
};
println!("{}", console.paint(Tone::Error, note));
ExitCode::FAILURE
}
}
fn clean(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let candidates = clean::candidates(manifest, members, rust_root, root);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "clean")
);
println!();
if candidates.is_empty() {
println!(
"{}",
console.paint(Tone::Muted, "Nothing to remove; the project is clean.")
);
return ExitCode::SUCCESS;
}
let width = candidates
.iter()
.map(|candidate| candidate.display.chars().count())
.max()
.unwrap_or(0);
for candidate in &candidates {
println!(
" {} {}",
console.paint(
if candidate.heavy {
Tone::Warning
} else {
Tone::Info
},
format!("{:width$}", candidate.display)
),
console.paint(Tone::Muted, clean::human(candidate.bytes)),
);
}
println!();
let artefacts: Vec<&clean::Candidate> =
candidates.iter().filter(|entry| !entry.heavy).collect();
let everything: Vec<&clean::Candidate> = candidates.iter().collect();
let artefact_bytes: u64 = artefacts.iter().map(|entry| entry.bytes).sum();
let total_bytes: u64 = everything.iter().map(|entry| entry.bytes).sum();
let mut menu = Menu::new().add_group({
let mut group = Group::new("Remove");
if !artefacts.is_empty() {
group = group.add_item(
Item::new("artefacts", "Build artefacts")
.with_description(clean::human(artefact_bytes)),
);
}
if artefacts.len() != everything.len() {
group = group.add_item(
Item::new("everything", "Everything, including node_modules")
.with_description(clean::human(total_bytes)),
);
}
group.add_item(Item::new("cancel", "Cancel"))
});
menu = menu.with_note(format!("{} removable", clean::human(total_bytes)));
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
let chosen = match menu.run(
Console::stderr(ColorMode::Auto),
SelectMode::Auto,
interactive,
) {
Ok(Outcome::Selected(id)) => id,
Ok(Outcome::Unavailable) => {
println!(
"{}",
console.paint(
Tone::Muted,
"Run opi --clean in a terminal to remove any of it."
)
);
return ExitCode::SUCCESS;
}
Ok(_) => return ExitCode::SUCCESS,
Err(error) => {
let block =
ErrorBlock::new("Cannot open the terminal").with_explanation(format!("{error}"));
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
let selected: &[&clean::Candidate] = match chosen.as_str() {
"artefacts" => &artefacts,
"everything" => &everything,
_ => return ExitCode::SUCCESS,
};
let started = std::time::Instant::now();
let (freed, failures) = clean::remove(selected);
println!(
"{}",
console.paint(
Tone::Success,
format!(
"Removed {} in {:.1}s",
clean::human(freed),
started.elapsed().as_secs_f64()
)
)
);
for (path, error) in &failures {
println!(
"{}",
console.paint(Tone::Error, format!("Could not remove {path}: {error}"))
);
}
if failures.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn security(
manifest: &Manifest,
members: &[workspace::Member],
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "security")
);
println!();
let mut clean = true;
let scans: Vec<check::Check> = check::Check::detect_all(manifest, members, None, root)
.into_iter()
.filter(|check| check.name == "Secrets")
.collect();
if scans.is_empty() {
println!(
"{}",
console.paint(
Tone::Muted,
"No secret scanner: this project depends on none that opi knows."
)
);
} else {
let reports = check::run_all(scans, |_| {});
for report in &reports {
if report.passed() {
println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, format!("Secrets — {}", report.check.tool))
);
} else {
clean = false;
println!(
"{} {}",
console.paint(Tone::Error, "✗"),
console.paint(Tone::Error, format!("Secrets — {}", report.check.tool))
);
let lines: Vec<&str> = report.output.lines().collect();
for line in lines.iter().take(OUTPUT_LINES) {
println!(" {line}");
}
if let Some(rest) = lines.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(
" {}",
console.paint(
Tone::Muted,
format!(
"… {rest} more lines — run `{}`",
report.check.command_line()
)
)
);
}
}
}
}
println!();
match audit::run(project.package_manager.manager, root) {
Ok(found) if found.is_empty() => println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, "Dependencies — no known vulnerabilities")
),
Ok(found) => {
let serious = found
.advisories
.iter()
.any(|advisory| advisory.severity.serious());
clean = clean && !serious;
let mut report = Report::new(
"Dependencies",
if serious {
Verdict::Failed
} else {
Verdict::Warning
},
)
.with_detail_level(DetailLevel::Detailed);
for (severity, count) in found.counts() {
report = report.add_metric(
Metric::new(severity.label(), count.to_string()).with_verdict(
if severity.serious() {
Verdict::Failed
} else {
Verdict::Warning
},
),
);
}
let mut group = FindingGroup::new("Vulnerable dependencies");
for advisory in &found.advisories {
let mut finding = Finding::new(
if advisory.severity.serious() {
Tone::Error
} else {
Tone::Warning
},
&advisory.module,
)
.with_rule_id(advisory.severity.label());
if let Some(patched) = &advisory.patched {
finding = finding.with_remedy(format!("update to {patched}"));
}
group = group.add_finding(finding);
}
report = report.add_group(group);
print!("{}", report.render(console));
}
Err(error) => println!(
"{} {}",
console.paint(Tone::Muted, "–"),
console.paint(Tone::Muted, format!("Dependencies — {error}"))
),
}
println!();
if clean {
println!("{}", console.paint(Tone::Success, "Nothing to act on."));
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn run_workflow(
name: &str,
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let Some(workflow) = workflow::Workflow::parse(name) else {
let block = ErrorBlock::new(format!("No workflow named {name}"))
.with_remedy("Known workflows: commit, release.");
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
};
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, workflow.label())
);
println!();
let mut blocked = false;
for gate in workflow.gates() {
let result = gate.check(root, manifest.version.as_deref());
let (tone, mark) = match result.verdict {
runemark::Verdict::Passed => (Tone::Success, "✓"),
runemark::Verdict::Skipped => (Tone::Muted, "–"),
_ => {
blocked = true;
(Tone::Error, "✗")
}
};
let detail = result
.detail
.map_or_else(String::new, |detail| format!(" {detail}"));
println!(
"{} {}{}",
console.paint(tone, mark),
console.paint(tone, gate.name()),
console.paint(Tone::Muted, detail)
);
}
let checks: Vec<check::Check> = check::Check::detect_all(manifest, members, rust_root, root)
.into_iter()
.filter(|check| workflow.includes(check.name))
.collect();
if checks.is_empty() {
println!(
"{}",
console.paint(Tone::Muted, "No checks apply to this project.")
);
}
let width = checks
.iter()
.map(|check| check.label().chars().count())
.max()
.unwrap_or(0);
let reports = check::run_all(checks, |report| {
let (tone, mark) = match report {
report if report.passed() => (Tone::Success, "✓"),
report if report.unusable() => (Tone::Warning, "!"),
_ => (Tone::Error, "✗"),
};
println!(
"{} {} {}",
console.paint(tone, mark),
console.paint(tone, format!("{:width$}", report.check.label())),
console.paint(Tone::Muted, report.check.tool),
);
});
let failed = reports.iter().filter(|report| !report.passed()).count();
println!();
if blocked || failed > 0 {
for report in reports.iter().filter(|report| !report.passed()) {
println!(
"{}",
console.paint(
Tone::Error,
format!("{} — {}", report.check.label(), report.check.tool)
)
);
for line in report.output.lines().take(OUTPUT_LINES) {
println!(" {line}");
}
}
println!();
println!(
"{}",
console.paint(Tone::Error, format!("Not ready to {name}."))
);
ExitCode::FAILURE
} else {
println!(
"{}",
console.paint(Tone::Success, format!("Ready to {name}."))
);
ExitCode::SUCCESS
}
}
fn updates(project: &Project, root: &Path) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let manager = project.package_manager.manager;
let found = match outdated::run(manager, root) {
Ok(found) => found,
Err(error) => {
let block = ErrorBlock::new("Cannot list outdated dependencies")
.with_explanation(format!("{error}"));
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
if found.is_empty() {
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Success, "everything is current")
);
return ExitCode::SUCCESS;
}
let (breaking, safe): (Vec<_>, Vec<_>) =
found.iter().partition(|update| update.jump.breaking());
let mut report = Report::new(
project.display_name(root),
if breaking.is_empty() {
Verdict::Info
} else {
Verdict::Warning
},
)
.with_detail_level(DetailLevel::Detailed);
if !safe.is_empty() {
report = report.add_metric(Metric::new("safe", safe.len().to_string()));
}
if !breaking.is_empty() {
report = report
.add_metric(Metric::new("major", breaking.len().to_string()).with_tone(Tone::Warning));
}
for (title, tone, group) in [
("Safe to take", Tone::Muted, &safe),
("A decision each", Tone::Warning, &breaking),
] {
if group.is_empty() {
continue;
}
let mut findings = FindingGroup::new(title);
for update in group.iter() {
findings = findings.add_finding(
Finding::new(
tone,
format!("{} {} → {}", update.name, update.current, update.latest),
)
.with_rule_id(update.jump.label()),
);
}
report = report.add_group(findings);
}
if !safe.is_empty() {
report = report.add_next_step(
NextStep::new("Take the safe ones").with_command(format!("{manager} update")),
);
}
print!("{}", report.render(console));
ExitCode::SUCCESS
}