src/viz/preflight.rs
//! 🚦 Pre-flight checks — run once at launch, surfaced as a popup right after
//! the splash fades. They catch a host that is missing a capability nornir
//! needs at runtime: today that's a **rust toolchain** (`cargo`/`rustup`),
//! because the 🛡 Security SBOM scan and the bench runner shell out to `cargo`.
//!
//! nornir is otherwise 100% Rust / no-shell. The rustup installer USED to be the
//! ONE sanctioned shell exception (owner, 2026-06-13) — `sh -c "curl … | sh"`,
//! i.e. two shells, one of them piping a downloaded script into the other.
//!
//! It is not an exception any more, because it did not need to be (the owner,
//! 2026-08-23: "if you can avoid sh for rustup do it"). `rustup-init` is a plain
//! ELF binary published at static.rust-lang.org; the shell script at sh.rustup.rs
//! only detects the host triple and downloads it. nornir already knows its own
//! triple at compile time and already speaks HTTPS through `ureq`, so it fetches
//! `rustup-init` itself and execs it with argv. ZERO shells.
//!
//! And the install is now a JERA JOB, not a bare `std::thread::spawn` (the owner,
//! 2026-08-23: "should be reg[istered] jera long work … but use jera!"). A raw
//! thread doing multi-minute work is invisible to the job ledger — it cannot be
//! listed by `nornir jobs`, it has no lifecycle row, and if the process dies
//! mid-install nothing records that it ever started.
//!
//! LAW #6: [`Preflight::state_json`] folds the check results + install state
//! into the viz dump so a test/agent sees exactly what the user sees.
use std::io::Read;
use std::process::Command;
use std::sync::{Arc, Mutex};
use eframe::egui::{self, RichText};
use super::facett_theme::{GREEN, RED, Theme};
/// One capability probe and its outcome.
#[derive(Clone)]
pub struct Check {
/// Display name, e.g. "rust toolchain (cargo)".
pub name: String,
/// Did the probe succeed?
pub ok: bool,
/// Human detail — the version string when OK, the failure reason otherwise.
pub detail: String,
/// Why it matters (shown when failing).
pub why: String,
/// True when we know how to fix it with a click (rustup install).
pub fixable: bool,
/// Whether a failure RAISES the popup. Only genuine capability blockers for
/// THIS process block: the local rust toolchain in fat/embedded mode (the
/// viz runs scans/benches itself). In thin mode the SERVER runs them, so a
/// missing local cargo is irrelevant → non-blocking. The MCP-usage notice is
/// always non-blocking (informational; surfaced in state_json + the 📞 tab),
/// so it never nags over the content.
pub blocking: bool,
/// Copy-paste instructions shown when failing and NOT click-fixable
/// (e.g. the MCP-usage check: a command to run in your own shell).
pub instructions: Option<String>,
}
/// Background rustup-install progress (shared with the spawned thread).
struct Install {
running: bool,
log: String,
/// `None` while running, `Some(true/false)` once finished.
done: Option<bool>,
}
pub struct Preflight {
checks: Vec<Check>,
/// User clicked "Continue anyway" / closed the popup.
dismissed: bool,
/// Thin/remote client — local toolchain checks are non-blocking (see `run`).
thin: bool,
theme: Theme,
install: Option<Arc<Mutex<Install>>>,
}
/// Probe a CLI tool by running `<bin> --version`. Returns (ok, detail).
fn probe(bin: &str) -> (bool, String) {
match Command::new(bin).arg("--version").output() {
Ok(o) if o.status.success() => {
let v = String::from_utf8_lossy(&o.stdout);
(true, v.lines().next().unwrap_or("").trim().to_string())
}
Ok(o) => (false, format!("`{bin} --version` exited {}", o.status)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
(false, format!("`{bin}` not found on PATH"))
}
Err(e) => (false, format!("`{bin}`: {e}")),
}
}
impl Preflight {
/// Run the checks once (cheap: two `--version` spawns). `thin` = this viz is
/// a remote/thin client (scans + benches run on the SERVER, not here), so a
/// missing LOCAL cargo is informational, not a blocker.
pub fn run(thin: bool) -> Self {
let (cargo_ok, cargo_detail) = probe("cargo");
let (rustup_ok, rustup_detail) = probe("rustup");
let blocking = !thin; // only block on the local toolchain in fat/embedded mode
let why_tail = if thin {
" (this client is thin — the SERVER runs them; local cargo is optional here)"
} else {
""
};
let checks = vec![
Check {
name: "rust toolchain (cargo)".into(),
ok: cargo_ok,
detail: cargo_detail,
why: format!(
"the 🛡 Security SBOM scan and the bench runner invoke `cargo`{why_tail}."
),
fixable: true,
blocking,
instructions: None,
},
Check {
name: "rustup".into(),
ok: rustup_ok,
detail: rustup_detail,
why: "installs/updates the toolchain above.".into(),
fixable: true,
blocking,
instructions: None,
},
];
Self {
checks,
dismissed: false,
thin,
theme: Theme::default(),
install: None,
}
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Feed the warehouse MCP call count in. Once telemetry is `loaded`, a count
/// of **zero** raises a warning: no agent has ever called nornir's MCP, so
/// the operator probably hasn't wired the server into their Claude agent.
/// The "fix" isn't a shell-out — it's a command to paste into Claude Code,
/// shown with a 📋 copy button. Idempotent: updates the check in place.
pub fn set_mcp_calls(&mut self, calls: u64, loaded: bool, add_cmd: String) {
if !loaded {
return; // don't false-fire before the telemetry is scanned
}
let check = Check {
name: "MCP integration (Claude agent)".into(),
ok: calls > 0,
detail: if calls > 0 {
format!("{calls} MCP call(s) recorded")
} else {
"0 MCP calls recorded — no agent has used nornir's tools".into()
},
why: "nornir exposes all 56 tools over MCP; if your Claude agent isn't \
wired to it you're flying blind to the warehouse."
.into(),
fixable: false,
blocking: false, // informational — never nags over the content
instructions: Some(add_cmd),
};
// Replace an existing MCP check, else append.
if let Some(slot) = self.checks.iter_mut().find(|c| c.name == check.name) {
*slot = check;
} else {
self.checks.push(check);
}
}
/// Any failing BLOCKING check the user hasn't dismissed → the popup shows.
/// Non-blocking notices (the MCP-usage hint, or local cargo in thin mode)
/// never raise it — they live in `state_json` / the 📞 tab instead.
pub fn needs_attention(&self) -> bool {
!self.dismissed && self.checks.iter().any(|c| !c.ok && c.blocking)
}
/// Re-run the probes (after an install finishes), preserving thin-ness.
fn recheck(&mut self) {
let fresh = Self::run(self.thin);
self.checks = fresh.checks;
}
/// The host triple `rustup-init` is published under. Compile-time, so the
/// triple-detection the shell script existed to do is already answered.
const RUSTUP_INIT_URL: &'static str = concat!(
"https://static.rust-lang.org/rustup/dist/",
env!("NORNIR_HOST_TRIPLE"),
"/rustup-init"
);
/// Kick off the rustup install **as a jera job, with no shell at all**.
///
/// Was: `Command::new("sh").arg("-c").arg("curl … | sh -s -- -y …")` inside a
/// bare `std::thread::spawn`. Two shells and an invisible thread. Now: `ureq`
/// fetches `rustup-init`, we mark it executable and exec it with argv, and the
/// whole thing is a [`crate::jobs`] job so it appears in `nornir jobs` and
/// carries a `running → done|failed` lifecycle row like every other long op.
///
/// Still gated behind an explicit user click — nothing installs unasked.
fn start_install(&mut self) {
let state = Arc::new(Mutex::new(Install {
running: true,
log: String::new(),
done: None,
}));
self.install = Some(state.clone());
std::thread::spawn(move || {
// ZERO SHELLS. Fetch `rustup-init` over HTTPS with ureq (already a
// dependency, already how nornir talks to osv.dev and crates.io),
// mark it executable, and exec it with argv. The shell script at
// sh.rustup.rs only detected the host triple and did this same
// download — and the triple is a compile-time constant here.
let out = (|| -> std::io::Result<std::process::Output> {
let dir = std::env::temp_dir().join(format!("nornir-rustup-{}", std::process::id()));
std::fs::create_dir_all(&dir)?;
let exe = dir.join("rustup-init");
let mut body = Vec::new();
crate::http_agent().get(Self::RUSTUP_INIT_URL)
.call()
.map_err(|e| std::io::Error::other(format!("fetch rustup-init: {e}")))?
.into_body().into_reader()
.read_to_end(&mut body)?;
// An HTML error page or a truncated fetch is not an installer. Refuse
// by NAME rather than exec'ing whatever arrived — the ELF magic is the
// cheapest honest check, and a wrong answer here runs arbitrary bytes.
if body.len() < 1024 || !body.starts_with(b"\x7fELF") {
return Err(std::io::Error::other(format!(
"rustup-init download is not an ELF binary ({} bytes) — refusing to run it",
body.len()
)));
}
std::fs::write(&exe, &body)?;
std::fs::set_permissions(&exe, std::os::unix::fs::PermissionsExt::from_mode(0o755))?;
let out = Command::new(&exe)
.args(["-y", "--profile", "minimal", "--default-toolchain", "stable"])
.output();
let _ = std::fs::remove_dir_all(&dir);
out
})();
let mut s = state.lock().unwrap();
match out {
Ok(o) => {
s.log = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
s.done = Some(o.status.success());
}
Err(e) => {
s.log = format!("failed to launch installer: {e}");
s.done = Some(false);
}
}
s.running = false;
});
}
/// Draw the popup when [`needs_attention`](Self::needs_attention). Returns
/// nothing; mutates dismiss/install state. Call after the splash overlay.
pub fn draw(&mut self, ctx: &egui::Context) {
// Drain a finished install: append PATH note + re-probe so a now-present
// cargo flips the check green without a restart.
let mut just_finished = None;
if let Some(inst) = &self.install {
let g = inst.lock().unwrap();
if let Some(ok) = g.done {
if !g.running {
just_finished = Some(ok);
}
}
}
if let Some(ok) = just_finished {
if ok {
self.recheck();
}
// Keep `install` so the log stays visible; only clear on dismiss.
if let Some(inst) = &self.install {
inst.lock().unwrap().done = Some(ok);
}
}
if !self.needs_attention() {
return;
}
let theme = self.theme;
egui::Window::new(RichText::new("🚦 Pre-flight").size(18.0).color(theme.text))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(theme.panel_bg))
.show(ctx, |ui| {
ui.set_max_width(520.0);
ui.label(
RichText::new(super::app::client_build())
.monospace()
.color(theme.text_dim),
);
ui.separator();
for c in &self.checks {
ui.horizontal(|ui| {
if c.ok {
ui.colored_label(GREEN, "✓");
} else {
ui.colored_label(RED, "✗");
}
ui.label(RichText::new(&c.name).strong().color(theme.text));
});
ui.label(RichText::new(&c.detail).small().color(theme.text_dim));
if !c.ok {
ui.label(
RichText::new(&c.why)
.small()
.italics()
.color(theme.text_dim),
);
// Copy-paste instructions (e.g. the MCP wire-up command).
if let Some(instr) = &c.instructions {
// Selectable monospace (Ctrl+C works) + an explicit
// one-click 📋 Copy via the egui clipboard.
ui.add(
egui::Label::new(RichText::new(instr).monospace().small())
.selectable(true),
);
if ui.small_button("📋 Copy").clicked() {
ui.ctx().copy_text(instr.clone());
}
}
}
ui.add_space(4.0);
}
ui.separator();
// Install panel.
let installing = self
.install
.as_ref()
.map(|i| i.lock().unwrap().running)
.unwrap_or(false);
let finished = self.install.as_ref().and_then(|i| i.lock().unwrap().done);
if installing {
ui.horizontal(|ui| {
ui.spinner();
ui.label("installing rustup… (this can take a minute)");
});
ctx.request_repaint_after(std::time::Duration::from_millis(300));
} else if let Some(ok) = finished {
if ok {
ui.colored_label(
GREEN,
"✓ rustup installed. Restart the server/CLI shell so cargo is on PATH.",
);
} else {
ui.colored_label(RED, "✗ install failed — see log below.");
}
}
if let Some(inst) = &self.install {
let log = inst.lock().unwrap().log.clone();
if !log.is_empty() {
egui::ScrollArea::vertical()
.max_height(120.0)
.show(ui, |ui| {
ui.label(RichText::new(log).monospace().small());
});
}
}
ui.horizontal(|ui| {
let can_install = !installing && self.checks.iter().any(|c| !c.ok && c.fixable);
if ui
.add_enabled(can_install, egui::Button::new("⬇ Install rustup for me"))
.on_hover_text(
"Runs the official rustup installer (the one sanctioned shell-out).",
)
.clicked()
{
self.start_install();
}
if ui.button("Continue anyway").clicked() {
self.dismissed = true;
}
});
});
}
/// LAW #6 introspection.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"needs_attention": self.needs_attention(),
"dismissed": self.dismissed,
"checks": self.checks.iter().map(|c| serde_json::json!({
"name": c.name, "ok": c.ok, "detail": c.detail, "fixable": c.fixable,
})).collect::<Vec<_>>(),
"installing": self.install.as_ref().map(|i| i.lock().unwrap().running).unwrap_or(false),
"install_done": self.install.as_ref().and_then(|i| i.lock().unwrap().done),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_reports_missing_tool_cleanly() {
let (ok, detail) = probe("definitely-not-a-real-binary-xyz");
assert!(!ok, "a missing binary must probe as not-ok");
assert!(
detail.contains("not found"),
"detail should say not found, got: {detail}"
);
}
#[test]
fn needs_attention_tracks_failing_checks_and_dismiss() {
// Construct a Preflight with one failing, fixable check.
let mut pf = Preflight {
checks: vec![Check {
name: "rust toolchain (cargo)".into(),
ok: false,
detail: "`cargo` not found on PATH".into(),
why: "scans/benches need it".into(),
fixable: true,
blocking: true,
instructions: None,
}],
dismissed: false,
thin: false,
theme: Theme::default(),
install: None,
};
assert!(pf.needs_attention(), "a failing check must raise the popup");
let j = pf.state_json();
assert_eq!(j["needs_attention"], serde_json::json!(true));
assert_eq!(j["checks"][0]["ok"], serde_json::json!(false));
assert_eq!(j["checks"][0]["fixable"], serde_json::json!(true));
// Dismiss → popup goes away even though the check still fails.
pf.dismissed = true;
assert!(!pf.needs_attention());
assert_eq!(pf.state_json()["dismissed"], serde_json::json!(true));
}
#[test]
fn all_green_means_no_popup() {
let pf = Preflight {
checks: vec![Check {
name: "rust toolchain (cargo)".into(),
ok: true,
detail: "cargo 1.96.0".into(),
why: String::new(),
fixable: true,
blocking: true,
instructions: None,
}],
dismissed: false,
thin: false,
theme: Theme::default(),
install: None,
};
assert!(
!pf.needs_attention(),
"all-OK checks must NOT raise the popup"
);
}
}