1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
//! 🚦 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. Shelling out to the rustup
//! installer is the ONE sanctioned exception (Rickard, 2026-06-13), and it is
//! gated behind an explicit user click — we never install anything unasked.
//!
//! 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::process::Command;
use std::sync::{Arc, Mutex};
use eframe::egui::{self, RichText};
use super::facett_theme::{Theme, GREEN, RED};
/// 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;
}
/// Kick off the rustup install in a background thread (the no-shell
/// exception). The official one-liner; `-y` for non-interactive, minimal
/// profile + stable toolchain.
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 || {
let out = Command::new("sh")
.arg("-c")
.arg("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable")
.output();
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()).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");
}
}