1use super::bins::{resolve_local_ecosystem_bins, LocalBins};
4use super::process::{ProcessRunner, StdioMode};
5use crate::error::{HortoError, Result};
6use std::fmt::Write;
7use std::fs;
8use std::io::Write as IoWrite;
9use std::path::{Path, PathBuf};
10
11pub const DEFAULT_INSTALL_DIR: &str = "/usr/local/bin";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub struct EcosystemInstallChoice {
17 pub status_api: bool,
19 pub mcp: bool,
21}
22
23impl EcosystemInstallChoice {
24 #[must_use]
26 pub const fn none() -> Self {
27 Self {
28 status_api: false,
29 mcp: false,
30 }
31 }
32
33 #[must_use]
35 pub const fn any(self) -> bool {
36 self.status_api || self.mcp
37 }
38}
39
40pub const STATUS_API_UNIT: &str = r"[Unit]
42Description=Horto OS UI status API
43After=network.target
44
45[Service]
46Type=simple
47EnvironmentFile=/etc/horto-os-ui/api.env
48ExecStart=/usr/local/bin/horto-os-ui-status-api --bind 0.0.0.0:8787
49Restart=on-failure
50
51[Install]
52WantedBy=multi-user.target
53";
54
55pub const MCP_UNIT: &str =
60 include_str!("../../../horto-os-ui-mcp/packaging/horto-os-ui-mcp.service");
61
62pub const API_TOKEN_DROP_BASENAME: &str = ".horto-os-ui-api-token";
64
65pub const ENSURE_API_TOKEN_SCRIPT: &str = r#"
70set -e
71sudo mkdir -p /etc/horto-os-ui
72if [ ! -f /etc/horto-os-ui/api.env ]; then
73 TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
74 printf 'HORTO_API_TOKEN=%s\n' "$TOKEN" | sudo tee /etc/horto-os-ui/api.env >/dev/null
75 sudo chmod 600 /etc/horto-os-ui/api.env
76fi
77DROP="${HOME}/.horto-os-ui-api-token"
78sudo grep '^HORTO_API_TOKEN=' /etc/horto-os-ui/api.env > "$DROP"
79chmod 600 "$DROP"
80"#;
81
82#[must_use]
84pub fn unit_with_install_dir(unit: &str, install_dir: &str) -> String {
85 let install = install_dir.trim_end_matches('/');
86 if install == DEFAULT_INSTALL_DIR {
87 return unit.to_owned();
88 }
89 unit.replace("/usr/local/bin/", &format!("{install}/"))
90}
91
92#[must_use]
97pub fn remote_enable_ecosystem_script(
98 staging: &str,
99 install_dir: &str,
100 choice: EcosystemInstallChoice,
101) -> String {
102 let install = install_dir.trim_end_matches('/');
103 let staging = staging.trim_end_matches('/');
104 let api_unit_path = "/etc/systemd/system/horto-os-ui-status-api.service";
105 let mcp_unit_path = "/etc/systemd/system/horto-os-ui-mcp.service";
106 let api_unit = unit_with_install_dir(STATUS_API_UNIT, install);
107 let mcp_unit = unit_with_install_dir(MCP_UNIT, install);
108
109 let mut bin_names = vec!["horto-os-ui", "horto-os-ui-tui"];
110 if choice.status_api {
111 bin_names.push("horto-os-ui-status-api");
112 }
113 if choice.mcp {
114 bin_names.push("horto-os-ui-mcp");
115 }
116 let bins = bin_names
117 .iter()
118 .map(|n| format!("{staging}/{n}"))
119 .collect::<Vec<_>>()
120 .join(" ");
121
122 let mut script = String::new();
123 if choice.status_api {
124 script.push_str(ENSURE_API_TOKEN_SCRIPT);
125 script.push('\n');
126 let _ = write!(
127 script,
128 "sudo tee {api_unit_path} > /dev/null <<'HORTO_UNIT_EOF'\n{api_unit}HORTO_UNIT_EOF\n"
129 );
130 }
131 if choice.mcp {
132 let _ = write!(
133 script,
134 "sudo tee {mcp_unit_path} > /dev/null <<'HORTO_UNIT_EOF'\n{mcp_unit}HORTO_UNIT_EOF\n"
135 );
136 }
137 let _ = write!(script, "sudo install -m 755 {bins} {install}/");
138 script.push_str(" && sudo systemctl daemon-reload");
139 if choice.status_api {
140 script.push_str(" && sudo systemctl enable --now horto-os-ui-status-api.service");
141 }
142 if choice.mcp {
143 script.push_str(" && sudo systemctl enable --now horto-os-ui-mcp.service");
144 }
145 if install != DEFAULT_INSTALL_DIR {
146 script.push_str(" && sudo systemctl daemon-reload");
147 if choice.status_api {
148 script.push_str(" && sudo systemctl restart horto-os-ui-status-api.service");
149 }
150 if choice.mcp {
151 script.push_str(" && sudo systemctl restart horto-os-ui-mcp.service");
152 }
153 }
154 script
155}
156fn api_env_path(etc_root: &Path) -> PathBuf {
157 etc_root.join("horto-os-ui").join("api.env")
158}
159
160fn ensure_api_env_at(etc_root: &Path) -> Result<Option<String>> {
174 let dir = etc_root.join("horto-os-ui");
175 fs::create_dir_all(&dir)
176 .map_err(|e| HortoError::msg(format!("create {}: {e}", dir.display())))?;
177 let env_path = api_env_path(etc_root);
178 if !env_path.is_file() {
179 let token = random_hex_token();
180 write_mode_600(&env_path, &format!("HORTO_API_TOKEN={token}\n"))?;
181 }
182 let raw = fs::read_to_string(&env_path)
183 .map_err(|e| HortoError::msg(format!("read {}: {e}", env_path.display())))?;
184 Ok(parse_api_token_line(&raw))
185}
186
187fn parse_api_token_line(raw: &str) -> Option<String> {
188 let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
189 let hex = line.strip_prefix("HORTO_API_TOKEN=").unwrap_or(line).trim();
190 if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
191 return None;
192 }
193 Some(hex.to_owned())
194}
195
196fn random_hex_token() -> String {
197 use std::io::Read;
198 let mut buf = [0u8; 32];
199 if let Ok(mut f) = fs::File::open("/dev/urandom") {
200 let _ = f.read_exact(&mut buf);
201 } else {
202 use std::time::{SystemTime, UNIX_EPOCH};
203 let nanos = SystemTime::now()
204 .duration_since(UNIX_EPOCH)
205 .map_or(0, |d| d.as_nanos());
206 for (i, b) in buf.iter_mut().enumerate() {
207 *b = ((nanos >> ((i % 16) * 8)) & 0xff) as u8;
208 }
209 }
210 buf.iter()
211 .fold(String::with_capacity(buf.len() * 2), |mut s, b| {
212 let _ = write!(s, "{b:02x}");
213 s
214 })
215}
216
217fn write_mode_600(path: &Path, body: &str) -> Result<()> {
218 #[cfg(unix)]
219 {
220 use std::os::unix::fs::OpenOptionsExt;
221 let mut f = fs::OpenOptions::new()
222 .write(true)
223 .create(true)
224 .truncate(true)
225 .mode(0o600)
226 .open(path)
227 .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
228 f.write_all(body.as_bytes())
229 .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
230 }
231 #[cfg(not(unix))]
232 {
233 fs::write(path, body)
234 .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
235 }
236 Ok(())
237}
238
239fn write_unit_file(path: &Path, body: &str) -> Result<()> {
240 if let Some(parent) = path.parent() {
241 fs::create_dir_all(parent)
242 .map_err(|e| HortoError::msg(format!("create {}: {e}", parent.display())))?;
243 }
244 fs::write(path, body).map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
245 Ok(())
246}
247
248fn install_bin(src: &Path, dest: &Path) -> Result<()> {
249 if let Some(parent) = dest.parent() {
250 fs::create_dir_all(parent)
251 .map_err(|e| HortoError::msg(format!("create {}: {e}", parent.display())))?;
252 }
253 fs::copy(src, dest).map_err(|e| {
254 HortoError::msg(format!(
255 "install {} → {}: {e}",
256 src.display(),
257 dest.display()
258 ))
259 })?;
260 #[cfg(unix)]
261 {
262 use std::os::unix::fs::PermissionsExt;
263 fs::set_permissions(dest, fs::Permissions::from_mode(0o755))
264 .map_err(|e| HortoError::msg(format!("chmod {}: {e}", dest.display())))?;
265 }
266 Ok(())
267}
268
269pub fn install_ecosystem_services(
279 runner: &dyn ProcessRunner,
280 bins: &LocalBins,
281 install_dir: &Path,
282 choice: EcosystemInstallChoice,
283) -> Result<Option<String>> {
284 install_ecosystem_services_at(runner, bins, install_dir, choice, Path::new("/etc"))
285}
286
287fn install_ecosystem_services_at(
293 runner: &dyn ProcessRunner,
294 bins: &LocalBins,
295 install_dir: &Path,
296 choice: EcosystemInstallChoice,
297 etc_root: &Path,
298) -> Result<Option<String>> {
299 let choice = EcosystemInstallChoice {
300 status_api: choice.status_api,
301 mcp: choice.mcp && bins.mcp.is_some(),
302 };
303 if !choice.any() {
304 return Ok(None);
305 }
306 let install = install_dir
307 .to_str()
308 .ok_or_else(|| HortoError::msg("non-utf8 install_dir"))?;
309
310 let token = if choice.status_api {
311 ensure_api_env_at(etc_root)?
312 } else {
313 None
314 };
315
316 let mut pairs: Vec<(&PathBuf, &str)> =
317 vec![(&bins.cli, "horto-os-ui"), (&bins.tui, "horto-os-ui-tui")];
318 if choice.status_api {
319 pairs.push((&bins.status_api, "horto-os-ui-status-api"));
320 }
321 if let Some(mcp) = bins.mcp.as_ref() {
322 if choice.mcp {
323 pairs.push((mcp, "horto-os-ui-mcp"));
324 }
325 }
326 for (src, name) in pairs {
327 let dest = install_dir.join(name);
328 if src != &dest || !dest.is_file() {
329 install_bin(src, &dest)?;
330 }
331 }
332
333 let unit_dir = etc_root.join("systemd").join("system");
334 if choice.status_api {
335 let api_unit = unit_with_install_dir(STATUS_API_UNIT, install);
336 write_unit_file(&unit_dir.join("horto-os-ui-status-api.service"), &api_unit)?;
337 }
338 if choice.mcp {
339 let mcp_unit = unit_with_install_dir(MCP_UNIT, install);
340 write_unit_file(&unit_dir.join("horto-os-ui-mcp.service"), &mcp_unit)?;
341 }
342
343 let mut sys_args: Vec<&[&str]> = vec![&["daemon-reload"]];
344 if choice.status_api {
345 sys_args.push(&["enable", "--now", "horto-os-ui-status-api.service"]);
346 }
347 if choice.mcp {
348 sys_args.push(&["enable", "--now", "horto-os-ui-mcp.service"]);
349 }
350 for args in sys_args {
351 let out = runner.run("systemctl", args, &[], StdioMode::Capture)?;
352 if !out.success() {
353 let detail = if out.stderr.trim().is_empty() {
354 out.stdout.trim()
355 } else {
356 out.stderr.trim()
357 };
358 return Err(HortoError::command(
359 "systemctl",
360 format!("exit {}: {detail}", out.status),
361 ));
362 }
363 }
364
365 Ok(token)
366}
367
368pub fn install_ecosystem_after_embedded_apply(
374 runner: &dyn ProcessRunner,
375 install_dir: &Path,
376 choice: EcosystemInstallChoice,
377) -> Result<Option<String>> {
378 if !choice.any() {
379 return Ok(None);
380 }
381 let bins = resolve_local_ecosystem_bins(install_dir)?;
382 install_ecosystem_services(runner, &bins, install_dir, choice)
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::remote::bins::BOX_BIN_NAMES;
389 use crate::remote::process::ScriptedRunner;
390 use tempfile::TempDir;
391
392 #[test]
393 fn unit_rewrite_custom_prefix() {
394 let u = unit_with_install_dir(STATUS_API_UNIT, "/opt/horto/bin");
395 assert!(u.contains("/opt/horto/bin/horto-os-ui-status-api"));
396 assert!(!u.contains("/usr/local/bin/horto-os-ui-status-api"));
397 let m = unit_with_install_dir(MCP_UNIT, "/opt/horto/bin");
398 assert!(m.contains("/opt/horto/bin/horto-os-ui-mcp"));
399 assert!(MCP_UNIT.contains("horto-os-ui-status-api.service"));
400 }
401
402 #[test]
403 fn remote_enable_script_mentions_both_units_and_mcp_bin() {
404 let both = EcosystemInstallChoice {
405 status_api: true,
406 mcp: true,
407 };
408 let s = remote_enable_ecosystem_script("/tmp/stage", "/usr/local/bin", both);
409 assert!(s.contains("horto-os-ui-status-api.service"));
410 assert!(s.contains("horto-os-ui-mcp.service"));
411 assert!(s.contains("horto-os-ui-mcp"));
412 assert!(s.contains("enable --now horto-os-ui-mcp.service"));
413 assert!(!s.contains("sudo sed"));
414 }
415
416 #[test]
417 fn remote_enable_script_api_only_skips_mcp_unit() {
418 let api = EcosystemInstallChoice {
419 status_api: true,
420 mcp: false,
421 };
422 let s = remote_enable_ecosystem_script("/tmp/stage", "/usr/local/bin", api);
423 assert!(s.contains("horto-os-ui-status-api.service"));
424 assert!(!s.contains("horto-os-ui-mcp.service"));
425 }
426
427 #[test]
428 fn remote_enable_script_custom_prefix_restarts() {
429 let both = EcosystemInstallChoice {
430 status_api: true,
431 mcp: true,
432 };
433 let s = remote_enable_ecosystem_script("/tmp/stage", "/opt/horto/bin", both);
434 assert!(s.contains("/opt/horto/bin/horto-os-ui-mcp"));
435 assert!(s.contains("restart horto-os-ui-mcp.service"));
436 }
437
438 #[test]
439 fn remote_enable_script_mcp_only_skips_api_token() {
440 let mcp = EcosystemInstallChoice {
441 status_api: false,
442 mcp: true,
443 };
444 let s = remote_enable_ecosystem_script("/tmp/stage", "/usr/local/bin", mcp);
445 assert!(!s.contains("openssl rand"));
446 assert!(!s.contains(API_TOKEN_DROP_BASENAME));
447 assert!(s.contains("horto-os-ui-mcp.service"));
448 assert!(!s.contains("enable --now horto-os-ui-status-api.service"));
449 }
450
451 #[test]
452 fn parse_api_token_line_accepts_and_rejects() {
453 assert_eq!(
454 parse_api_token_line("HORTO_API_TOKEN=aabb\n").as_deref(),
455 Some("aabb")
456 );
457 assert_eq!(
458 parse_api_token_line("deadbeef\n").as_deref(),
459 Some("deadbeef")
460 );
461 assert!(parse_api_token_line("").is_none());
462 assert!(parse_api_token_line("HORTO_API_TOKEN=\n").is_none());
463 assert!(parse_api_token_line("not-hex!\n").is_none());
464 }
465
466 #[test]
467 fn choice_helpers() {
468 assert!(!EcosystemInstallChoice::none().any());
469 assert!(EcosystemInstallChoice {
470 status_api: true,
471 mcp: false
472 }
473 .any());
474 assert!(EcosystemInstallChoice {
475 status_api: false,
476 mcp: true
477 }
478 .any());
479 }
480
481 #[test]
482 fn install_ecosystem_services_none_is_noop() {
483 let tmp = TempDir::new().unwrap();
484 let bins = LocalBins {
485 dir: tmp.path().to_path_buf(),
486 cli: tmp.path().join("horto-os-ui"),
487 tui: tmp.path().join("horto-os-ui-tui"),
488 status_api: tmp.path().join("horto-os-ui-status-api"),
489 mcp: Some(tmp.path().join("horto-os-ui-mcp")),
490 };
491 let runner = ScriptedRunner::default();
492 let token = install_ecosystem_services_at(
493 &runner,
494 &bins,
495 tmp.path(),
496 EcosystemInstallChoice::none(),
497 tmp.path().join("etc").as_path(),
498 )
499 .unwrap();
500 assert!(token.is_none());
501 assert_eq!(runner.calls.lock().unwrap().as_slice(), &[]);
502 }
503
504 #[test]
505 fn install_ecosystem_services_copies_and_enables_both() {
506 let tmp = TempDir::new().unwrap();
507 let src = tmp.path().join("src");
508 let dest = tmp.path().join("dest");
509 let etc = tmp.path().join("etc");
510 fs::create_dir_all(&src).unwrap();
511 fs::create_dir_all(&dest).unwrap();
512 for name in BOX_BIN_NAMES {
513 fs::write(src.join(name), b"bin").unwrap();
514 }
515 let bins = LocalBins {
516 dir: src.clone(),
517 cli: src.join("horto-os-ui"),
518 tui: src.join("horto-os-ui-tui"),
519 status_api: src.join("horto-os-ui-status-api"),
520 mcp: Some(src.join("horto-os-ui-mcp")),
521 };
522 let runner = ScriptedRunner::default();
523 runner.push("systemctl", ScriptedRunner::ok(""));
524 runner.push("systemctl", ScriptedRunner::ok(""));
525 runner.push("systemctl", ScriptedRunner::ok(""));
526
527 let token = install_ecosystem_services_at(
528 &runner,
529 &bins,
530 &dest,
531 EcosystemInstallChoice {
532 status_api: true,
533 mcp: true,
534 },
535 &etc,
536 )
537 .unwrap();
538 assert!(token.is_some());
539 assert!(token
540 .as_ref()
541 .unwrap()
542 .chars()
543 .all(|c| c.is_ascii_hexdigit()));
544 for name in BOX_BIN_NAMES {
545 assert!(dest.join(name).is_file(), "missing {name}");
546 }
547 assert!(etc
548 .join("systemd/system/horto-os-ui-status-api.service")
549 .is_file());
550 assert!(etc.join("systemd/system/horto-os-ui-mcp.service").is_file());
551 assert!(etc.join("horto-os-ui/api.env").is_file());
552 let runner2 = ScriptedRunner::default();
554 runner2.push("systemctl", ScriptedRunner::ok(""));
555 runner2.push("systemctl", ScriptedRunner::ok(""));
556 runner2.push("systemctl", ScriptedRunner::ok(""));
557 let token2 = install_ecosystem_services_at(
558 &runner2,
559 &bins,
560 &dest,
561 EcosystemInstallChoice {
562 status_api: true,
563 mcp: true,
564 },
565 &etc,
566 )
567 .unwrap();
568 assert_eq!(token, token2);
569 }
570
571 #[test]
572 fn install_ecosystem_services_mcp_only_skips_api_env() {
573 let tmp = TempDir::new().unwrap();
574 let src = tmp.path().join("src");
575 let dest = tmp.path().join("dest");
576 let etc = tmp.path().join("etc");
577 fs::create_dir_all(&src).unwrap();
578 for name in BOX_BIN_NAMES {
579 fs::write(src.join(name), b"bin").unwrap();
580 }
581 let bins = LocalBins {
582 dir: src.clone(),
583 cli: src.join("horto-os-ui"),
584 tui: src.join("horto-os-ui-tui"),
585 status_api: src.join("horto-os-ui-status-api"),
586 mcp: Some(src.join("horto-os-ui-mcp")),
587 };
588 let runner = ScriptedRunner::default();
589 runner.push("systemctl", ScriptedRunner::ok(""));
590 runner.push("systemctl", ScriptedRunner::ok(""));
591
592 let token = install_ecosystem_services_at(
593 &runner,
594 &bins,
595 &dest,
596 EcosystemInstallChoice {
597 status_api: false,
598 mcp: true,
599 },
600 &etc,
601 )
602 .unwrap();
603 assert!(token.is_none());
604 assert!(dest.join("horto-os-ui-mcp").is_file());
605 assert!(!dest.join("horto-os-ui-status-api").exists());
606 assert!(!etc.join("horto-os-ui/api.env").exists());
607 assert!(etc.join("systemd/system/horto-os-ui-mcp.service").is_file());
608 assert!(!etc
609 .join("systemd/system/horto-os-ui-status-api.service")
610 .exists());
611 }
612
613 #[test]
614 fn install_ecosystem_services_systemctl_failure() {
615 let tmp = TempDir::new().unwrap();
616 let src = tmp.path().join("src");
617 let dest = tmp.path().join("dest");
618 let etc = tmp.path().join("etc");
619 fs::create_dir_all(&src).unwrap();
620 for name in BOX_BIN_NAMES {
621 fs::write(src.join(name), b"bin").unwrap();
622 }
623 let bins = LocalBins {
624 dir: src.clone(),
625 cli: src.join("horto-os-ui"),
626 tui: src.join("horto-os-ui-tui"),
627 status_api: src.join("horto-os-ui-status-api"),
628 mcp: Some(src.join("horto-os-ui-mcp")),
629 };
630 let runner = ScriptedRunner::default();
631 runner.push("systemctl", ScriptedRunner::fail(1, "nope"));
632
633 let err = install_ecosystem_services_at(
634 &runner,
635 &bins,
636 &dest,
637 EcosystemInstallChoice {
638 status_api: true,
639 mcp: false,
640 },
641 &etc,
642 )
643 .unwrap_err();
644 assert!(err.to_string().contains("systemctl"));
645 }
646
647 #[test]
648 fn install_after_embedded_none_skips_resolve() {
649 let tmp = TempDir::new().unwrap();
650 let runner = ScriptedRunner::default();
651 let token = install_ecosystem_after_embedded_apply(
652 &runner,
653 tmp.path(),
654 EcosystemInstallChoice::none(),
655 )
656 .unwrap();
657 assert!(token.is_none());
658 }
659
660 #[test]
661 fn random_hex_token_is_64_hex_chars() {
662 let t = random_hex_token();
663 assert_eq!(t.len(), 64);
664 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
665 }
666}