Skip to main content

horto_os_ui_shared/remote/
bins.rs

1//! Local Release cache and `--bin-dir` resolution for box binaries.
2
3use super::arch::BoxArch;
4use super::process::{CommandOutput, ProcessRunner, StdioMode};
5use crate::error::{HortoError, Result};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Binaries that must be present for remote / embedded install to proceed.
10pub const REQUIRED_BOX_BIN_NAMES: [&str; 3] =
11    ["horto-os-ui", "horto-os-ui-tui", "horto-os-ui-status-api"];
12
13/// Filenames expected in a full box Release tar (MCP may be absent).
14pub const BOX_BIN_NAMES: [&str; 4] = [
15    "horto-os-ui",
16    "horto-os-ui-tui",
17    "horto-os-ui-status-api",
18    "horto-os-ui-mcp",
19];
20
21/// Paths to box binaries after cache extract or `--bin-dir`.
22#[derive(Debug, Clone)]
23pub struct LocalBins {
24    /// Directory that contains the binaries (source or install layout).
25    pub dir: PathBuf,
26    /// CLI apply agent.
27    pub cli: PathBuf,
28    /// TUI binary.
29    pub tui: PathBuf,
30    /// Status API binary.
31    pub status_api: PathBuf,
32    /// MCP server binary when present in the Release / bin dir.
33    pub mcp: Option<PathBuf>,
34}
35
36fn warn_mcp_binary_missing(dir: &Path) {
37    let msg = format!(
38        "horto-os-ui-mcp not found under {} — continuing without MCP on the box",
39        dir.display()
40    );
41    tracing::warn!("{msg}");
42    eprintln!("[horto remote] warning: {msg}");
43}
44
45/// Release tarball file name for a version + target triple.
46#[must_use]
47pub fn asset_name(version: &str, arch: BoxArch) -> String {
48    format!("horto-os-ui-{version}-{}.tar.gz", arch.target_triple())
49}
50
51/// Default GitHub Release tag for a Cargo version (`0.1.0` → `v0.1.0`).
52#[must_use]
53pub fn default_release_tag(version: &str) -> String {
54    format!("v{version}")
55}
56
57/// GitHub Release download URL for a box tar.gz asset.
58///
59/// `release_tag` is the GitHub tag (`v0.1.0` or tip Pre-release `dev-preview`).
60/// The asset **filename** still embeds `version` (Cargo workspace version).
61#[must_use]
62pub fn release_download_url(repo: &str, release_tag: &str, version: &str, arch: BoxArch) -> String {
63    let name = asset_name(version, arch);
64    format!("https://github.com/{repo}/releases/download/{release_tag}/{name}")
65}
66
67/// Whether a Release tag is treated as immutable for the local remote-bins cache.
68///
69/// Stable tags look like `v0.1.0` (leading `v` + digit). Tip tags such as
70/// `dev-preview` are overwritten in place with the same asset filenames, so a
71/// warm cache would keep stale tip binaries forever.
72#[must_use]
73pub fn release_tag_is_immutable(release_tag: &str) -> bool {
74    let mut chars = release_tag.chars();
75    matches!(chars.next(), Some('v')) && matches!(chars.next(), Some(c) if c.is_ascii_digit())
76}
77
78/// Default XDG cache root: `$XDG_CACHE_HOME/horto-os-ui/remote-bins` or `~/.cache/...`.
79#[must_use]
80pub fn default_cache_root() -> PathBuf {
81    let base = std::env::var_os("XDG_CACHE_HOME")
82        .map(PathBuf::from)
83        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
84        .unwrap_or_else(|| PathBuf::from("/tmp"));
85    base.join("horto-os-ui").join("remote-bins")
86}
87
88/// Cache directory for one release tag + version + arch.
89#[must_use]
90pub fn cache_bin_dir(
91    cache_root: &Path,
92    release_tag: &str,
93    version: &str,
94    arch: BoxArch,
95) -> PathBuf {
96    cache_root
97        .join(release_tag)
98        .join(version)
99        .join(arch.cache_label())
100}
101
102fn bins_from_dir(dir: &Path) -> Result<LocalBins> {
103    let cli = dir.join("horto-os-ui");
104    let tui = dir.join("horto-os-ui-tui");
105    let status_api = dir.join("horto-os-ui-status-api");
106    let mcp_path = dir.join("horto-os-ui-mcp");
107    for p in [&cli, &tui, &status_api] {
108        if !p.is_file() {
109            return Err(HortoError::msg(format!(
110                "missing binary {} (required: {})",
111                p.display(),
112                REQUIRED_BOX_BIN_NAMES.join(", ")
113            )));
114        }
115    }
116    let mcp = if mcp_path.is_file() {
117        Some(mcp_path)
118    } else {
119        warn_mcp_binary_missing(dir);
120        None
121    };
122    Ok(LocalBins {
123        dir: dir.to_path_buf(),
124        cli,
125        tui,
126        status_api,
127        mcp,
128    })
129}
130
131fn first_existing(candidates: &[PathBuf]) -> Option<PathBuf> {
132    candidates.iter().find(|p| p.is_file()).cloned()
133}
134
135fn which_bin(name: &str) -> Option<PathBuf> {
136    which::which(name).ok()
137}
138
139/// Resolve ecosystem binaries for embedded install.
140///
141/// Prefers a complete sibling directory next to the running executable, then a
142/// complete `install_dir`, then a merge of sibling / `install_dir` / `PATH`.
143/// MCP is optional: missing `horto-os-ui-mcp` warns and continues.
144///
145/// # Errors
146///
147/// Returns [`crate::HortoError`] when CLI, TUI, or status-api cannot be found.
148pub fn resolve_local_ecosystem_bins(install_dir: &Path) -> Result<LocalBins> {
149    if let Ok(exe) = std::env::current_exe() {
150        if let Some(dir) = exe.parent() {
151            if let Ok(bins) = bins_from_dir(dir) {
152                return Ok(bins);
153            }
154        }
155    }
156    if let Ok(bins) = bins_from_dir(install_dir) {
157        return Ok(bins);
158    }
159
160    let sibling = std::env::current_exe()
161        .ok()
162        .and_then(|e| e.parent().map(Path::to_path_buf));
163    let dir = sibling.clone().unwrap_or_else(|| install_dir.to_path_buf());
164
165    let pick = |name: &str| -> Result<PathBuf> {
166        let mut cands = Vec::new();
167        if let Some(ref sib) = sibling {
168            cands.push(sib.join(name));
169        }
170        cands.push(install_dir.join(name));
171        if let Some(p) = which_bin(name) {
172            cands.push(p);
173        }
174        first_existing(&cands).ok_or_else(|| {
175            HortoError::msg(format!(
176                "missing binary {name} (looked next to exe, in {}, and on PATH)",
177                install_dir.display()
178            ))
179        })
180    };
181
182    let mcp = {
183        let mut cands = Vec::new();
184        if let Some(ref sib) = sibling {
185            cands.push(sib.join("horto-os-ui-mcp"));
186        }
187        cands.push(install_dir.join("horto-os-ui-mcp"));
188        if let Some(p) = which_bin("horto-os-ui-mcp") {
189            cands.push(p);
190        }
191        first_existing(&cands).or_else(|| {
192            warn_mcp_binary_missing(&dir);
193            None
194        })
195    };
196
197    Ok(LocalBins {
198        dir,
199        cli: pick("horto-os-ui")?,
200        tui: pick("horto-os-ui-tui")?,
201        status_api: pick("horto-os-ui-status-api")?,
202        mcp,
203    })
204}
205
206fn require_ok(program: &str, out: &CommandOutput) -> Result<()> {
207    if out.success() {
208        return Ok(());
209    }
210    let detail = if out.stderr.trim().is_empty() {
211        out.stdout.trim().to_owned()
212    } else {
213        out.stderr.trim().to_owned()
214    };
215    Err(HortoError::command(
216        program,
217        format!("exit {}: {detail}", out.status),
218    ))
219}
220
221/// Resolve box binaries from `--bin-dir` or download+extract a Release tar.gz.
222///
223/// # Errors
224///
225/// Returns [`crate::HortoError`] when paths are missing, download fails, or extract fails.
226pub fn ensure_local_bins(
227    runner: &dyn ProcessRunner,
228    release_tag: &str,
229    version: &str,
230    repo: &str,
231    arch: BoxArch,
232    bin_dir: Option<&Path>,
233    cache_root: &Path,
234) -> Result<LocalBins> {
235    if let Some(dir) = bin_dir {
236        return bins_from_dir(dir);
237    }
238
239    let dest = cache_bin_dir(cache_root, release_tag, version, arch);
240    let marker = dest.join("horto-os-ui");
241    if marker.is_file() && release_tag_is_immutable(release_tag) {
242        return bins_from_dir(&dest);
243    }
244
245    if dest.exists() {
246        fs::remove_dir_all(&dest)?;
247    }
248    fs::create_dir_all(&dest)?;
249    let url = release_download_url(repo, release_tag, version, arch);
250    let tarball = dest.join(asset_name(version, arch));
251    let curl_out = runner.run(
252        "curl",
253        &[
254            "-fsSL",
255            "-o",
256            tarball
257                .to_str()
258                .ok_or_else(|| HortoError::msg("non-utf8 path"))?,
259            &url,
260        ],
261        &[],
262        StdioMode::Capture,
263    )?;
264    require_ok("curl", &curl_out)?;
265
266    let dest_str = dest
267        .to_str()
268        .ok_or_else(|| HortoError::msg("non-utf8 cache path"))?;
269    let tar_out = runner.run(
270        "tar",
271        &[
272            "-xzf",
273            tarball
274                .to_str()
275                .ok_or_else(|| HortoError::msg("non-utf8 tarball path"))?,
276            "-C",
277            dest_str,
278            "--strip-components=1",
279        ],
280        &[],
281        StdioMode::Capture,
282    )?;
283    require_ok("tar", &tar_out)?;
284    bins_from_dir(&dest)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::remote::process::{CommandOutput, ProcessRunner, ScriptedRunner, StdioMode};
291    use std::path::PathBuf;
292    use tempfile::TempDir;
293
294    /// Scripted runner that materializes box binaries when `tar` is invoked.
295    struct ExtractRunner {
296        inner: ScriptedRunner,
297        dest: PathBuf,
298        payload: &'static [u8],
299    }
300
301    impl ProcessRunner for ExtractRunner {
302        fn run(
303            &self,
304            program: &str,
305            args: &[&str],
306            env: &[(&str, &str)],
307            stdio: StdioMode,
308        ) -> crate::error::Result<CommandOutput> {
309            let out = self.inner.run(program, args, env, stdio)?;
310            if program == "tar" {
311                fs::create_dir_all(&self.dest).unwrap();
312                for name in [
313                    "horto-os-ui",
314                    "horto-os-ui-tui",
315                    "horto-os-ui-status-api",
316                    "horto-os-ui-mcp",
317                ] {
318                    fs::write(self.dest.join(name), self.payload).unwrap();
319                }
320            }
321            Ok(out)
322        }
323    }
324
325    #[test]
326    fn asset_and_url_shape() {
327        let name = asset_name("0.1.0", BoxArch::Amd64);
328        assert_eq!(name, "horto-os-ui-0.1.0-x86_64-unknown-linux-gnu.tar.gz");
329        assert_eq!(default_release_tag("0.1.0"), "v0.1.0");
330        let url = release_download_url(
331            "Hortos-Network/horto-os-ui",
332            "v0.1.0",
333            "0.1.0",
334            BoxArch::Arm64,
335        );
336        assert!(url.contains("/download/v0.1.0/"));
337        assert!(url.ends_with("aarch64-unknown-linux-gnu.tar.gz"));
338        let tip = release_download_url(
339            "Hortos-Network/horto-os-ui",
340            "dev-preview",
341            "0.1.0",
342            BoxArch::Amd64,
343        );
344        assert!(tip.contains("/download/dev-preview/"));
345        assert!(tip.contains("horto-os-ui-0.1.0-x86_64-unknown-linux-gnu.tar.gz"));
346        assert!(release_tag_is_immutable("v0.1.0"));
347        assert!(release_tag_is_immutable("v1.2.3-rc.1"));
348        assert!(!release_tag_is_immutable("dev-preview"));
349        assert!(!release_tag_is_immutable("latest"));
350        assert!(!release_tag_is_immutable("0.1.0"));
351    }
352
353    #[test]
354    fn bin_dir_requires_core_three_mcp_optional() {
355        let tmp = TempDir::new().unwrap();
356        let err = bins_from_dir(tmp.path()).unwrap_err();
357        assert!(err.to_string().contains("missing binary"));
358
359        for name in REQUIRED_BOX_BIN_NAMES {
360            fs::write(tmp.path().join(name), b"x").unwrap();
361        }
362        let bins = bins_from_dir(tmp.path()).unwrap();
363        assert!(bins.cli.ends_with("horto-os-ui"));
364        assert!(bins.mcp.is_none());
365
366        fs::write(tmp.path().join("horto-os-ui-mcp"), b"x").unwrap();
367        let bins = bins_from_dir(tmp.path()).unwrap();
368        assert!(bins
369            .mcp
370            .as_ref()
371            .is_some_and(|p| p.ends_with("horto-os-ui-mcp")));
372    }
373
374    #[test]
375    fn ensure_uses_bin_dir_without_curl() {
376        let tmp = TempDir::new().unwrap();
377        for name in [
378            "horto-os-ui",
379            "horto-os-ui-tui",
380            "horto-os-ui-status-api",
381            "horto-os-ui-mcp",
382        ] {
383            fs::write(tmp.path().join(name), b"x").unwrap();
384        }
385        let runner = ScriptedRunner::default();
386        let bins = ensure_local_bins(
387            &runner,
388            "v0.1.0",
389            "0.1.0",
390            "Hortos-Network/horto-os-ui",
391            BoxArch::Amd64,
392            Some(tmp.path()),
393            tmp.path(),
394        )
395        .unwrap();
396        assert_eq!(bins.dir, tmp.path());
397        assert_eq!(runner.calls.lock().unwrap().as_slice(), &[]);
398    }
399
400    #[test]
401    fn ensure_downloads_when_cache_cold() {
402        let tmp = TempDir::new().unwrap();
403        let cache = tmp.path().join("cache");
404        let runner = ScriptedRunner::default();
405        runner.push("curl", ScriptedRunner::ok(""));
406        runner.push("tar", ScriptedRunner::ok(""));
407
408        // Pre-create binaries as if tar extracted them (tar is scripted as success).
409        let dest = cache_bin_dir(&cache, "v0.1.0", "0.1.0", BoxArch::Amd64);
410        fs::create_dir_all(&dest).unwrap();
411        for name in [
412            "horto-os-ui",
413            "horto-os-ui-tui",
414            "horto-os-ui-status-api",
415            "horto-os-ui-mcp",
416        ] {
417            fs::write(dest.join(name), b"x").unwrap();
418        }
419
420        // Cold cache: delete marker so download path runs, but recreate after curl/tar scripts.
421        // Simpler: run ensure with empty dest and have tar script also write files via side effect
422        // before bins_from_dir - we already wrote files; ensure checks marker first.
423        // Wipe and re-run download path:
424        let _ = fs::remove_dir_all(&dest);
425        fs::create_dir_all(&dest).unwrap();
426        // After curl+tar, ensure calls bins_from_dir - write files now simulating tar.
427        // Race: ensure creates dest, curls, tars, then bins_from_dir. We need files after tar.
428        // Scripted tar just returns ok - so write files in a custom way:
429        // Call ensure when cache already has files after we push scripts that "extract".
430        // Re-write approach: push curl/tar, and between them we can't inject. So write files
431        // before ensure, but then marker exists and skip download.
432        // Instead test download failure and success of curl args only via a unit that
433        // doesn't need extract:
434
435        let runner2 = ScriptedRunner::default();
436        runner2.push("curl", ScriptedRunner::fail(22, "404"));
437        let err = ensure_local_bins(
438            &runner2,
439            "v0.1.0",
440            "0.1.0",
441            "Hortos-Network/horto-os-ui",
442            BoxArch::Amd64,
443            None,
444            &cache,
445        )
446        .unwrap_err();
447        assert!(err.to_string().contains("curl"));
448        let calls = runner2.calls.lock().unwrap();
449        assert_eq!(calls[0].0, "curl");
450        assert!(calls[0].1.iter().any(|a| a.contains("github.com")));
451        drop(calls);
452    }
453
454    #[test]
455    fn ensure_uses_warm_cache_without_download() {
456        let tmp = TempDir::new().unwrap();
457        let cache = tmp.path().join("cache");
458        let dest = cache_bin_dir(&cache, "v0.1.0", "0.1.0", BoxArch::Amd64);
459        fs::create_dir_all(&dest).unwrap();
460        for name in [
461            "horto-os-ui",
462            "horto-os-ui-tui",
463            "horto-os-ui-status-api",
464            "horto-os-ui-mcp",
465        ] {
466            fs::write(dest.join(name), b"x").unwrap();
467        }
468        let runner = ScriptedRunner::default();
469        let bins = ensure_local_bins(
470            &runner,
471            "v0.1.0",
472            "0.1.0",
473            "Hortos-Network/horto-os-ui",
474            BoxArch::Amd64,
475            None,
476            &cache,
477        )
478        .unwrap();
479        assert_eq!(bins.dir, dest);
480        assert_eq!(runner.calls.lock().unwrap().as_slice(), &[]);
481    }
482
483    #[test]
484    fn ensure_tip_tag_redownloads_over_warm_cache() {
485        let tmp = TempDir::new().unwrap();
486        let cache = tmp.path().join("cache");
487        let dest = cache_bin_dir(&cache, "dev-preview", "0.1.0", BoxArch::Arm64);
488        fs::create_dir_all(&dest).unwrap();
489        for name in [
490            "horto-os-ui",
491            "horto-os-ui-tui",
492            "horto-os-ui-status-api",
493            "horto-os-ui-mcp",
494        ] {
495            fs::write(dest.join(name), b"stale").unwrap();
496        }
497
498        let inner = ScriptedRunner::default();
499        inner.push("curl", ScriptedRunner::ok(""));
500        inner.push("tar", ScriptedRunner::ok(""));
501        let runner = ExtractRunner {
502            inner,
503            dest: dest.clone(),
504            payload: b"fresh",
505        };
506        let bins = ensure_local_bins(
507            &runner,
508            "dev-preview",
509            "0.1.0",
510            "Hortos-Network/horto-os-ui",
511            BoxArch::Arm64,
512            None,
513            &cache,
514        )
515        .unwrap();
516        assert_eq!(bins.dir, dest);
517        assert_eq!(fs::read(dest.join("horto-os-ui")).unwrap(), b"fresh");
518        let calls = runner.inner.calls.lock().unwrap();
519        assert_eq!(calls[0].0, "curl");
520        assert!(calls[0]
521            .1
522            .iter()
523            .any(|a| a.contains("/download/dev-preview/")));
524        assert_eq!(calls[1].0, "tar");
525        drop(calls);
526    }
527
528    #[test]
529    fn ensure_download_and_extract_success() {
530        let tmp = TempDir::new().unwrap();
531        let cache = tmp.path().join("cache");
532        let dest = cache_bin_dir(&cache, "dev-preview", "0.2.0", BoxArch::Arm64);
533        let inner = ScriptedRunner::default();
534        inner.push("curl", ScriptedRunner::ok(""));
535        inner.push("tar", ScriptedRunner::ok(""));
536        let extract = ExtractRunner {
537            inner,
538            dest,
539            payload: b"x",
540        };
541        let bins = ensure_local_bins(
542            &extract,
543            "dev-preview",
544            "0.2.0",
545            "Hortos-Network/horto-os-ui",
546            BoxArch::Arm64,
547            None,
548            &cache,
549        )
550        .unwrap();
551        assert!(bins.cli.is_file());
552    }
553
554    #[test]
555    fn tar_failure_uses_stderr_detail() {
556        let tmp = TempDir::new().unwrap();
557        let cache = tmp.path().join("cache2");
558        let runner = ScriptedRunner::default();
559        runner.push("curl", ScriptedRunner::ok(""));
560        runner.push("tar", ScriptedRunner::fail(2, "bad archive"));
561        let err = ensure_local_bins(
562            &runner,
563            "v0.3.0",
564            "0.3.0",
565            "Hortos-Network/horto-os-ui",
566            BoxArch::Amd64,
567            None,
568            &cache,
569        )
570        .unwrap_err();
571        assert!(err.to_string().contains("tar"));
572        assert!(err.to_string().contains("bad archive"));
573    }
574
575    #[test]
576    fn default_cache_root_non_empty() {
577        assert_ne!(default_cache_root().as_os_str(), "");
578    }
579
580    #[test]
581    fn resolve_local_ecosystem_bins_from_install_dir() {
582        let tmp = TempDir::new().unwrap();
583        for name in BOX_BIN_NAMES {
584            fs::write(tmp.path().join(name), b"x").unwrap();
585        }
586        let bins = resolve_local_ecosystem_bins(tmp.path()).unwrap();
587        assert_eq!(bins.dir, tmp.path());
588        assert!(bins
589            .mcp
590            .as_ref()
591            .is_some_and(|p| p.ends_with("horto-os-ui-mcp")));
592    }
593
594    #[test]
595    fn resolve_local_ecosystem_bins_ok_without_mcp() {
596        let tmp = TempDir::new().unwrap();
597        for name in REQUIRED_BOX_BIN_NAMES {
598            fs::write(tmp.path().join(name), b"x").unwrap();
599        }
600        let bins = resolve_local_ecosystem_bins(tmp.path()).unwrap();
601        assert!(bins.mcp.is_none());
602        assert!(bins.cli.is_file());
603    }
604
605    #[test]
606    fn resolve_local_ecosystem_bins_errors_when_incomplete() {
607        let tmp = TempDir::new().unwrap();
608        fs::write(tmp.path().join("horto-os-ui"), b"x").unwrap();
609        let err = resolve_local_ecosystem_bins(tmp.path()).unwrap_err();
610        assert!(err.to_string().contains("missing"));
611    }
612
613    #[test]
614    fn cache_bin_dir_layout() {
615        let root = PathBuf::from("/tmp/cache");
616        let p = cache_bin_dir(&root, "v0.1.0", "0.1.0", BoxArch::Amd64);
617        assert!(p.to_string_lossy().contains("v0.1.0"));
618        assert!(p.to_string_lossy().contains("0.1.0"));
619    }
620}