Skip to main content

wire/
nuke.rs

1//! `wire nuke` — hard reset of all wire state on the machine.
2//!
3//! `NukePlan::compute` enumerates everything that would be removed
4//! (paths, service units, host MCP entries, optionally the binary)
5//! WITHOUT mutating anything — it is the dry-run output and the
6//! execution input. `NukePlan::execute` performs the removal.
7
8use std::path::PathBuf;
9
10use anyhow::Result;
11use serde::Serialize;
12
13/// Everything a nuke will tear down. Computed from the environment;
14/// pure (no mutation) so it can be printed for --dry-run / confirm.
15#[derive(Debug, Serialize)]
16pub struct NukePlan {
17    /// Directories to delete (only those that currently exist).
18    pub paths: Vec<PathBuf>,
19    /// Host MCP config files we'll de-register the `wire` entry from
20    /// (only files that currently exist).
21    pub mcp_files: Vec<PathBuf>,
22    /// True if the `wire` binary + shell lines should also go (--purge).
23    pub purge_binary: bool,
24}
25
26impl NukePlan {
27    /// Compute the plan. `purge` = remove binary + shell lines too.
28    pub fn compute(purge: bool) -> Result<Self> {
29        let mut paths = Vec::new();
30        // Default-session config/state + machine-wide sessions root + cache.
31        for p in [
32            crate::config::config_dir().ok(),
33            crate::config::state_dir().ok(),
34            crate::session::sessions_root().ok(),
35            dirs::cache_dir().map(|c| c.join("wire")),
36        ]
37        .into_iter()
38        .flatten()
39        {
40            if p.exists() && !paths.contains(&p) {
41                paths.push(p);
42            }
43        }
44        // Host MCP config files that actually exist (one per adapter path).
45        let mut mcp_files = Vec::new();
46        for adapter in crate::adapters::harness::HARNESS_ADAPTERS {
47            for path in (adapter.paths_fn)() {
48                if path.exists() && !mcp_files.contains(&path) {
49                    mcp_files.push(path);
50                }
51            }
52        }
53        Ok(NukePlan {
54            paths,
55            mcp_files,
56            purge_binary: purge,
57        })
58    }
59
60    /// Perform the teardown. Best-effort: a failure on one item is
61    /// recorded in `warnings` and the rest proceed (a nuke that
62    /// half-aborts leaves a confusing machine — the whole point is to
63    /// finish the job; cf. rustup #1072).
64    pub fn execute(&self) -> Result<NukeReport> {
65        self.execute_with(|kind| crate::service::uninstall_kind(kind).map(|rep| rep.platform))
66    }
67
68    /// `execute` with the service-unit teardown injected. The unit
69    /// uninstall is the ONE machine-global step that no temp `WIRE_HOME`
70    /// can scope — calling the real thing from a unit test boots the
71    /// operator's live launchd daemon out from under them (it did,
72    /// 2026-06-11: every host `cargo test --lib` removed the dev box's
73    /// `sh.slancha.wire.daemon` unit and killed its process tree — THE
74    /// recurring "wire is mysteriously down" engine). Tests pass a stub;
75    /// only `execute()` reaches launchctl/systemd/schtasks.
76    fn execute_with<U>(&self, uninstall_unit: U) -> Result<NukeReport>
77    where
78        U: Fn(crate::service::ServiceKind) -> Result<String>,
79    {
80        let mut r = NukeReport::default();
81
82        // 1. Service units (cross-platform via existing impl).
83        for kind in [
84            crate::service::ServiceKind::Daemon,
85            crate::service::ServiceKind::LocalRelay,
86        ] {
87            match uninstall_unit(kind) {
88                Ok(platform) => r.removed_units.push(format!("{kind:?}: {platform}")),
89                Err(e) => r.warnings.push(format!("uninstall {kind:?}: {e:#}")),
90            }
91        }
92
93        // 2. De-register the wire MCP entry from each host file.
94        //    For each file in the plan, try every adapter's remove_fn
95        //    (each is a no-op / Ok(false) on files it doesn't own).
96        //    First adapter that reports Ok(true) wins; the rest skip.
97        'files: for path in &self.mcp_files {
98            for adapter in crate::adapters::harness::HARNESS_ADAPTERS {
99                match (adapter.remove_fn)(path, "wire") {
100                    Ok(true) => {
101                        r.removed_mcp_entries.push(path.clone());
102                        continue 'files;
103                    }
104                    Ok(false) => {}
105                    Err(e) => {
106                        r.warnings
107                            .push(format!("mcp de-register {}: {e:#}", path.display()));
108                        continue 'files;
109                    }
110                }
111            }
112        }
113
114        // 3. Delete dirs.
115        for p in &self.paths {
116            match std::fs::remove_dir_all(p) {
117                Ok(()) => r.removed_paths.push(p.clone()),
118                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
119                Err(e) => r.warnings.push(format!("rm {}: {e:#}", p.display())),
120            }
121        }
122
123        Ok(r)
124    }
125}
126
127/// Decide whether to proceed. `force` bypasses all prompts; otherwise
128/// a non-TTY refuses, and a TTY proceeds only on an exact "nuke" line.
129/// `read_line` is injected so this is unit-testable.
130pub fn should_proceed(force: bool, is_tty: bool, read_line: impl FnOnce() -> String) -> bool {
131    if force {
132        return true;
133    }
134    if !is_tty {
135        return false;
136    }
137    read_line().trim() == "nuke"
138}
139
140/// Parse `(cwd, session-name)` bindings out of a session registry's raw
141/// bytes. Malformed/empty input → no bindings (the guard then stays
142/// silent, matching a machine with no operator install).
143pub fn parse_registry_bindings(bytes: &[u8]) -> Vec<(String, String)> {
144    let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) else {
145        return Vec::new();
146    };
147    let Some(by_cwd) = v.get("by_cwd").and_then(|m| m.as_object()) else {
148        return Vec::new();
149    };
150    by_cwd
151        .iter()
152        .filter_map(|(cwd, name)| name.as_str().map(|n| (cwd.clone(), n.to_string())))
153        .collect()
154}
155
156/// Read the cwd→session bindings of the machine's DEFAULT registry —
157/// `WIRE_HOME` deliberately ignored, because nuke's unit/process/MCP
158/// teardown is machine-global no matter what home the caller resolved.
159/// Any read failure → empty (no install worth guarding).
160pub fn default_registry_bindings() -> Vec<(String, String)> {
161    let Ok(root) = crate::session::default_sessions_root() else {
162        return Vec::new();
163    };
164    match std::fs::read(root.join("registry.json")) {
165        Ok(bytes) => parse_registry_bindings(&bytes),
166        Err(_) => Vec::new(),
167    }
168}
169
170/// Operator-machine guard. `wire nuke` tears down MACHINE-GLOBAL
171/// surfaces — launchd/systemd units, host MCP configs, every running
172/// wire daemon — regardless of `WIRE_HOME`, so an agent or test harness
173/// invoking it under a temp home still takes the operator's live
174/// install down with it (this killed a dev box's daemon during v0.15
175/// testing). Registry cwd bindings only exist when an operator
176/// deliberately bound sessions, so they are the "live install" signal.
177/// Returns the refusal message, or `None` to proceed.
178pub fn host_guard_refusal(bound: &[(String, String)], really: bool) -> Option<String> {
179    if really || bound.is_empty() {
180        return None;
181    }
182    let mut msg = format!(
183        "refusing to nuke: this machine has a live wire install ({} registry-bound session(s)):\n",
184        bound.len()
185    );
186    for (cwd, name) in bound {
187        msg.push_str(&format!("  {name}  ←  {cwd}\n"));
188    }
189    msg.push_str(
190        "nuke removes launchd/systemd units, MCP registrations, and kills every wire daemon \
191         machine-wide — even when WIRE_HOME points elsewhere.\n\
192         If you really mean this machine, re-run with --really-this-machine.",
193    );
194    Some(msg)
195}
196
197/// What a nuke actually did (for --json + operator output).
198#[derive(Debug, Default, Serialize)]
199pub struct NukeReport {
200    pub removed_paths: Vec<PathBuf>,
201    pub removed_mcp_entries: Vec<PathBuf>,
202    pub removed_units: Vec<String>,
203    pub killed_pids: Vec<u32>,
204    pub binary_removed: bool,
205    /// Non-fatal warnings (e.g. a unit that wasn't installed).
206    pub warnings: Vec<String>,
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn plan_lists_existing_wire_dirs_only() {
215        crate::config::test_support::with_temp_home(|| {
216            // Create a couple of the dirs a real install would have.
217            crate::config::ensure_dirs().unwrap();
218            let plan = NukePlan::compute(false).unwrap();
219            // state_dir + config_dir exist → both in the plan; nonexistent
220            // dirs are skipped (we don't list paths that aren't there).
221            assert!(
222                plan.paths.iter().any(|p| p.ends_with("wire")),
223                "expected a wire dir in {:?}",
224                plan.paths
225            );
226            assert!(
227                !plan.purge_binary,
228                "default plan does not remove the binary"
229            );
230        });
231    }
232
233    #[test]
234    fn purge_plan_sets_binary_removal() {
235        crate::config::test_support::with_temp_home(|| {
236            let plan = NukePlan::compute(true).unwrap();
237            assert!(plan.purge_binary);
238        });
239    }
240
241    #[test]
242    fn confirm_logic() {
243        // --force always proceeds, no input read.
244        assert!(should_proceed(
245            /*force*/ true,
246            /*is_tty*/ false,
247            || unreachable!()
248        ));
249        // non-TTY without force → refuse.
250        assert!(!should_proceed(false, false, String::new));
251        // TTY: proceed iff the typed line is exactly "nuke".
252        assert!(should_proceed(false, true, || "nuke".to_string()));
253        assert!(!should_proceed(false, true, || "no".to_string()));
254        assert!(!should_proceed(false, true, || "NUKE".to_string()));
255    }
256
257    #[test]
258    fn execute_removes_dirs_and_mcp_entry() {
259        crate::config::test_support::with_temp_home(|| {
260            crate::config::ensure_dirs().unwrap();
261            let state = crate::config::state_dir().unwrap();
262            assert!(state.exists());
263            // A fake host MCP file under the temp home with a wire entry.
264            let mcp =
265                std::path::PathBuf::from(std::env::var("WIRE_HOME").unwrap()).join("mcp.json");
266            std::fs::write(&mcp, r#"{"mcpServers":{"wire":{"command":"wire"}}}"#).unwrap();
267            let plan = NukePlan {
268                paths: vec![state.clone()],
269                mcp_files: vec![mcp.clone()],
270                purge_binary: false,
271            };
272            // Stub the unit teardown — NEVER call `execute()` (the real
273            // launchctl path) from a test; it boots the host's live
274            // daemon out. See `execute_with`'s doc.
275            let report = plan.execute_with(|_kind| Ok("stub".to_string())).unwrap();
276            assert_eq!(report.removed_units.len(), 2, "both unit kinds attempted");
277            assert!(!state.exists(), "state dir deleted");
278            let v: serde_json::Value =
279                serde_json::from_slice(&std::fs::read(&mcp).unwrap()).unwrap();
280            assert!(v["mcpServers"].get("wire").is_none(), "wire de-registered");
281            assert!(report.removed_paths.contains(&state));
282            assert!(report.removed_mcp_entries.contains(&mcp));
283        });
284    }
285
286    // ---- host guard ----
287
288    #[test]
289    fn host_guard_silent_with_no_bindings() {
290        // Fresh machine / CI runner: default registry empty → no guard.
291        assert_eq!(host_guard_refusal(&[], false), None);
292        assert_eq!(host_guard_refusal(&[], true), None);
293    }
294
295    #[test]
296    fn host_guard_refuses_bound_machine_without_flag() {
297        let bound = vec![(
298            "/Users/op/Source/wire".to_string(),
299            "slancha-wire".to_string(),
300        )];
301        let msg = host_guard_refusal(&bound, false).expect("guard must refuse");
302        // The refusal must name what it's protecting and the override.
303        assert!(msg.contains("slancha-wire"));
304        assert!(msg.contains("/Users/op/Source/wire"));
305        assert!(msg.contains("--really-this-machine"));
306    }
307
308    #[test]
309    fn host_guard_passes_with_explicit_flag() {
310        let bound = vec![("/x".to_string(), "s".to_string())];
311        assert_eq!(host_guard_refusal(&bound, true), None);
312    }
313
314    #[test]
315    fn registry_bindings_parse_shapes() {
316        // Real shape.
317        let bytes = br#"{"by_cwd":{"/a":"one","/b":"two"}}"#;
318        let mut got = parse_registry_bindings(bytes);
319        got.sort();
320        assert_eq!(
321            got,
322            vec![
323                ("/a".to_string(), "one".to_string()),
324                ("/b".to_string(), "two".to_string())
325            ]
326        );
327        // Empty map, missing key, non-object, garbage → no bindings.
328        assert!(parse_registry_bindings(br#"{"by_cwd":{}}"#).is_empty());
329        assert!(parse_registry_bindings(br"{}").is_empty());
330        assert!(parse_registry_bindings(br#"{"by_cwd":42}"#).is_empty());
331        assert!(parse_registry_bindings(b"not json").is_empty());
332        // Non-string values are skipped, string ones kept.
333        assert_eq!(
334            parse_registry_bindings(br#"{"by_cwd":{"/a":1,"/b":"two"}}"#),
335            vec![("/b".to_string(), "two".to_string())]
336        );
337    }
338}