Skip to main content

lean_ctx/core/addons/
bootstrap.rs

1//! Addon bootstrap engine (#1105, Phase 2): install an addon's upstream package
2//! through a *real* package manager as part of `addon add`, idempotently and
3//! with mandatory version pinning — then uninstall it on `addon remove`.
4//!
5//! Security model — the engine **never** goes through a shell. Each supported
6//! [`Manager`] owns its argv template; the manifest only supplies `package` +
7//! `version` (validated to be pinned and free of shell metacharacters), and the
8//! engine inserts them as *discrete* argv elements via [`std::process::Command`].
9//! Because there is no string interpolation into a shell, a hostile registry
10//! entry cannot inject a command — the worst it can do is name a different
11//! package, which is already disclosed in the install preview and audited.
12//!
13//! The manager binary is resolved from `PATH` by default, or pinned to an exact
14//! path via `LEANCTX_BOOTSTRAP_<MANAGER>` (e.g. `LEANCTX_BOOTSTRAP_UV=/opt/uv`)
15//! for locked-down / enterprise environments.
16
17use std::process::{Command, Stdio};
18
19use serde::{Deserialize, Serialize};
20
21/// A supported package manager. The set is closed on purpose: the engine only
22/// runs managers whose install/uninstall argv it fully controls.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Manager {
25    /// Astral `uv` — `uv tool install <pkg>==<ver>` (Python CLIs).
26    Uv,
27    /// `pip` — `pip install --user <pkg>==<ver>` (Python libraries/CLIs).
28    Pip,
29    /// `cargo` — `cargo install <pkg> --version <ver>` (Rust binaries).
30    Cargo,
31    /// `npm` — `npm install -g <pkg>@<ver>` (Node CLIs).
32    Npm,
33    /// Homebrew — `brew install <formula>` (version pinned via the formula name,
34    /// e.g. `node@22`; the `version` field documents the expected version).
35    Brew,
36    /// .NET SDK — `dotnet tool install --global <pkg> --version <ver>` (.NET
37    /// global tools published to NuGet, e.g. `CodeCompress.Server`).
38    Dotnet,
39}
40
41impl Manager {
42    /// Parse a manager slug from a manifest, case-insensitively. Unknown → `None`.
43    #[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    /// Canonical slug (also the default executable name).
57    #[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    /// The executable to invoke — the `LEANCTX_BOOTSTRAP_<MANAGER>` override if
70    /// set + non-empty, otherwise the manager's name (resolved via `PATH`).
71    #[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    /// A short, actionable hint for installing this manager when it is absent —
81    /// surfaced by the `addon add` pre-flight so a missing manager fails with a
82    /// "here's how to get it" message rather than a raw spawn error.
83    #[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    /// Whether the manager's executable can actually be launched: its
96    /// `LEANCTX_BOOTSTRAP_<MANAGER>` override path is executable, or its bare
97    /// name resolves on `PATH`. Lets `addon add` pre-flight a missing manager.
98    #[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    /// argv to install `package` pinned to `version`. Engine-owned; `package`
109    /// and `version` are discrete elements (never concatenated into a shell).
110    #[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            // Homebrew cannot install an arbitrary historical version; the
125            // formula name carries the pin (`node@22`), so we install it verbatim.
126            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    /// argv to uninstall the package previously installed by [`Self::install_argv`].
139    #[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/// The `[install]` block — how `addon add` provisions the addon's upstream
158/// package before wiring its `[mcp]` server. Absent (all-empty) ⇒ the addon is
159/// either an ephemeral runner (`npx`/`uvx`, installs lazily on first spawn) or
160/// already on the host; no bootstrap runs.
161#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(default)]
163pub struct AddonInstall {
164    /// Package manager: `uv` | `pip` | `cargo` | `npm` | `brew` | `dotnet`.
165    pub manager: String,
166    /// Package/formula to install (may carry extras, e.g. `headroom-ai[all]`).
167    pub package: String,
168    /// Exact pinned version (mandatory; floating/`latest` is rejected).
169    pub version: String,
170    /// Executable the install provides — the `[mcp].command`. Used for the
171    /// idempotency check (skip if already on PATH). Defaults to the package name.
172    pub bin: String,
173    /// Optional explicit "is it installed?" probe (argv; exit 0 ⇒ installed).
174    /// Overrides the default PATH check; never run through a shell.
175    pub verify: Vec<String>,
176}
177
178impl AddonInstall {
179    /// `true` when the block actually requests a bootstrap install.
180    #[must_use]
181    pub fn is_declared(&self) -> bool {
182        !self.manager.trim().is_empty() && !self.package.trim().is_empty()
183    }
184
185    /// Inverse of [`Self::is_declared`] — for `#[serde(skip_serializing_if)]`.
186    #[must_use]
187    pub fn is_absent(&self) -> bool {
188        !self.is_declared()
189    }
190
191    /// The parsed manager, if declared and supported.
192    #[must_use]
193    pub fn manager(&self) -> Option<Manager> {
194        self.is_declared()
195            .then(|| Manager::parse(&self.manager))
196            .flatten()
197    }
198
199    /// The executable name to probe on PATH — explicit `bin`, else the package
200    /// base name (extras + version stripped).
201    #[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    /// Validate the block: known manager, non-empty package, an exact pin, and
212    /// no shell metacharacters anywhere (defence-in-depth). A no-op when absent.
213    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    /// A receipt recording exactly what was installed, for a clean uninstall.
251    #[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    /// The exact install argv (for the disclosure preview). Empty if unsupported.
262    #[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    /// The exact uninstall argv (for the disclosure preview). Empty if unsupported.
270    #[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    /// Whether the package already appears installed: the explicit `verify`
278    /// probe (exit 0), else the `bin` resolving on PATH.
279    #[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/// What `[install]` actually did, persisted in `installed.json` so `remove` can
289/// uninstall exactly what `add` installed.
290#[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/// Outcome of [`ensure_installed`].
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum BootstrapStatus {
307    /// The package was already present — nothing ran (idempotent).
308    AlreadyPresent,
309    /// The package manager ran and installed it.
310    Installed,
311}
312
313/// Result of a successful [`ensure_installed`].
314#[derive(Debug, Clone)]
315pub struct BootstrapOutcome {
316    pub status: BootstrapStatus,
317    pub receipt: InstallReceipt,
318    /// A non-fatal note (e.g. installed but the bin is not yet on PATH).
319    pub warning: Option<String>,
320}
321
322/// Provision `install`'s package idempotently. Returns immediately if it is
323/// already satisfied; otherwise runs the manager (streaming its output so the
324/// user sees real progress) and re-checks. A non-zero manager exit is an error;
325/// a clean exit whose binary is not yet on PATH is a non-fatal warning.
326///
327/// Spawns a subprocess — only ever called from the interactive CLI layer after
328/// the user has consented; the core `install::install` stays pure.
329pub 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    // Pre-flight: the package is absent, so the manager must run — verify it
345    // exists first and fail with an install hint instead of a raw spawn error.
346    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
379/// Uninstall a previously bootstrapped package (best-effort; the caller logs a
380/// note on failure rather than blocking the unwire).
381pub 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
394/// Strip a package spec down to the bare name a manager uninstalls by: drop
395/// extras (`pkg[all]` → `pkg`) and any inline version (`pkg==1` / `pkg@1`).
396fn 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    // Trim a trailing `@version` but keep an npm scope (`@scope/pkg`).
401    match p.rsplit_once('@') {
402        Some((head, _)) if !head.is_empty() => head,
403        _ => p,
404    }
405    .trim()
406}
407
408/// Run a manager command, inheriting stdio so the user sees live progress.
409fn 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
426/// Run a quiet probe (stdio suppressed); `true` iff it exits 0.
427fn 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
437/// Whether `bin` resolves to an executable on `PATH`.
438fn 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
459/// Shell metacharacters that would matter if (and only if) the value ever
460/// reached a shell. We never use a shell — this is defence-in-depth so a
461/// hostile registry entry is rejected loudly rather than silently tolerated.
462fn has_shell_meta(s: &str) -> bool {
463    s.chars()
464        .any(|c| matches!(c, '|' | ';' | '&' | '`' | '>' | '<' | '\n' | '\r'))
465        || s.contains("$(")
466}
467
468/// Whether a version string is a floating/`latest` tag rather than an exact pin.
469fn 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    /// Serialises the few tests that set a `LEANCTX_BOOTSTRAP_*` override, since
481    /// process environment is global. No other test reads these vars. Unix-only:
482    /// the env-override tests it guards are themselves `#[cfg(unix)]`.
483    #[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        // Extras brackets are not shell metacharacters → accepted.
519        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        // A tool present on every host of the platform; a random name never does.
613        #[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    /// Write an executable shell script at `path` with `body`. Unix-only: the
620    /// bootstrap executor tests it drives are themselves `#[cfg(unix)]`.
621    #[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
634            .lock()
635            .unwrap_or_else(std::sync::PoisonError::into_inner);
636        let tmp = std::env::temp_dir().join(format!("leanctx-boot-{}", std::process::id()));
637        std::fs::create_dir_all(&tmp).unwrap();
638        let marker = tmp.join("installed.marker");
639        let fake_uv = tmp.join("uv");
640        // Fake `uv`: create the marker the verify probe checks for.
641        write_script(&fake_uv, &format!("touch '{}'", marker.display()));
642
643        let mut install = declared("uv", "demo-pkg", "1.0.0");
644        install.verify = vec!["test".into(), "-f".into(), marker.display().to_string()];
645
646        // SAFETY: guarded by ENV_LOCK; restored below.
647        unsafe { std::env::set_var("LEANCTX_BOOTSTRAP_UV", &fake_uv) };
648        let _ = std::fs::remove_file(&marker);
649
650        let out = ensure_installed(&install).expect("install");
651        assert_eq!(out.status, BootstrapStatus::Installed);
652        assert!(out.warning.is_none(), "verify passed → no warning");
653        assert!(marker.exists(), "fake manager ran");
654
655        // Second run is idempotent — marker already there, manager not re-run.
656        std::fs::remove_file(&fake_uv).unwrap(); // would error if invoked
657        let out2 = ensure_installed(&install).expect("idempotent");
658        assert_eq!(out2.status, BootstrapStatus::AlreadyPresent);
659
660        // SAFETY: guarded by ENV_LOCK; clears the override set above.
661        unsafe { std::env::remove_var("LEANCTX_BOOTSTRAP_UV") };
662        let _ = std::fs::remove_dir_all(&tmp);
663    }
664
665    #[test]
666    #[cfg(unix)]
667    fn ensure_installed_propagates_manager_failure() {
668        let _guard = ENV_LOCK
669            .lock()
670            .unwrap_or_else(std::sync::PoisonError::into_inner);
671        let tmp = std::env::temp_dir().join(format!("leanctx-boot-fail-{}", std::process::id()));
672        std::fs::create_dir_all(&tmp).unwrap();
673        let fake_uv = tmp.join("uv");
674        write_script(&fake_uv, "exit 1");
675
676        let mut install = declared("uv", "demo-pkg", "1.0.0");
677        install.verify = vec!["false".into()]; // never satisfied
678
679        // SAFETY: guarded by ENV_LOCK; restored below.
680        unsafe { std::env::set_var("LEANCTX_BOOTSTRAP_UV", &fake_uv) };
681        let err = ensure_installed(&install).expect_err("manager failed");
682        assert!(err.contains("failed"), "got: {err}");
683
684        // SAFETY: guarded by ENV_LOCK; clears the override set above.
685        unsafe { std::env::remove_var("LEANCTX_BOOTSTRAP_UV") };
686        let _ = std::fs::remove_dir_all(&tmp);
687    }
688
689    #[test]
690    fn install_hint_is_present_for_every_manager() {
691        for m in [
692            Manager::Uv,
693            Manager::Pip,
694            Manager::Cargo,
695            Manager::Npm,
696            Manager::Brew,
697            Manager::Dotnet,
698        ] {
699            assert!(!m.install_hint().is_empty(), "{m:?} needs an install hint");
700        }
701    }
702
703    #[test]
704    #[cfg(unix)]
705    fn ensure_installed_preflights_a_missing_manager() {
706        let _guard = ENV_LOCK
707            .lock()
708            .unwrap_or_else(std::sync::PoisonError::into_inner);
709        let tmp = std::env::temp_dir().join(format!("leanctx-boot-miss-{}", std::process::id()));
710        std::fs::create_dir_all(&tmp).unwrap();
711        let missing = tmp.join("uv-not-here");
712
713        let mut install = declared("uv", "demo-pkg", "1.0.0");
714        install.verify = vec!["false".into()]; // never satisfied → must install
715
716        // SAFETY: guarded by ENV_LOCK; points the manager at a non-existent path
717        // so the pre-flight must reject *before* any spawn is attempted.
718        unsafe { std::env::set_var("LEANCTX_BOOTSTRAP_UV", &missing) };
719        let err = ensure_installed(&install).expect_err("missing manager rejected");
720
721        // SAFETY: guarded by ENV_LOCK; clears the override set above.
722        unsafe { std::env::remove_var("LEANCTX_BOOTSTRAP_UV") };
723        let _ = std::fs::remove_dir_all(&tmp);
724
725        assert!(err.contains("uv"), "names the manager: {err}");
726        assert!(err.contains("not installed"), "explains why: {err}");
727        assert!(err.contains("astral.sh"), "gives an install hint: {err}");
728    }
729}