Skip to main content

horto_os_ui_shared/remote/runner/
options.rs

1//! Remote session options, banners, and shared command helpers.
2
3use super::super::ecosystem::{EcosystemInstallChoice, DEFAULT_INSTALL_DIR as ECO_INSTALL_DIR};
4use super::super::host::parse_host_spec;
5use super::super::process::{ProcessRunner, StdioMode};
6use super::super::ssh::{SshEnv, SshSession};
7use crate::error::Result;
8use crate::VERSION;
9use std::path::PathBuf;
10
11/// Default GitHub repo that publishes box Release tar.gz assets.
12pub const DEFAULT_GITHUB_REPO: &str = "Hortos-Network/horto-os-ui";
13
14/// Remote directory for the temporary CLI apply agent.
15pub const DEFAULT_REMOTE_AGENT_DIR: &str = "/tmp/horto-os-ui-remote";
16
17/// Permanent install directory for CLI + TUI + status-api + MCP on the box.
18pub const DEFAULT_INSTALL_DIR: &str = ECO_INSTALL_DIR;
19
20/// Options for a remote OpenSSH session and binary source.
21#[derive(Debug, Clone)]
22pub struct RemoteOptions {
23    /// OpenSSH Host alias or `user@host`.
24    pub host: String,
25    /// When true, run `ssh-copy-id` once before apply. Default false.
26    pub install_ssh_key: bool,
27    /// Local directory with the four box binaries (skips GitHub download).
28    pub bin_dir: Option<PathBuf>,
29    /// Workspace / Release version (`0.1.0`, without `v`). Used in asset filenames.
30    pub version: String,
31    /// GitHub Release tag to download from (`v0.1.0` or tip `dev-preview`).
32    pub release_tag: String,
33    /// `owner/repo` for Release downloads.
34    pub github_repo: String,
35    /// Force `SSH_ASKPASS` (Desktop / no TTY).
36    pub force_askpass: bool,
37    /// Cache root for downloaded Release assets.
38    pub cache_root: PathBuf,
39    /// Remote temp dir for the apply agent.
40    pub remote_agent_dir: String,
41    /// Remote install dir for permanent binaries.
42    pub install_dir: String,
43    /// Optional OpenSSH config file (`ssh -F`). Used by tests and custom layouts.
44    pub ssh_config_file: Option<PathBuf>,
45}
46
47impl Default for RemoteOptions {
48    fn default() -> Self {
49        let version = VERSION.to_owned();
50        let release_tag = super::super::bins::default_release_tag(&version);
51        Self {
52            host: String::new(),
53            install_ssh_key: false,
54            bin_dir: None,
55            version,
56            release_tag,
57            github_repo: DEFAULT_GITHUB_REPO.to_owned(),
58            force_askpass: false,
59            cache_root: super::super::bins::default_cache_root(),
60            remote_agent_dir: DEFAULT_REMOTE_AGENT_DIR.to_owned(),
61            install_dir: DEFAULT_INSTALL_DIR.to_owned(),
62            ssh_config_file: None,
63        }
64    }
65}
66
67/// Common inputs shared by CLI, TUI, desktop, and MCP when building [`RemoteOptions`].
68#[derive(Debug, Clone, Default)]
69pub struct RemoteOptionsInput {
70    /// OpenSSH Host alias or `user@host`.
71    pub host: String,
72    /// When true, run `ssh-copy-id` once before apply.
73    pub install_ssh_key: bool,
74    /// Local directory with the four box binaries (skips GitHub download).
75    pub bin_dir: Option<PathBuf>,
76    /// Override Release tag when non-empty after trim; otherwise keep default.
77    pub release_tag: Option<String>,
78    /// Force `SSH_ASKPASS` (TUI / desktop / MCP / no TTY).
79    pub force_askpass: bool,
80}
81
82impl RemoteOptions {
83    /// Build session options from shared surface inputs (one place for askpass / tag policy).
84    #[must_use]
85    pub fn from_input(input: RemoteOptionsInput) -> Self {
86        let mut opts = Self {
87            host: input.host,
88            install_ssh_key: input.install_ssh_key,
89            bin_dir: input.bin_dir,
90            force_askpass: input.force_askpass,
91            ..Self::default()
92        };
93        if let Some(tag) = input
94            .release_tag
95            .as_ref()
96            .map(|t| t.trim())
97            .filter(|t| !t.is_empty())
98        {
99            tag.clone_into(&mut opts.release_tag);
100        }
101        opts
102    }
103}
104
105/// Boolean switches for a remote CLI run.
106#[allow(clippy::struct_excessive_bools)]
107#[derive(Debug, Clone, Copy, Default)]
108pub struct RemoteRunFlags {
109    /// Prefix with `sudo -n` / `sudo` when true (apply mode).
110    pub use_sudo: bool,
111    /// After a successful command, install selected ecosystem services on the box.
112    pub install_payload_on_success: bool,
113    /// After success (and payload install), offer an interactive box reboot (TTY only).
114    pub offer_reboot_on_success: bool,
115    /// Capture remote stdout/stderr instead of inheriting the local TTY.
116    ///
117    /// Use for read-only status/doctor so callers can parse JSON. Keep false for
118    /// apply paths that need interactive sudo / password prompts.
119    pub capture_output: bool,
120}
121
122/// CLI arguments to run on the box via the uploaded agent (without the binary name).
123#[derive(Debug, Clone)]
124pub struct RemoteRunRequest {
125    /// Session and binary options.
126    pub options: RemoteOptions,
127    /// Arguments after the remote `horto-os-ui` binary (e.g. `["setup", "run", "--full"]` or with `--apply`).
128    pub cli_args: Vec<String>,
129    /// Sudo / payload / reboot / capture switches.
130    pub flags: RemoteRunFlags,
131    /// Which services to install when [`RemoteRunFlags::install_payload_on_success`] is set.
132    pub ecosystem: EcosystemInstallChoice,
133}
134
135impl std::ops::Deref for RemoteRunRequest {
136    type Target = RemoteRunFlags;
137
138    fn deref(&self) -> &Self::Target {
139        &self.flags
140    }
141}
142
143pub fn session_from(opts: &RemoteOptions) -> Result<SshSession> {
144    Ok(SshSession {
145        host: parse_host_spec(&opts.host)?,
146        env: SshEnv {
147            force_askpass: opts.force_askpass,
148        },
149        config_file: opts.ssh_config_file.clone(),
150    })
151}
152
153pub fn shell_quote(arg: &str) -> String {
154    if arg.is_empty() {
155        return "''".to_owned();
156    }
157    if arg
158        .chars()
159        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '=' | ':' | '+'))
160    {
161        return arg.to_owned();
162    }
163    format!("'{}'", arg.replace('\'', "'\\''"))
164}
165
166/// Format an operator progress banner for remote SSH/SCP/key steps.
167#[must_use]
168pub fn remote_progress_message(host: &str, detail: &str) -> String {
169    format!("[horto remote] PC → box '{host}': {detail}")
170}
171
172/// Detail line for a remote CLI run banner.
173#[must_use]
174pub fn remote_run_banner_detail(remote_cmd: &str, use_sudo: bool) -> String {
175    if use_sudo {
176        format!("run `{remote_cmd}` (SSH + sudo; may ask password)")
177    } else {
178        format!("run `{remote_cmd}` (SSH; may ask password)")
179    }
180}
181
182pub fn remote_progress(host: &str, detail: &str) {
183    tracing::info!("{}", remote_progress_message(host, detail));
184}
185
186/// Log the remote doctor JSON header (stderr via tracing).
187pub fn remote_doctor_report_banner() {
188    tracing::info!("[horto remote] doctor report from box (JSON):");
189}
190
191pub fn remote_agent_bin(opts: &RemoteOptions) -> String {
192    format!(
193        "{}/horto-os-ui",
194        opts.remote_agent_dir.trim_end_matches('/')
195    )
196}
197
198pub fn remote_install_bin(opts: &RemoteOptions) -> String {
199    format!("{}/horto-os-ui", opts.install_dir.trim_end_matches('/'))
200}
201
202pub fn remote_cli_candidates(opts: &RemoteOptions) -> [String; 2] {
203    [remote_install_bin(opts), remote_agent_bin(opts)]
204}
205
206pub fn build_remote_command_at(bin: &str, cli_args: &[String], use_sudo: bool) -> String {
207    let mut parts = Vec::new();
208    if use_sudo {
209        parts.push("sudo".to_owned());
210    }
211    parts.push(shell_quote(bin));
212    for a in cli_args {
213        parts.push(shell_quote(a));
214    }
215    parts.join(" ")
216}
217
218pub fn merge_command_log(out: &super::super::process::CommandOutput, remote_cmd: &str) -> String {
219    let mut log = out.stdout.clone();
220    if !out.stderr.trim().is_empty() {
221        if !log.is_empty() {
222            log.push('\n');
223        }
224        log.push_str(&out.stderr);
225    }
226    if log.trim().is_empty() {
227        format!("remote command finished: {remote_cmd}")
228    } else {
229        log
230    }
231}
232
233/// Run one remote CLI argv list on an already-resolved box binary (Capture).
234pub fn exec_remote_cli_captured(
235    runner: &dyn ProcessRunner,
236    session: &SshSession,
237    remote_bin: &str,
238    opts: &RemoteOptions,
239    cli_args: &[String],
240) -> Result<String> {
241    let remote_cmd = build_remote_command_at(remote_bin, cli_args, false);
242    remote_progress(&opts.host, &remote_run_banner_detail(&remote_cmd, false));
243    let out = session.exec(runner, &remote_cmd, StdioMode::Capture)?;
244    Ok(merge_command_log(&out, &remote_cmd))
245}