1use anyhow::{Context, Result};
8use clap::{Parser, Subcommand};
9use horto_os_ui_shared::{
10 backup_disk, backup_etc_initial, backup_etc_timestamped, backup_shrink, backup_status,
11 docker_rebuild, doctor, export_dhcp_leases, footer_line, format_surfaces_report, init_tracing,
12 install_ecosystem_after_embedded_apply, list_containers, list_timestamped_etc_backups,
13 offer_save_api_token, probe_disk_backup, probe_surfaces, read_leases,
14 remote_doctor_report_banner, remote_run_cli, require_root_for_apply, setup_run, setup_status,
15 setup_step, ApplyMode, DiskBackupOpts, EcosystemInstallChoice, HostContext, PromptsProvider,
16 RemoteOptions, RemoteOptionsInput, RemoteRunFlags, RemoteRunOutcome, RemoteRunRequest,
17 SetupKind, ShrinkBackupOpts, StdioPrompts, SystemProcessRunner, DEFAULT_INSTALL_DIR,
18 LONG_VERSION,
19};
20use std::path::{Path, PathBuf};
21
22#[derive(Parser, Debug)]
23#[command(
24 name = "horto-os-ui",
25 about = "Horto OS UI CLI",
26 version,
27 long_version = LONG_VERSION
28)]
29struct Cli {
30 #[arg(long, global = true)]
32 apply: bool,
33
34 #[arg(long, global = true)]
36 skip_piper: bool,
37
38 #[arg(long, global = true, env = "HORTO_REMOTE_HOST")]
40 remote: Option<String>,
41
42 #[arg(long, global = true, default_value_t = false)]
44 install_ssh_key: bool,
45
46 #[arg(long, global = true, env = "HORTO_BIN_DIR")]
48 bin_dir: Option<PathBuf>,
49
50 #[arg(long, global = true, env = "HORTO_RELEASE_TAG")]
52 release_tag: Option<String>,
53
54 #[command(subcommand)]
55 command: Commands,
56}
57
58#[derive(Subcommand, Debug)]
59enum Commands {
60 Setup {
62 #[command(subcommand)]
63 cmd: SetupCmd,
64 },
65 Doctor,
67 Docker {
69 #[command(subcommand)]
70 cmd: DockerCmd,
71 },
72 Net {
74 #[command(subcommand)]
75 cmd: NetCmd,
76 },
77 Backup {
79 #[command(subcommand)]
80 cmd: BackupCmd,
81 },
82 Surfaces {
84 #[arg(long)]
86 json: bool,
87 },
88}
89
90#[derive(Subcommand, Debug)]
91enum SetupCmd {
92 Status {
93 #[arg(long, group = "kind")]
94 full: bool,
95 #[arg(long, group = "kind")]
96 minimal: bool,
97 #[arg(long)]
99 json: bool,
100 },
101 Run {
102 #[arg(long, group = "kind")]
103 full: bool,
104 #[arg(long, group = "kind")]
105 minimal: bool,
106 },
107 Step {
108 id: String,
109 #[arg(long, group = "kind")]
110 full: bool,
111 #[arg(long, group = "kind")]
112 minimal: bool,
113 },
114}
115
116#[derive(Subcommand, Debug)]
117enum DockerCmd {
118 Status,
119 Init,
121 Rebuild {
123 #[arg(long, default_value = ".")]
124 dir: PathBuf,
125 },
126}
127
128#[derive(Subcommand, Debug)]
129enum NetCmd {
130 Leases,
131 #[command(name = "export-leases")]
132 ExportLeases,
133}
134
135#[derive(Subcommand, Debug)]
136enum BackupCmd {
137 Etc {
139 #[arg(long)]
141 initial: bool,
142 },
143 List,
145 #[command(name = "disk-status")]
147 DiskStatus {
148 #[arg(long, default_value = "/dev/mmcblk0p1")]
149 source: PathBuf,
150 #[arg(long, default_value = "/mnt/external/horto-os")]
151 dest: PathBuf,
152 },
153 Disk {
155 #[arg(long, default_value = "/dev/mmcblk0p1")]
156 source: PathBuf,
157 #[arg(long, default_value = "/mnt/external/horto-os")]
158 dest: PathBuf,
159 #[arg(long)]
161 boot_sectors: bool,
162 #[arg(long)]
164 force: bool,
165 },
166 Shrink {
168 #[arg(long)]
169 dest: PathBuf,
170 #[arg(long)]
171 force: bool,
172 },
173 Status,
175}
176
177const fn kind_from_flags(full: bool, minimal: bool) -> SetupKind {
178 if minimal {
179 SetupKind::Minimal
180 } else {
181 let _ = full;
182 SetupKind::Full
183 }
184}
185
186const fn mode(apply: bool) -> ApplyMode {
187 if apply {
188 ApplyMode::Apply
189 } else {
190 ApplyMode::DryRun
191 }
192}
193
194fn make_ctx(cli: &Cli, kind: SetupKind) -> HostContext {
195 let mut ctx = HostContext::new(mode(cli.apply), kind).with_prompts(Box::new(StdioPrompts));
196 ctx.skip_piper = cli.skip_piper;
197 ctx
198}
199
200fn remote_options(cli: &Cli) -> RemoteOptions {
201 RemoteOptions::from_input(RemoteOptionsInput {
202 host: cli.remote.clone().unwrap_or_default(),
203 install_ssh_key: cli.install_ssh_key,
204 bin_dir: cli.bin_dir.clone(),
205 release_tag: cli.release_tag.clone(),
206 force_askpass: false,
207 })
208}
209
210fn remote_cli_args(cli: &Cli, rest: &[&str]) -> Vec<String> {
211 let mut args = Vec::new();
212 if cli.apply {
213 args.push("--apply".into());
214 }
215 if cli.skip_piper {
216 args.push("--skip-piper".into());
217 }
218 for a in rest {
219 args.push((*a).to_owned());
220 }
221 args
222}
223
224fn run_remote(
225 cli: &Cli,
226 rest: &[&str],
227 use_sudo: bool,
228 ecosystem: EcosystemInstallChoice,
229 offer_reboot: bool,
230 capture_output: bool,
231) -> Result<RemoteRunOutcome> {
232 let req = RemoteRunRequest {
233 options: remote_options(cli),
234 cli_args: remote_cli_args(cli, rest),
235 flags: RemoteRunFlags {
236 use_sudo,
237 install_payload_on_success: ecosystem.any(),
238 offer_reboot_on_success: offer_reboot,
239 capture_output,
240 },
241 ecosystem,
242 };
243 Ok(remote_run_cli(&SystemProcessRunner, &req)?)
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251struct RemoteApplyPrivilege {
252 use_sudo: bool,
253 offer_reboot: bool,
254}
255
256impl RemoteApplyPrivilege {
257 #[must_use]
259 const fn from_apply(apply: bool) -> Self {
260 Self {
261 use_sudo: apply,
262 offer_reboot: apply,
263 }
264 }
265}
266
267#[must_use]
269const fn remote_ecosystem_for_setup_run(
270 apply: bool,
271 minimal: bool,
272 answered: EcosystemInstallChoice,
273) -> EcosystemInstallChoice {
274 if apply && !minimal {
275 answered
276 } else {
277 EcosystemInstallChoice::none()
278 }
279}
280
281fn print_remote_log(out: &RemoteRunOutcome) {
282 if !out.log.is_empty() {
283 println!("{}", out.log);
284 }
285}
286
287fn maybe_offer_save_token(out: &RemoteRunOutcome) -> Result<()> {
288 if let Some(token) = out.api_token.as_deref() {
289 let _saved = offer_save_api_token(token)?;
290 }
291 Ok(())
292}
293
294fn prompt_ecosystem_choice(on_remote_box: bool) -> EcosystemInstallChoice {
296 use std::io::IsTerminal;
297 if !std::io::stdin().is_terminal() {
298 return EcosystemInstallChoice::none();
299 }
300 let where_ = if on_remote_box {
301 "on the remote box"
302 } else {
303 "on this host"
304 };
305 let mut prompts = StdioPrompts;
306 let status_api = prompts.confirm(&format!("Install status-api (systemd) {where_}?"), false);
307 let mcp = prompts.confirm(&format!("Install MCP (systemd) {where_}?"), false);
308 EcosystemInstallChoice { status_api, mcp }
309}
310
311fn maybe_install_ecosystem_embedded(cli: &Cli, kind: SetupKind) -> Result<()> {
312 if !cli.apply || kind != SetupKind::Full {
313 return Ok(());
314 }
315 let choice = prompt_ecosystem_choice(false);
316 if !choice.any() {
317 tracing::info!("Skipped status-api / MCP install");
318 return Ok(());
319 }
320 let install_dir = std::path::Path::new(DEFAULT_INSTALL_DIR);
321 match install_ecosystem_after_embedded_apply(&SystemProcessRunner, install_dir, choice) {
322 Ok(token) => {
323 tracing::info!(
324 "Installed selected ecosystem services under {}",
325 install_dir.display()
326 );
327 if let Some(hex) = token.as_deref() {
328 let _ = offer_save_api_token(hex)?;
329 }
330 Ok(())
331 }
332 Err(e) => {
333 tracing::error!("ecosystem install after full apply failed: {e}");
334 Err(e.into())
335 }
336 }
337}
338
339fn main() -> Result<()> {
340 init_tracing("info");
341 let cli = Cli::parse();
342 match &cli.command {
343 Commands::Setup { cmd } => cmd_setup(&cli, cmd)?,
344 Commands::Doctor => cmd_doctor(&cli)?,
345 Commands::Docker { cmd } => cmd_docker(&cli, cmd)?,
346 Commands::Net { cmd } => cmd_net(&cli, cmd)?,
347 Commands::Backup { cmd } => cmd_backup(&cli, cmd)?,
348 Commands::Surfaces { json } => cmd_surfaces(&cli, *json)?,
349 }
350 Ok(())
351}
352
353fn cmd_setup(cli: &Cli, cmd: &SetupCmd) -> Result<()> {
354 match cmd {
355 SetupCmd::Status {
356 full,
357 minimal,
358 json,
359 } => {
360 if cli.remote.is_some() {
361 let kind = if *minimal { "--minimal" } else { "--full" };
362 let mut args = vec!["setup", "status", kind];
363 if *json {
364 args.push("--json");
365 }
366 let out = run_remote(
367 cli,
368 &args,
369 false,
370 EcosystemInstallChoice::none(),
371 false,
372 true,
373 )?;
374 print_remote_log(&out);
375 } else {
376 let kind = kind_from_flags(*full, *minimal);
377 let ctx = make_ctx(cli, kind);
378 let report = setup_status(&ctx, kind);
379 if *json {
380 println!("{}", serde_json::to_string_pretty(&report)?);
381 } else {
382 println!("Setup kind: {}", report.kind);
383 for s in &report.steps {
384 let flags = format!(
385 "{}{}",
386 if s.destructive { " [destructive]" } else { "" },
387 if s.needs_reboot_after {
388 " [reboot]"
389 } else {
390 ""
391 }
392 );
393 println!(
394 " [{:>7}] {} - {} (v{}){flags}",
395 s.status, s.id, s.title, s.step_version
396 );
397 }
398 }
399 }
400 }
401 SetupCmd::Run { full, minimal } => {
402 if cli.remote.is_some() {
403 let kind = if *minimal { "--minimal" } else { "--full" };
404 let answered = if cli.apply && !*minimal {
405 prompt_ecosystem_choice(true)
406 } else {
407 EcosystemInstallChoice::none()
408 };
409 let ecosystem = remote_ecosystem_for_setup_run(cli.apply, *minimal, answered);
410 let priv_ = RemoteApplyPrivilege::from_apply(cli.apply);
411 let out = run_remote(
412 cli,
413 &["setup", "run", kind],
414 priv_.use_sudo,
415 ecosystem,
416 priv_.offer_reboot,
417 false,
418 )?;
419 print_remote_log(&out);
420 maybe_offer_save_token(&out)?;
421 } else {
422 let kind = kind_from_flags(*full, *minimal);
423 let mut ctx = make_ctx(cli, kind);
424 require_root_for_apply(ctx.mode).context("root check")?;
425 setup_run(&mut ctx, kind)?;
426 maybe_install_ecosystem_embedded(cli, kind)?;
427 }
428 }
429 SetupCmd::Step { id, full, minimal } => {
430 if cli.remote.is_some() {
431 let kind = if *minimal { "--minimal" } else { "--full" };
432 let out = run_remote(
433 cli,
434 &["setup", "step", id, kind],
435 RemoteApplyPrivilege::from_apply(cli.apply).use_sudo,
436 EcosystemInstallChoice::none(),
437 false,
438 false,
439 )?;
440 print_remote_log(&out);
441 } else {
442 let kind = kind_from_flags(*full, *minimal);
443 let mut ctx = make_ctx(cli, kind);
444 require_root_for_apply(ctx.mode).context("root check")?;
445 setup_step(&mut ctx, kind, id)?;
446 }
447 }
448 }
449 Ok(())
450}
451
452fn cmd_doctor(cli: &Cli) -> Result<()> {
453 if cli.remote.is_some() {
454 let out = run_remote(
455 cli,
456 &["doctor"],
457 false,
458 EcosystemInstallChoice::none(),
459 false,
460 true,
461 )?;
462 remote_doctor_report_banner();
463 print_remote_log(&out);
464 } else {
465 let ctx = make_ctx(cli, SetupKind::Full);
466 let report = doctor(&ctx);
467 println!("{}", serde_json::to_string_pretty(&report)?);
468 eprintln!("{}", footer_line());
469 }
470 Ok(())
471}
472
473fn cmd_docker(cli: &Cli, cmd: &DockerCmd) -> Result<()> {
474 match cmd {
475 DockerCmd::Status => {
476 if cli.remote.is_some() {
477 let out = run_remote(
478 cli,
479 &["docker", "status"],
480 false,
481 EcosystemInstallChoice::none(),
482 false,
483 true,
484 )?;
485 print_remote_log(&out);
486 } else {
487 let list = list_containers()?;
488 if list.is_empty() {
489 println!("No containers (docker missing or none running).");
490 } else {
491 for c in list {
492 println!(
493 "{}\t{}\t{}\t{}\t{}",
494 c.id, c.names, c.image, c.status, c.ports
495 );
496 }
497 }
498 }
499 }
500 DockerCmd::Init => {
501 if cli.remote.is_some() {
502 let out = run_remote(
503 cli,
504 &["docker", "init"],
505 RemoteApplyPrivilege::from_apply(cli.apply).use_sudo,
506 EcosystemInstallChoice::none(),
507 false,
508 false,
509 )?;
510 print_remote_log(&out);
511 } else {
512 let mut ctx = make_ctx(cli, SetupKind::Full);
513 require_root_for_apply(ctx.mode).context("root check")?;
514 setup_step(&mut ctx, SetupKind::Full, "d1")?;
515 }
516 }
517 DockerCmd::Rebuild { dir } => {
518 if cli.remote.is_some() {
519 anyhow::bail!(
520 "docker rebuild over --remote is not supported yet; use embedded or SSH manually"
521 );
522 }
523 docker_rebuild(dir)?;
524 println!("Rebuilt compose project in {}", dir.display());
525 }
526 }
527 Ok(())
528}
529
530fn cmd_net(cli: &Cli, cmd: &NetCmd) -> Result<()> {
531 match cmd {
532 NetCmd::Leases => {
533 if cli.remote.is_some() {
534 let out = run_remote(
535 cli,
536 &["net", "leases"],
537 false,
538 EcosystemInstallChoice::none(),
539 false,
540 true,
541 )?;
542 print_remote_log(&out);
543 } else {
544 let ctx = make_ctx(cli, SetupKind::Full);
545 let leases = read_leases(&ctx.paths.lease_file, &ctx.paths.leases_json());
546 if leases.is_empty() {
547 println!("No leases found.");
548 } else {
549 for l in leases {
550 println!("{}\t{}\t{}\t{}", l.hostname, l.ip, l.mac, l.expires);
551 }
552 }
553 }
554 }
555 NetCmd::ExportLeases => {
556 if cli.remote.is_some() {
557 let out = run_remote(
558 cli,
559 &["net", "export-leases"],
560 RemoteApplyPrivilege::from_apply(cli.apply).use_sudo,
561 EcosystemInstallChoice::none(),
562 false,
563 false,
564 )?;
565 print_remote_log(&out);
566 } else {
567 let mut ctx = make_ctx(cli, SetupKind::Full);
568 export_dhcp_leases(&mut ctx)?;
569 }
570 }
571 }
572 Ok(())
573}
574
575fn cmd_backup(cli: &Cli, cmd: &BackupCmd) -> Result<()> {
576 match cmd {
577 BackupCmd::Etc { initial } => cmd_backup_etc(cli, *initial),
578 BackupCmd::List => cmd_backup_list(cli),
579 BackupCmd::DiskStatus { source, dest } => cmd_backup_disk_status(cli, source, dest),
580 BackupCmd::Disk {
581 source,
582 dest,
583 boot_sectors,
584 force,
585 } => cmd_backup_disk(cli, source, dest, *boot_sectors, *force),
586 BackupCmd::Shrink { dest, force } => cmd_backup_shrink(cli, dest, *force),
587 BackupCmd::Status => cmd_backup_status(cli),
588 }
589}
590
591fn cmd_backup_etc(cli: &Cli, initial: bool) -> Result<()> {
592 if cli.remote.is_some() {
593 let mut args = vec!["backup", "etc"];
594 if initial {
595 args.push("--initial");
596 }
597 let out = run_remote(
598 cli,
599 &args,
600 RemoteApplyPrivilege::from_apply(cli.apply).use_sudo,
601 EcosystemInstallChoice::none(),
602 false,
603 false,
604 )?;
605 print_remote_log(&out);
606 } else {
607 let mut ctx = make_ctx(cli, SetupKind::Full);
608 require_root_for_apply(ctx.mode).context("root check")?;
609 let report = if initial {
610 backup_etc_initial(&mut ctx)?
611 } else {
612 backup_etc_timestamped(&mut ctx)?
613 };
614 println!("{}", serde_json::to_string_pretty(&report)?);
615 }
616 Ok(())
617}
618
619fn cmd_backup_list(cli: &Cli) -> Result<()> {
620 if cli.remote.is_some() {
621 let out = run_remote(
622 cli,
623 &["backup", "list"],
624 false,
625 EcosystemInstallChoice::none(),
626 false,
627 true,
628 )?;
629 print_remote_log(&out);
630 } else {
631 let ctx = make_ctx(cli, SetupKind::Full);
632 let list = list_timestamped_etc_backups(&ctx);
633 if list.is_empty() {
634 println!("No timestamped /etc backups under /srv/backup/etc/");
635 } else {
636 for name in list {
637 println!("{name}");
638 }
639 }
640 }
641 Ok(())
642}
643
644fn cmd_backup_disk_status(cli: &Cli, source: &Path, dest: &Path) -> Result<()> {
645 if cli.remote.is_some() {
646 let source_s = source.display().to_string();
647 let dest_s = dest.display().to_string();
648 let out = run_remote(
649 cli,
650 &[
651 "backup",
652 "disk-status",
653 "--source",
654 &source_s,
655 "--dest",
656 &dest_s,
657 ],
658 false,
659 EcosystemInstallChoice::none(),
660 false,
661 true,
662 )?;
663 print_remote_log(&out);
664 } else {
665 let probe = probe_disk_backup(&DiskBackupOpts {
666 source: source.to_path_buf(),
667 dest_dir: dest.to_path_buf(),
668 include_boot_sectors: false,
669 force: false,
670 });
671 println!("{}", serde_json::to_string_pretty(&probe)?);
672 }
673 Ok(())
674}
675
676fn cmd_backup_disk(
677 cli: &Cli,
678 source: &Path,
679 dest: &Path,
680 boot_sectors: bool,
681 force: bool,
682) -> Result<()> {
683 if cli.remote.is_some() {
684 let mut args = vec![
685 "backup".into(),
686 "disk".into(),
687 "--source".into(),
688 source.display().to_string(),
689 "--dest".into(),
690 dest.display().to_string(),
691 ];
692 if boot_sectors {
693 args.push("--boot-sectors".into());
694 }
695 if force {
696 args.push("--force".into());
697 }
698 let rest: Vec<&str> = args.iter().map(String::as_str).collect();
699 let out = run_remote(
700 cli,
701 &rest,
702 RemoteApplyPrivilege::from_apply(cli.apply).use_sudo,
703 EcosystemInstallChoice::none(),
704 false,
705 false,
706 )?;
707 print_remote_log(&out);
708 } else {
709 let mut ctx = make_ctx(cli, SetupKind::Full);
710 require_root_for_apply(ctx.mode).context("root check")?;
711 let opts = DiskBackupOpts {
712 source: source.to_path_buf(),
713 dest_dir: dest.to_path_buf(),
714 include_boot_sectors: boot_sectors,
715 force,
716 };
717 let path = backup_disk(&mut ctx, &opts)?;
718 println!("image: {}", path.display());
719 }
720 Ok(())
721}
722
723fn cmd_backup_shrink(cli: &Cli, dest: &Path, force: bool) -> Result<()> {
724 if cli.remote.is_some() {
725 anyhow::bail!("backup shrink over --remote is not supported yet");
726 }
727 let mut ctx = make_ctx(cli, SetupKind::Full);
728 require_root_for_apply(ctx.mode).context("root check")?;
729 let path = backup_shrink(
730 &mut ctx,
731 &ShrinkBackupOpts {
732 dest_img: dest.to_path_buf(),
733 force,
734 },
735 )?;
736 println!("image: {}", path.display());
737 Ok(())
738}
739
740fn cmd_backup_status(cli: &Cli) -> Result<()> {
741 if cli.remote.is_some() {
742 let out = run_remote(
743 cli,
744 &["backup", "status"],
745 false,
746 EcosystemInstallChoice::none(),
747 false,
748 true,
749 )?;
750 print_remote_log(&out);
751 } else {
752 let ctx = make_ctx(cli, SetupKind::Full);
753 println!("{}", serde_json::to_string_pretty(&backup_status(&ctx))?);
754 }
755 Ok(())
756}
757
758fn cmd_surfaces(cli: &Cli, json: bool) -> Result<()> {
759 let embedded = cli.remote.is_none();
760 let opts = if embedded {
761 RemoteOptions::default()
762 } else {
763 remote_options(cli)
764 };
765 let report = probe_surfaces(&SystemProcessRunner, &opts, embedded)?;
766 if json {
767 println!("{}", serde_json::to_string_pretty(&report)?);
768 } else {
769 print!("{}", format_surfaces_report(&report));
770 eprintln!("{}", footer_line());
771 }
772 Ok(())
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use clap::CommandFactory;
779 use clap::Parser;
780
781 #[test]
782 fn cli_debug_assert() {
783 Cli::command().debug_assert();
784 }
785
786 #[test]
787 fn parses_common_commands() {
788 let cases = [
789 vec!["horto-os-ui", "setup", "status", "--full"],
790 vec!["horto-os-ui", "setup", "status", "--minimal"],
791 vec!["horto-os-ui", "setup", "run", "--full"],
792 vec!["horto-os-ui", "setup", "step", "s1", "--full"],
793 vec!["horto-os-ui", "doctor"],
794 vec!["horto-os-ui", "docker", "status"],
795 vec!["horto-os-ui", "docker", "init"],
796 vec!["horto-os-ui", "docker", "rebuild", "--dir", "/tmp"],
797 vec!["horto-os-ui", "net", "leases"],
798 vec!["horto-os-ui", "net", "export-leases"],
799 vec!["horto-os-ui", "backup", "status"],
800 vec!["horto-os-ui", "backup", "list"],
801 vec!["horto-os-ui", "backup", "etc"],
802 vec!["horto-os-ui", "backup", "etc", "--initial"],
803 vec!["horto-os-ui", "backup", "disk-status"],
804 vec!["horto-os-ui", "backup", "disk", "--force"],
805 vec!["horto-os-ui", "backup", "shrink", "--dest", "/tmp/x.img"],
806 vec!["horto-os-ui", "surfaces"],
807 vec!["horto-os-ui", "surfaces", "--json"],
808 vec!["horto-os-ui", "--remote", "horto-box", "surfaces"],
809 vec![
810 "horto-os-ui",
811 "--remote",
812 "horto-box",
813 "--apply",
814 "setup",
815 "run",
816 "--full",
817 ],
818 vec![
819 "horto-os-ui",
820 "--remote",
821 "user@192.168.1.10",
822 "--install-ssh-key",
823 "--bin-dir",
824 "/tmp/bins",
825 "doctor",
826 ],
827 ];
828 for args in cases {
829 Cli::try_parse_from(args).expect("parse");
830 }
831 }
832
833 #[test]
834 fn remote_defaults_key_off() {
835 let cli = Cli::try_parse_from(["horto-os-ui", "--remote", "box", "doctor"]).unwrap();
836 assert_eq!(cli.remote.as_deref(), Some("box"));
837 assert!(!cli.install_ssh_key);
838 assert!(cli.bin_dir.is_none());
839 }
840
841 #[test]
842 fn remote_from_horto_remote_host_env() {
843 let prev = std::env::var_os("HORTO_REMOTE_HOST");
846 std::env::set_var("HORTO_REMOTE_HOST", "env-box");
847 let cli = Cli::try_parse_from(["horto-os-ui", "doctor"]).unwrap();
848 assert_eq!(cli.remote.as_deref(), Some("env-box"));
849 let cli = Cli::try_parse_from(["horto-os-ui", "--remote", "flag-box", "doctor"]).unwrap();
851 assert_eq!(cli.remote.as_deref(), Some("flag-box"));
852 match prev {
853 Some(v) => std::env::set_var("HORTO_REMOTE_HOST", v),
854 None => std::env::remove_var("HORTO_REMOTE_HOST"),
855 }
856 }
857
858 #[test]
859 fn kind_and_mode_helpers() {
860 assert_eq!(kind_from_flags(true, false), SetupKind::Full);
861 assert_eq!(kind_from_flags(false, true), SetupKind::Minimal);
862 assert_eq!(mode(false), ApplyMode::DryRun);
863 assert_eq!(mode(true), ApplyMode::Apply);
864 }
865
866 #[test]
867 fn remote_apply_privilege_is_not_inverted() {
868 assert_eq!(
870 RemoteApplyPrivilege::from_apply(true),
871 RemoteApplyPrivilege {
872 use_sudo: true,
873 offer_reboot: true,
874 }
875 );
876 assert_eq!(
877 RemoteApplyPrivilege::from_apply(false),
878 RemoteApplyPrivilege {
879 use_sudo: false,
880 offer_reboot: false,
881 }
882 );
883 assert!(RemoteApplyPrivilege::from_apply(true).use_sudo);
884 assert!(!RemoteApplyPrivilege::from_apply(false).use_sudo);
885 }
886
887 #[test]
888 fn remote_ecosystem_only_on_full_apply() {
889 let both = EcosystemInstallChoice {
890 status_api: true,
891 mcp: true,
892 };
893 assert_eq!(remote_ecosystem_for_setup_run(true, false, both), both);
894 assert_eq!(
895 remote_ecosystem_for_setup_run(false, false, both),
896 EcosystemInstallChoice::none()
897 );
898 assert_eq!(
899 remote_ecosystem_for_setup_run(true, true, both),
900 EcosystemInstallChoice::none()
901 );
902 }
903
904 #[test]
905 fn parses_apply_flag_on_remote_setup_run() {
906 let cli = Cli::try_parse_from([
907 "horto-os-ui",
908 "--remote",
909 "box",
910 "--apply",
911 "setup",
912 "run",
913 "--full",
914 ])
915 .unwrap();
916 assert!(cli.apply);
917 let priv_ = RemoteApplyPrivilege::from_apply(cli.apply);
918 assert!(priv_.use_sudo);
919 assert!(priv_.offer_reboot);
920 }
921}