1use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16
17use crate::Step;
18
19pub const LJOS_PROGRAMS: &[&str] = &[
21 "ljos",
22 "ljos-mcp",
23 "ljos-policyd",
24 "ljos-consensus",
25 "ljos-hud",
26];
27
28pub 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
41pub 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#[derive(Debug, Clone, Default)]
64pub struct Options {
65 pub dry: bool,
67 pub keep_programs: bool,
69 pub all: bool,
71 pub purge: bool,
73 pub stub: bool,
75 pub bin_dir: Option<PathBuf>,
78}
79
80pub 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
139fn 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
171fn 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
224pub 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
252fn 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 #[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 #[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 #[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}