Skip to main content

horto_os_ui/
main.rs

1//! Horto OS UI CLI (`horto-os-ui`): plan-by-default box setup and day-2 ops.
2//!
3//! Surfaces call [`horto_os_ui_shared`] for steps, kits, remote SSH, and status.
4//! Privileged writes require `--apply` (or `APPLY=1` via Make). Interactive
5//! confirm / reboot prompts stay on stderr; ops logs use `tracing`.
6
7use 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    /// Apply privileged changes (default: plan only, no writes)
31    #[arg(long, global = true)]
32    apply: bool,
33
34    /// Skip piper model download during docker init
35    #[arg(long, global = true)]
36    skip_piper: bool,
37
38    /// OpenSSH Host alias or user@host; run setup/doctor on that box via SSH
39    #[arg(long, global = true, env = "HORTO_REMOTE_HOST")]
40    remote: Option<String>,
41
42    /// Opt-in: install this PC's public key on the box (`ssh-copy-id`). Off by default.
43    #[arg(long, global = true, default_value_t = false)]
44    install_ssh_key: bool,
45
46    /// Local directory with horto-os-ui / tui / status-api / mcp (skips GitHub)
47    #[arg(long, global = true, env = "HORTO_BIN_DIR")]
48    bin_dir: Option<PathBuf>,
49
50    /// GitHub Release tag for box tar.gz download (`v0.1.0` or tip `dev-preview`)
51    #[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 pipeline status / run
61    Setup {
62        #[command(subcommand)]
63        cmd: SetupCmd,
64    },
65    /// Host readiness checks
66    Doctor,
67    /// Docker helpers
68    Docker {
69        #[command(subcommand)]
70        cmd: DockerCmd,
71    },
72    /// Network / DHCP lease helpers
73    Net {
74        #[command(subcommand)]
75        cmd: NetCmd,
76    },
77    /// Config and full-disk backups
78    Backup {
79        #[command(subcommand)]
80        cmd: BackupCmd,
81    },
82    /// Probe SSH / CLI / API / MCP surfaces (same report as TUI / Desktop)
83    Surfaces {
84        /// Print `SurfaceProbeReport` as JSON
85        #[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        /// Print setup status as JSON (for remote TUI / scripts).
98        #[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    /// Run the d1 docker init step
120    Init,
121    /// Rebuild one compose project (cwd or --dir)
122    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    /// Timestamped managed /etc backup (or --initial for protected `initial_setup`)
138    Etc {
139        /// Write to /`srv/backup/etc/initial_setup` (same as setup step s3)
140        #[arg(long)]
141        initial: bool,
142    },
143    /// List timestamped /etc backups
144    List,
145    /// Show disk-backup readiness (always safe)
146    #[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    /// Guarded partclone eMMC partition backup (boot from SD first)
154    Disk {
155        #[arg(long, default_value = "/dev/mmcblk0p1")]
156        source: PathBuf,
157        #[arg(long, default_value = "/mnt/external/horto-os")]
158        dest: PathBuf,
159        /// Also dump first 4MiB of /dev/mmcblk0 (boot sectors)
160        #[arg(long)]
161        boot_sectors: bool,
162        /// Allow when root is not clearly SD/USB (never when root is eMMC)
163        #[arg(long)]
164        force: bool,
165    },
166    /// Optional shrink-backup wrapper (tool must be on PATH)
167    Shrink {
168        #[arg(long)]
169        dest: PathBuf,
170        #[arg(long)]
171        force: bool,
172    },
173    /// JSON status used by API / soft client
174    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/// Remote privilege flags that track CLI `--apply` with the same polarity.
247///
248/// After `--dry-run` was removed, some call sites briefly used `!cli.apply`, so
249/// `--apply` disabled sudo and skipped payload. Never invert again.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251struct RemoteApplyPrivilege {
252    use_sudo: bool,
253    offer_reboot: bool,
254}
255
256impl RemoteApplyPrivilege {
257    /// `--apply` → sudo (and reboot offer when requested by the caller).
258    #[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/// Ecosystem answers apply only on remote full apply; otherwise skip both services.
268#[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
294/// Ask yes/non for status-api and MCP. Non-TTY → neither.
295fn 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        // clap reads HORTO_REMOTE_HOST when --remote is omitted.
844        // Isolate from the process environment so CI hosts do not leak in.
845        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        // Explicit --remote wins over env.
850        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        // Regression: leftover `!cli.apply` after dry-run → --apply rename.
869        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}