Skip to main content

ljos_cli/
uninstall.rs

1//! Take ljos off this machine.
2//!
3//! `ljos uninstall` offboards every runner, puts the global git hooks
4//! back, removes the ljos programs and leaves a stub named `ljos` that
5//! answers every hook call with an allow. A runner that loaded its hook
6//! list while ljos was installed keeps calling the old path for the rest
7//! of its session; the stub answers, so nothing is blocked. The seat's
8//! state, data and configuration stay unless `purge` is set.
9//!
10//! `scripts/uninstall.sh` does the same without the program, for a
11//! machine the binary was already deleted from.
12
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16
17use crate::Step;
18
19/// The programs the ljos repositories ship.
20pub const LJOS_PROGRAMS: &[&str] = &[
21    "ljos",
22    "ljos-mcp",
23    "ljos-policyd",
24    "ljos-consensus",
25    "ljos-hud",
26];
27
28/// The other seat programs the installer puts beside them. They hold the
29/// tracker, the pack and the deeds, so they stay unless asked for.
30pub const SEAT_PROGRAMS: &[&str] = &[
31    "packset",
32    "packsetd",
33    "packset-mcp",
34    "packset-embed",
35    "vissue",
36    "vissue-mcp",
37    "deedar",
38    "claimdag",
39];
40
41/// The stub left where `ljos` was. `scripts/uninstall.sh` writes the same
42/// text; a test keeps the two equal.
43pub const STUB: &str = r#"#!/bin/sh
44# ljos was uninstalled. This stub stands where the program was, so a
45# runner that loaded its hook list while ljos was installed still gets
46# an answer: every hook call is allowed. Remove it with
47#   rm "$0"
48# or reinstall ljos over it.
49case "$1" in
50  hook)
51    cat >/dev/null 2>&1
52    case " $* " in *" --git-commit "*) exit 0 ;; esac
53    printf '%s\n' '{"permission":"allow","continue":true}'
54    exit 0
55    ;;
56  git-hook|hookd) exit 0 ;;
57esac
58printf '%s\n' "ljos is uninstalled; $0 is the stub that answers hooks. Reinstall: curl -fsSLO https://github.com/leidarljos/ljos/releases/latest/download/install.sh && sh install.sh" >&2
59exit 1
60"#;
61
62/// What `uninstall` does beyond offboarding.
63#[derive(Debug, Clone, Default)]
64pub struct Options {
65    /// Report every step and write nothing.
66    pub dry: bool,
67    /// Leave the programs in place.
68    pub keep_programs: bool,
69    /// Remove [`SEAT_PROGRAMS`] too.
70    pub all: bool,
71    /// Remove the state, data, cache and configuration directories.
72    pub purge: bool,
73    /// Leave [`STUB`] where `ljos` was.
74    pub stub: bool,
75    /// The directory the programs are in; the running ljos's own when
76    /// `None`.
77    pub bin_dir: Option<PathBuf>,
78}
79
80/// Run every step. Steps that fail are reported, not fatal, so a runner
81/// file that cannot be rewritten still leaves the programs removed.
82///
83/// # Errors
84///
85/// The onboard manifest cannot be read, or no program directory can be
86/// found.
87pub fn uninstall(opts: &Options) -> Result<Vec<Step>> {
88    let mut steps = crate::offboard::offboard(None, opts.dry)?;
89    steps.push(git_hooks_step(opts.dry));
90    steps.push(hookd_step(opts.dry));
91    if !opts.keep_programs {
92        let bin = bin_dir(opts.bin_dir.as_deref())?;
93        let names = LJOS_PROGRAMS
94            .iter()
95            .chain(opts.all.then_some(SEAT_PROGRAMS).into_iter().flatten());
96        for name in names {
97            if let Some(step) = program_step(&bin, name, opts) {
98                steps.push(step);
99            }
100        }
101    }
102    if opts.purge {
103        for dir in purge_dirs() {
104            if let Some(step) = purge_step(&dir, opts.dry) {
105                steps.push(step);
106            }
107        }
108    }
109    Ok(steps)
110}
111
112fn git_hooks_step(dry: bool) -> Step {
113    let what = "git-hooks".to_string();
114    if dry {
115        let status = crate::git_hooks::status();
116        return Step {
117            what,
118            detail: format!(
119                "would put the global git hooks back ({})",
120                status.lines().next().unwrap_or("").trim()
121            ),
122            ok: true,
123        };
124    }
125    match crate::git_hooks::uninstall() {
126        Ok(()) => Step {
127            what,
128            detail: "the global git hooks are as they were before ljos".into(),
129            ok: true,
130        },
131        Err(e) => Step {
132            what,
133            detail: format!("the global git hooks could not be put back: {e:#}"),
134            ok: false,
135        },
136    }
137}
138
139/// The hook server exits once its socket is gone and no call is waiting.
140fn hookd_step(dry: bool) -> Step {
141    let what = "hookd".to_string();
142    let sock = crate::runtime_dir().join("hookd.sock");
143    if !sock.exists() {
144        return Step {
145            what,
146            detail: "no hook server is running".into(),
147            ok: true,
148        };
149    }
150    if dry {
151        return Step {
152            what,
153            detail: format!("would stop the hook server at {}", sock.display()),
154            ok: true,
155        };
156    }
157    match std::fs::remove_file(&sock) {
158        Ok(()) => Step {
159            what,
160            detail: "the hook server stops once its last call is answered".into(),
161            ok: true,
162        },
163        Err(e) => Step {
164            what,
165            detail: format!("{}: {e}", sock.display()),
166            ok: false,
167        },
168    }
169}
170
171/// `dir`, else the directory of the running `ljos`.
172fn bin_dir(dir: Option<&Path>) -> Result<PathBuf> {
173    if let Some(dir) = dir {
174        return Ok(dir.to_path_buf());
175    }
176    let exe = std::env::current_exe().context("uninstall: no path for the running ljos")?;
177    let exe = exe.canonicalize().unwrap_or(exe);
178    exe.parent()
179        .map(Path::to_path_buf)
180        .context("uninstall: the running ljos has no directory")
181}
182
183fn program_step(bin: &Path, name: &str, opts: &Options) -> Option<Step> {
184    let path = bin.join(name);
185    let what = "program".to_string();
186    if !path.exists() {
187        return None;
188    }
189    let stub = name == "ljos" && opts.stub;
190    if opts.dry {
191        return Some(Step {
192            what,
193            detail: if stub {
194                format!(
195                    "would replace {} with the stub that allows every hook",
196                    path.display()
197                )
198            } else {
199                format!("would remove {}", path.display())
200            },
201            ok: true,
202        });
203    }
204    let done = if stub {
205        write_stub(&path)
206            .map(|()| format!("{} is now the stub that allows every hook", path.display()))
207    } else {
208        std::fs::remove_file(&path).map(|()| format!("removed {}", path.display()))
209    };
210    Some(match done {
211        Ok(detail) => Step {
212            what,
213            detail,
214            ok: true,
215        },
216        Err(e) => Step {
217            what,
218            detail: format!("{}: {e}", path.display()),
219            ok: false,
220        },
221    })
222}
223
224/// Write [`STUB`] to `path` by rename, so a `ljos` that is running keeps
225/// its old file until it exits.
226///
227/// # Errors
228///
229/// The directory cannot be written.
230pub fn write_stub(path: &Path) -> std::io::Result<()> {
231    let tmp = path.with_extension(format!("stub.{}", std::process::id()));
232    std::fs::write(&tmp, STUB)?;
233    #[cfg(unix)]
234    {
235        use std::os::unix::fs::PermissionsExt;
236        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
237    }
238    std::fs::rename(&tmp, path)
239}
240
241fn xdg(var: &str, under_home: &str) -> Option<PathBuf> {
242    std::env::var_os(var)
243        .filter(|v| !v.is_empty())
244        .map(PathBuf::from)
245        .or_else(|| {
246            std::env::var_os("HOME")
247                .filter(|v| !v.is_empty())
248                .map(|h| PathBuf::from(h).join(under_home))
249        })
250}
251
252/// The directories the seat and its guard write under the XDG homes.
253fn purge_dirs() -> Vec<PathBuf> {
254    let mut out = Vec::new();
255    for (var, under, names) in [
256        ("XDG_CONFIG_HOME", ".config", &["ljos"][..]),
257        ("XDG_DATA_HOME", ".local/share", &["ljos"]),
258        ("XDG_STATE_HOME", ".local/state", &["ljos", "ljos-policyd"]),
259        ("XDG_CACHE_HOME", ".cache", &["ljos"]),
260    ] {
261        if let Some(base) = xdg(var, under) {
262            out.extend(names.iter().map(|n| base.join(n)));
263        }
264    }
265    out
266}
267
268fn purge_step(dir: &Path, dry: bool) -> Option<Step> {
269    let what = "purge".to_string();
270    if !dir.exists() {
271        return None;
272    }
273    if dry {
274        return Some(Step {
275            what,
276            detail: format!("would remove {}", dir.display()),
277            ok: true,
278        });
279    }
280    Some(match std::fs::remove_dir_all(dir) {
281        Ok(()) => Step {
282            what,
283            detail: format!("removed {}", dir.display()),
284            ok: true,
285        },
286        Err(e) => Step {
287            what,
288            detail: format!("{}: {e}", dir.display()),
289            ok: false,
290        },
291    })
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    /// The stub answers a hook with an allow, says nothing for a git hook,
299    /// and refuses to stand in for the rest of the program.
300    #[test]
301    fn the_stub_allows_hooks_and_nothing_else() {
302        let dir = tempfile::tempdir().unwrap();
303        let path = dir.path().join("ljos");
304        write_stub(&path).unwrap();
305        let run = |args: &[&str], stdin: &str| {
306            use std::io::Write as _;
307            let mut child = std::process::Command::new("sh")
308                .arg(&path)
309                .args(args)
310                .stdin(std::process::Stdio::piped())
311                .stdout(std::process::Stdio::piped())
312                .stderr(std::process::Stdio::piped())
313                .spawn()
314                .unwrap();
315            child
316                .stdin
317                .take()
318                .unwrap()
319                .write_all(stdin.as_bytes())
320                .unwrap();
321            let out = child.wait_with_output().unwrap();
322            (
323                out.status.code(),
324                String::from_utf8_lossy(&out.stdout).into_owned(),
325                String::from_utf8_lossy(&out.stderr).into_owned(),
326            )
327        };
328        let (code, out, _) = run(&["hook"], r#"{"hook_event_name":"PreToolUse"}"#);
329        assert_eq!(code, Some(0));
330        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
331        assert_eq!(v["permission"], "allow");
332        assert_eq!(v["continue"], true);
333        let (code, out, _) = run(&["hook", "--git-commit"], "");
334        assert_eq!((code, out.as_str()), (Some(0), ""));
335        let (code, out, _) = run(&["git-hook", "post-commit"], "");
336        assert_eq!((code, out.as_str()), (Some(0), ""));
337        let (code, _, err) = run(&["sitting", "X-1"], "");
338        assert_eq!(code, Some(1));
339        assert!(err.contains("uninstalled"), "{err}");
340    }
341
342    /// The shipped script writes the same stub, so a machine without the
343    /// program ends up as one with it.
344    #[test]
345    fn the_script_carries_the_same_stub() {
346        let script = std::fs::read_to_string(
347            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/uninstall.sh"),
348        )
349        .unwrap();
350        let body = STUB.trim_end_matches('\n');
351        assert!(
352            script.contains(body),
353            "scripts/uninstall.sh does not carry the stub"
354        );
355    }
356
357    /// The script names every hooks file the shipped runner table does, so
358    /// a hand-deleted ljos leaves no runner blocked.
359    #[test]
360    fn the_script_names_every_shipped_hooks_file() {
361        let script = std::fs::read_to_string(
362            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/uninstall.sh"),
363        )
364        .unwrap();
365        let shipped: crate::Harnesses = toml::from_str(crate::HARNESSES_EXAMPLE).unwrap();
366        for h in shipped
367            .harness
368            .iter()
369            .filter(|h| !h.name.starts_with("runner-with-"))
370        {
371            let Some(hooks) = h.hooks.as_deref() else {
372                continue;
373            };
374            let path = hooks.replace("~/", "$HOME/");
375            assert!(
376                script.contains(&path),
377                "{}: {path} is not in scripts/uninstall.sh",
378                h.name
379            );
380            if let Some(skills) = h.skills.as_deref() {
381                let path = skills.replace("~/", "$HOME/");
382                assert!(
383                    script.contains(&path),
384                    "{}: {path} is not in scripts/uninstall.sh",
385                    h.name
386                );
387            }
388        }
389    }
390
391    #[test]
392    fn a_dry_run_removes_nothing() {
393        let dir = tempfile::tempdir().unwrap();
394        let bin = dir.path().join("bin");
395        std::fs::create_dir_all(&bin).unwrap();
396        std::fs::write(bin.join("ljos"), "x").unwrap();
397        std::fs::write(bin.join("ljos-mcp"), "x").unwrap();
398        std::fs::write(bin.join("packset"), "x").unwrap();
399        let opts = Options {
400            dry: true,
401            stub: true,
402            bin_dir: Some(bin.clone()),
403            ..Options::default()
404        };
405        let steps: Vec<Step> = LJOS_PROGRAMS
406            .iter()
407            .filter_map(|n| program_step(&bin, n, &opts))
408            .collect();
409        assert_eq!(steps.len(), 2, "{steps:?}");
410        assert!(steps[0].detail.contains("would replace"), "{steps:?}");
411        assert!(steps[1].detail.contains("would remove"), "{steps:?}");
412        assert_eq!(std::fs::read_to_string(bin.join("ljos")).unwrap(), "x");
413        let live = Options { dry: false, ..opts };
414        for n in LJOS_PROGRAMS {
415            program_step(&bin, n, &live);
416        }
417        assert!(std::fs::read_to_string(bin.join("ljos"))
418            .unwrap()
419            .contains("stub"));
420        assert!(!bin.join("ljos-mcp").exists());
421        assert!(bin.join("packset").exists(), "other seat programs stay");
422    }
423}