1use std::process::{Command, Stdio};
18
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Manager {
25 Uv,
27 Pip,
29 Cargo,
31 Npm,
33 Brew,
36 Dotnet,
39}
40
41impl Manager {
42 #[must_use]
44 pub fn parse(s: &str) -> Option<Self> {
45 match s.trim().to_ascii_lowercase().as_str() {
46 "uv" => Some(Self::Uv),
47 "pip" | "pip3" => Some(Self::Pip),
48 "cargo" => Some(Self::Cargo),
49 "npm" => Some(Self::Npm),
50 "brew" | "homebrew" => Some(Self::Brew),
51 "dotnet" => Some(Self::Dotnet),
52 _ => None,
53 }
54 }
55
56 #[must_use]
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::Uv => "uv",
61 Self::Pip => "pip",
62 Self::Cargo => "cargo",
63 Self::Npm => "npm",
64 Self::Brew => "brew",
65 Self::Dotnet => "dotnet",
66 }
67 }
68
69 #[must_use]
72 pub fn program(self) -> String {
73 let key = format!("LEANCTX_BOOTSTRAP_{}", self.as_str().to_ascii_uppercase());
74 std::env::var(&key)
75 .ok()
76 .filter(|v| !v.trim().is_empty())
77 .unwrap_or_else(|| self.as_str().to_string())
78 }
79
80 #[must_use]
84 pub fn install_hint(self) -> &'static str {
85 match self {
86 Self::Uv => "install uv → https://docs.astral.sh/uv/getting-started/installation/",
87 Self::Pip => "install Python & pip → https://pip.pypa.io/en/stable/installation/",
88 Self::Cargo => "install Rust (cargo) → https://rustup.rs",
89 Self::Npm => "install Node.js (ships npm) → https://nodejs.org/",
90 Self::Brew => "install Homebrew → https://brew.sh",
91 Self::Dotnet => "install the .NET SDK → https://dotnet.microsoft.com/download",
92 }
93 }
94
95 #[must_use]
99 pub fn is_available(self) -> bool {
100 let prog = self.program();
101 if prog.contains('/') || prog.contains('\\') {
102 is_executable(std::path::Path::new(&prog))
103 } else {
104 binary_on_path(&prog)
105 }
106 }
107
108 #[must_use]
111 fn install_argv(self, package: &str, version: &str) -> Vec<String> {
112 let pkg = package.trim();
113 let ver = version.trim();
114 match self {
115 Self::Uv => vec!["tool".into(), "install".into(), format!("{pkg}=={ver}")],
116 Self::Pip => vec!["install".into(), "--user".into(), format!("{pkg}=={ver}")],
117 Self::Cargo => vec![
118 "install".into(),
119 package_base(pkg).into(),
120 "--version".into(),
121 ver.into(),
122 ],
123 Self::Npm => vec!["install".into(), "-g".into(), format!("{pkg}@{ver}")],
124 Self::Brew => vec!["install".into(), pkg.into()],
127 Self::Dotnet => vec![
128 "tool".into(),
129 "install".into(),
130 "--global".into(),
131 package_base(pkg).into(),
132 "--version".into(),
133 ver.into(),
134 ],
135 }
136 }
137
138 #[must_use]
140 fn uninstall_argv(self, package: &str) -> Vec<String> {
141 let base = package_base(package.trim());
142 match self {
143 Self::Uv => vec!["tool".into(), "uninstall".into(), base.into()],
144 Self::Pip => vec!["uninstall".into(), "-y".into(), base.into()],
145 Self::Npm => vec!["rm".into(), "-g".into(), base.into()],
146 Self::Cargo | Self::Brew => vec!["uninstall".into(), base.into()],
147 Self::Dotnet => vec![
148 "tool".into(),
149 "uninstall".into(),
150 "--global".into(),
151 base.into(),
152 ],
153 }
154 }
155}
156
157#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(default)]
163pub struct AddonInstall {
164 pub manager: String,
166 pub package: String,
168 pub version: String,
170 pub bin: String,
173 pub verify: Vec<String>,
176}
177
178impl AddonInstall {
179 #[must_use]
181 pub fn is_declared(&self) -> bool {
182 !self.manager.trim().is_empty() && !self.package.trim().is_empty()
183 }
184
185 #[must_use]
187 pub fn is_absent(&self) -> bool {
188 !self.is_declared()
189 }
190
191 #[must_use]
193 pub fn manager(&self) -> Option<Manager> {
194 self.is_declared()
195 .then(|| Manager::parse(&self.manager))
196 .flatten()
197 }
198
199 #[must_use]
202 pub fn bin(&self) -> &str {
203 let b = self.bin.trim();
204 if b.is_empty() {
205 package_base(self.package.trim())
206 } else {
207 b
208 }
209 }
210
211 pub fn validate(&self) -> Result<(), String> {
214 if !self.is_declared() {
215 return Ok(());
216 }
217 if self.manager().is_none() {
218 return Err(format!(
219 "[install] manager `{}` is not supported — use one of: uv, pip, cargo, npm, brew, dotnet",
220 self.manager.trim()
221 ));
222 }
223 let ver = self.version.trim();
224 if ver.is_empty() {
225 return Err(format!(
226 "[install] `{}` must pin an exact `version` — floating installs are rejected",
227 self.package.trim()
228 ));
229 }
230 if mentions_latest(ver) {
231 return Err("[install] `version` must be an exact pin, not `latest`".into());
232 }
233 for (field, val) in [
234 ("package", self.package.as_str()),
235 ("version", self.version.as_str()),
236 ("bin", self.bin.as_str()),
237 ] {
238 if has_shell_meta(val) {
239 return Err(format!(
240 "[install] `{field}` contains shell metacharacters (| ; & $ ` > <) — rejected"
241 ));
242 }
243 }
244 if self.verify.iter().any(|a| has_shell_meta(a)) {
245 return Err("[install] `verify` argv contains shell metacharacters — rejected".into());
246 }
247 Ok(())
248 }
249
250 #[must_use]
252 pub fn to_receipt(&self) -> InstallReceipt {
253 InstallReceipt {
254 manager: self.manager.trim().to_ascii_lowercase(),
255 package: self.package.trim().to_string(),
256 version: self.version.trim().to_string(),
257 bin: self.bin().to_string(),
258 }
259 }
260
261 #[must_use]
263 pub fn install_argv(&self) -> Vec<String> {
264 self.manager()
265 .map(|m| m.install_argv(&self.package, &self.version))
266 .unwrap_or_default()
267 }
268
269 #[must_use]
271 pub fn uninstall_argv(&self) -> Vec<String> {
272 self.manager()
273 .map(|m| m.uninstall_argv(&self.package))
274 .unwrap_or_default()
275 }
276
277 #[must_use]
280 fn already_satisfied(&self) -> bool {
281 if let Some((prog, rest)) = self.verify.split_first() {
282 return probe_ok(prog, rest);
283 }
284 binary_on_path(self.bin())
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct InstallReceipt {
292 pub manager: String,
293 pub package: String,
294 pub version: String,
295 pub bin: String,
296}
297
298impl InstallReceipt {
299 fn manager(&self) -> Option<Manager> {
300 Manager::parse(&self.manager)
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum BootstrapStatus {
307 AlreadyPresent,
309 Installed,
311}
312
313#[derive(Debug, Clone)]
315pub struct BootstrapOutcome {
316 pub status: BootstrapStatus,
317 pub receipt: InstallReceipt,
318 pub warning: Option<String>,
320}
321
322pub fn ensure_installed(install: &AddonInstall) -> Result<BootstrapOutcome, String> {
330 install.validate()?;
331 let manager = install
332 .manager()
333 .ok_or_else(|| format!("unsupported package manager `{}`", install.manager.trim()))?;
334 let receipt = install.to_receipt();
335
336 if install.already_satisfied() {
337 return Ok(BootstrapOutcome {
338 status: BootstrapStatus::AlreadyPresent,
339 receipt,
340 warning: None,
341 });
342 }
343
344 if !manager.is_available() {
347 return Err(format!(
348 "the `{mgr}` package manager is not installed (or not on PATH), so `{pkg}` cannot be \
349 installed.\n → {hint}\n Or install `{bin}` yourself, then re-run — `addon add` \
350 detects it and skips the bootstrap.",
351 mgr = manager.as_str(),
352 pkg = install.package.trim(),
353 hint = manager.install_hint(),
354 bin = install.bin(),
355 ));
356 }
357
358 run(
359 &manager.program(),
360 &manager.install_argv(&install.package, &install.version),
361 )?;
362
363 let warning = (!install.already_satisfied()).then(|| {
364 format!(
365 "`{}` installed but `{}` is not on your PATH yet — add the manager's bin directory \
366 (e.g. ~/.local/bin) to PATH so the MCP server can launch.",
367 install.package.trim(),
368 install.bin()
369 )
370 });
371
372 Ok(BootstrapOutcome {
373 status: BootstrapStatus::Installed,
374 receipt,
375 warning,
376 })
377}
378
379pub fn uninstall(receipt: &InstallReceipt) -> Result<(), String> {
382 let manager = receipt.manager().ok_or_else(|| {
383 format!(
384 "unsupported package manager `{}` in receipt",
385 receipt.manager
386 )
387 })?;
388 run(
389 &manager.program(),
390 &manager.uninstall_argv(&receipt.package),
391 )
392}
393
394fn package_base(package: &str) -> &str {
397 let p = package.trim();
398 let p = p.split('[').next().unwrap_or(p);
399 let p = p.split("==").next().unwrap_or(p);
400 match p.rsplit_once('@') {
402 Some((head, _)) if !head.is_empty() => head,
403 _ => p,
404 }
405 .trim()
406}
407
408fn run(program: &str, argv: &[String]) -> Result<(), String> {
410 let status = Command::new(program).args(argv).status().map_err(|e| {
411 format!("could not launch `{program}`: {e} — is it installed and on your PATH?")
412 })?;
413 if status.success() {
414 return Ok(());
415 }
416 Err(format!(
417 "`{program} {}` failed ({})",
418 argv.join(" "),
419 status.code().map_or_else(
420 || "terminated by signal".to_string(),
421 |c| format!("exit {c}")
422 )
423 ))
424}
425
426fn probe_ok(program: &str, argv: &[String]) -> bool {
428 Command::new(program)
429 .args(argv)
430 .stdin(Stdio::null())
431 .stdout(Stdio::null())
432 .stderr(Stdio::null())
433 .status()
434 .is_ok_and(|s| s.success())
435}
436
437fn binary_on_path(bin: &str) -> bool {
439 if bin.is_empty() {
440 return false;
441 }
442 let Some(path) = std::env::var_os("PATH") else {
443 return false;
444 };
445 std::env::split_paths(&path).any(|dir| is_executable(&dir.join(bin)))
446}
447
448#[cfg(unix)]
449fn is_executable(path: &std::path::Path) -> bool {
450 use std::os::unix::fs::PermissionsExt;
451 std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
452}
453
454#[cfg(not(unix))]
455fn is_executable(path: &std::path::Path) -> bool {
456 path.is_file()
457}
458
459fn has_shell_meta(s: &str) -> bool {
463 s.chars()
464 .any(|c| matches!(c, '|' | ';' | '&' | '`' | '>' | '<' | '\n' | '\r'))
465 || s.contains("$(")
466}
467
468fn mentions_latest(version: &str) -> bool {
470 let v = version.trim().to_ascii_lowercase();
471 v == "latest" || v.ends_with("@latest") || v.ends_with(":latest") || v == "*"
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 #[cfg(unix)]
478 use std::sync::Mutex;
479
480 #[cfg(unix)]
484 static ENV_LOCK: Mutex<()> = Mutex::new(());
485
486 fn declared(manager: &str, package: &str, version: &str) -> AddonInstall {
487 AddonInstall {
488 manager: manager.into(),
489 package: package.into(),
490 version: version.into(),
491 ..Default::default()
492 }
493 }
494
495 #[test]
496 fn absent_block_is_a_noop() {
497 let empty = AddonInstall::default();
498 assert!(!empty.is_declared());
499 assert!(empty.is_absent());
500 assert!(empty.validate().is_ok());
501 assert!(empty.manager().is_none());
502 }
503
504 #[test]
505 fn validate_requires_known_manager_and_pin() {
506 assert!(declared("uv", "pkg", "1.2.3").validate().is_ok());
507 assert!(declared("conda", "pkg", "1.2.3").validate().is_err());
508 assert!(declared("uv", "pkg", "").validate().is_err());
509 assert!(declared("uv", "pkg", "latest").validate().is_err());
510 assert!(declared("npm", "pkg", "*").validate().is_err());
511 }
512
513 #[test]
514 fn validate_rejects_shell_metacharacters() {
515 assert!(declared("uv", "pkg; rm -rf /", "1.0.0").validate().is_err());
516 assert!(declared("uv", "pkg", "1.0.0 && evil").validate().is_err());
517 assert!(declared("uv", "pkg`whoami`", "1.0.0").validate().is_err());
518 assert!(
520 declared("uv", "headroom-ai[all]", "1.4.2")
521 .validate()
522 .is_ok()
523 );
524 }
525
526 #[test]
527 fn install_argv_is_pinned_per_manager() {
528 assert_eq!(
529 declared("uv", "headroom-ai[all]", "1.4.2").install_argv(),
530 ["tool", "install", "headroom-ai[all]==1.4.2"]
531 );
532 assert_eq!(
533 declared("pip", "cognee", "0.1.0").install_argv(),
534 ["install", "--user", "cognee==0.1.0"]
535 );
536 assert_eq!(
537 declared("cargo", "ripgrep", "14.1.0").install_argv(),
538 ["install", "ripgrep", "--version", "14.1.0"]
539 );
540 assert_eq!(
541 declared("npm", "@scope/cli", "2.0.0").install_argv(),
542 ["install", "-g", "@scope/cli@2.0.0"]
543 );
544 assert_eq!(
545 declared("brew", "node@22", "22.0.0").install_argv(),
546 ["install", "node@22"]
547 );
548 assert_eq!(
549 declared("dotnet", "CodeCompress.Server", "0.15.0").install_argv(),
550 [
551 "tool",
552 "install",
553 "--global",
554 "CodeCompress.Server",
555 "--version",
556 "0.15.0"
557 ]
558 );
559 }
560
561 #[test]
562 fn uninstall_argv_targets_the_base_name() {
563 assert_eq!(
564 declared("uv", "headroom-ai[all]", "1.4.2").uninstall_argv(),
565 ["tool", "uninstall", "headroom-ai"]
566 );
567 assert_eq!(
568 declared("npm", "@scope/cli", "2.0.0").uninstall_argv(),
569 ["rm", "-g", "@scope/cli"]
570 );
571 assert_eq!(
572 declared("pip", "cognee==0.1.0", "0.1.0").uninstall_argv(),
573 ["uninstall", "-y", "cognee"]
574 );
575 assert_eq!(
576 declared("dotnet", "CodeCompress.Server", "0.15.0").uninstall_argv(),
577 ["tool", "uninstall", "--global", "CodeCompress.Server"]
578 );
579 }
580
581 #[test]
582 fn bin_defaults_to_package_base_else_explicit() {
583 assert_eq!(
584 declared("uv", "headroom-ai[all]", "1.0.0").bin(),
585 "headroom-ai"
586 );
587 let mut with_bin = declared("uv", "headroom-ai[all]", "1.0.0");
588 with_bin.bin = "headroom".into();
589 assert_eq!(with_bin.bin(), "headroom");
590 }
591
592 #[test]
593 fn package_base_strips_extras_version_and_keeps_npm_scope() {
594 assert_eq!(package_base("headroom-ai[all]==1.4.2"), "headroom-ai");
595 assert_eq!(package_base("pkg@1.2.3"), "pkg");
596 assert_eq!(package_base("@scope/pkg"), "@scope/pkg");
597 assert_eq!(package_base("@scope/pkg@1.0.0"), "@scope/pkg");
598 }
599
600 #[test]
601 fn receipt_round_trips_and_normalises_manager() {
602 let r = declared("UV", "pkg", "1.0.0").to_receipt();
603 assert_eq!(r.manager, "uv");
604 assert_eq!(r.manager().unwrap(), Manager::Uv);
605 let json = serde_json::to_string(&r).unwrap();
606 let back: InstallReceipt = serde_json::from_str(&json).unwrap();
607 assert_eq!(r, back);
608 }
609
610 #[test]
611 fn binary_on_path_finds_a_standard_tool() {
612 #[cfg(unix)]
614 assert!(binary_on_path("sh"));
615 assert!(!binary_on_path("lean-ctx-definitely-not-a-real-binary-xyz"));
616 assert!(!binary_on_path(""));
617 }
618
619 #[cfg(unix)]
622 fn write_script(path: &std::path::Path, body: &str) {
623 use std::os::unix::fs::PermissionsExt;
624 std::fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap();
625 let mut perms = std::fs::metadata(path).unwrap().permissions();
626 perms.set_mode(0o755);
627 std::fs::set_permissions(path, perms).unwrap();
628 }
629
630 #[test]
631 #[cfg(unix)]
632 fn ensure_installed_runs_manager_then_verifies() {
633 let _guard = ENV_LOCK.lock().unwrap();
634 let tmp = std::env::temp_dir().join(format!("leanctx-boot-{}", std::process::id()));
635 std::fs::create_dir_all(&tmp).unwrap();
636 let marker = tmp.join("installed.marker");
637 let fake_uv = tmp.join("uv");
638 write_script(&fake_uv, &format!("touch '{}'", marker.display()));
640
641 let mut install = declared("uv", "demo-pkg", "1.0.0");
642 install.verify = vec!["test".into(), "-f".into(), marker.display().to_string()];
643
644 unsafe { std::env::set_var("LEANCTX_BOOTSTRAP_UV", &fake_uv) };
646 let _ = std::fs::remove_file(&marker);
647
648 let out = ensure_installed(&install).expect("install");
649 assert_eq!(out.status, BootstrapStatus::Installed);
650 assert!(out.warning.is_none(), "verify passed → no warning");
651 assert!(marker.exists(), "fake manager ran");
652
653 std::fs::remove_file(&fake_uv).unwrap(); let out2 = ensure_installed(&install).expect("idempotent");
656 assert_eq!(out2.status, BootstrapStatus::AlreadyPresent);
657
658 unsafe { std::env::remove_var("LEANCTX_BOOTSTRAP_UV") };
660 let _ = std::fs::remove_dir_all(&tmp);
661 }
662
663 #[test]
664 #[cfg(unix)]
665 fn ensure_installed_propagates_manager_failure() {
666 let _guard = ENV_LOCK.lock().unwrap();
667 let tmp = std::env::temp_dir().join(format!("leanctx-boot-fail-{}", std::process::id()));
668 std::fs::create_dir_all(&tmp).unwrap();
669 let fake_uv = tmp.join("uv");
670 write_script(&fake_uv, "exit 1");
671
672 let mut install = declared("uv", "demo-pkg", "1.0.0");
673 install.verify = vec!["false".into()]; unsafe { std::env::set_var("LEANCTX_BOOTSTRAP_UV", &fake_uv) };
677 let err = ensure_installed(&install).expect_err("manager failed");
678 assert!(err.contains("failed"), "got: {err}");
679
680 unsafe { std::env::remove_var("LEANCTX_BOOTSTRAP_UV") };
682 let _ = std::fs::remove_dir_all(&tmp);
683 }
684
685 #[test]
686 fn install_hint_is_present_for_every_manager() {
687 for m in [
688 Manager::Uv,
689 Manager::Pip,
690 Manager::Cargo,
691 Manager::Npm,
692 Manager::Brew,
693 Manager::Dotnet,
694 ] {
695 assert!(!m.install_hint().is_empty(), "{m:?} needs an install hint");
696 }
697 }
698
699 #[test]
700 #[cfg(unix)]
701 fn ensure_installed_preflights_a_missing_manager() {
702 let _guard = ENV_LOCK.lock().unwrap();
703 let tmp = std::env::temp_dir().join(format!("leanctx-boot-miss-{}", std::process::id()));
704 std::fs::create_dir_all(&tmp).unwrap();
705 let missing = tmp.join("uv-not-here");
706
707 let mut install = declared("uv", "demo-pkg", "1.0.0");
708 install.verify = vec!["false".into()]; unsafe { std::env::set_var("LEANCTX_BOOTSTRAP_UV", &missing) };
713 let err = ensure_installed(&install).expect_err("missing manager rejected");
714
715 unsafe { std::env::remove_var("LEANCTX_BOOTSTRAP_UV") };
717 let _ = std::fs::remove_dir_all(&tmp);
718
719 assert!(err.contains("uv"), "names the manager: {err}");
720 assert!(err.contains("not installed"), "explains why: {err}");
721 assert!(err.contains("astral.sh"), "gives an install hint: {err}");
722 }
723}