1use super::arch::BoxArch;
4use super::process::{CommandOutput, ProcessRunner, StdioMode};
5use crate::error::{HortoError, Result};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9pub const REQUIRED_BOX_BIN_NAMES: [&str; 3] =
11 ["horto-os-ui", "horto-os-ui-tui", "horto-os-ui-status-api"];
12
13pub 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#[derive(Debug, Clone)]
23pub struct LocalBins {
24 pub dir: PathBuf,
26 pub cli: PathBuf,
28 pub tui: PathBuf,
30 pub status_api: PathBuf,
32 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#[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#[must_use]
53pub fn default_release_tag(version: &str) -> String {
54 format!("v{version}")
55}
56
57#[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#[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#[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#[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
139pub 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
221pub 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 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 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 let _ = fs::remove_dir_all(&dest);
425 fs::create_dir_all(&dest).unwrap();
426 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}