Skip to main content

horto_os_ui_shared/remote/runner/
apply.rs

1//! Prepare / upload remote CLI, run commands, and install payload.
2
3use super::super::arch::box_arch_from_uname;
4use super::super::bins::{ensure_local_bins, LocalBins};
5use super::super::ecosystem::{
6    remote_enable_ecosystem_script, EcosystemInstallChoice, API_TOKEN_DROP_BASENAME,
7};
8use super::super::process::{ProcessRunner, StdioMode};
9use super::super::ssh::SshSession;
10use super::super::transfer::transfer_files;
11use super::options::{
12    build_remote_command_at, merge_command_log, remote_agent_bin, remote_progress,
13    remote_run_banner_detail, session_from, shell_quote, RemoteOptions, RemoteRunFlags,
14    RemoteRunRequest,
15};
16use super::probe::{probe_remote_cli, RemoteCliProbe};
17use super::reboot::offer_remote_reboot;
18use super::token::parse_api_token_drop;
19use crate::error::Result;
20use std::path::Path;
21
22pub fn upload_remote_cli(
23    runner: &dyn ProcessRunner,
24    session: &SshSession,
25    opts: &RemoteOptions,
26    bins: &LocalBins,
27) -> Result<String> {
28    session.exec(
29        runner,
30        &format!("mkdir -p {}", shell_quote(&opts.remote_agent_dir)),
31        StdioMode::Capture,
32    )?;
33    let remote_bin = remote_agent_bin(opts);
34    remote_progress(&opts.host, "upload CLI agent (SCP; may ask password)");
35    session.scp_to(runner, &bins.cli, &remote_bin)?;
36    session.exec(
37        runner,
38        &format!("chmod +x {}", shell_quote(&remote_bin)),
39        StdioMode::Capture,
40    )?;
41    Ok(remote_bin)
42}
43
44/// Opt-in: install the tip pubkey on the box (`ssh-copy-id`).
45///
46/// # Errors
47///
48/// Returns [`crate::HortoError`] when SSH or `ssh-copy-id` fails.
49pub fn remote_ensure_ssh_key(runner: &dyn ProcessRunner, opts: &RemoteOptions) -> Result<()> {
50    let session = session_from(opts)?;
51    session.install_ssh_key(runner)
52}
53
54/// Upload the PC CLI to the box agent dir (s0 / explicit sync).
55///
56/// # Errors
57///
58/// Returns [`crate::HortoError`] on SSH/SCP/local-bin failures.
59pub fn remote_upload_cli(
60    runner: &dyn ProcessRunner,
61    opts: &RemoteOptions,
62) -> Result<RemoteCliProbe> {
63    let session = session_from(opts)?;
64    remote_progress(&opts.host, "probe arch (SSH; may ask password)");
65    let out = session.exec(runner, "uname -m", StdioMode::Capture)?;
66    let arch = box_arch_from_uname(&out.stdout)?;
67    let bins = ensure_local_bins(
68        runner,
69        &opts.release_tag,
70        &opts.version,
71        &opts.github_repo,
72        arch,
73        opts.bin_dir.as_deref(),
74        &opts.cache_root,
75    )?;
76    upload_remote_cli(runner, &session, opts, &bins)?;
77    maybe_install_key(runner, &session, opts)?;
78    probe_remote_cli(runner, &session, opts)
79}
80
81/// Ensure a current CLI on the box: reuse when version matches, otherwise SCP once.
82pub fn ensure_remote_cli(
83    runner: &dyn ProcessRunner,
84    session: &SshSession,
85    opts: &RemoteOptions,
86    bins: &LocalBins,
87) -> Result<String> {
88    let probe = probe_remote_cli(runner, session, opts)?;
89    if probe.current {
90        if let Some(path) = probe.path {
91            remote_progress(&opts.host, "CLI on box already current; skip upload");
92            return Ok(path);
93        }
94    }
95    upload_remote_cli(runner, session, opts, bins)
96}
97
98pub fn maybe_install_key(
99    runner: &dyn ProcessRunner,
100    session: &SshSession,
101    opts: &RemoteOptions,
102) -> Result<()> {
103    if opts.install_ssh_key {
104        session.install_ssh_key(runner)?;
105    }
106    Ok(())
107}
108
109/// Prepare OpenSSH session and local tip bins; SCP CLI only when box is stale/missing.
110pub fn prepare_remote_agent(
111    runner: &dyn ProcessRunner,
112    opts: &RemoteOptions,
113) -> Result<(SshSession, LocalBins, String)> {
114    let session = session_from(opts)?;
115    remote_progress(&opts.host, "probe arch (SSH; may ask password)");
116    let out = session.exec(runner, "uname -m", StdioMode::Capture)?;
117    let arch = box_arch_from_uname(&out.stdout)?;
118    let bins = ensure_local_bins(
119        runner,
120        &opts.release_tag,
121        &opts.version,
122        &opts.github_repo,
123        arch,
124        opts.bin_dir.as_deref(),
125        &opts.cache_root,
126    )?;
127    let remote_bin = ensure_remote_cli(runner, &session, opts, &bins)?;
128    maybe_install_key(runner, &session, opts)?;
129    Ok((session, bins, remote_bin))
130}
131
132/// Result of a remote CLI run (log text plus optional captured status-api bearer).
133#[derive(Debug, Clone, Default, PartialEq, Eq)]
134pub struct RemoteRunOutcome {
135    /// Merged remote stdout/stderr (or a short finished marker when Inherit was empty).
136    pub log: String,
137    /// Hex bearer from payload install, when captured from the box drop file.
138    pub api_token: Option<String>,
139}
140
141/// Upload the CLI agent (if needed), optionally install a public key, run a remote CLI command.
142///
143/// # Errors
144///
145/// Returns [`crate::HortoError`] on SSH/SCP/agent failures.
146pub fn remote_run_cli(
147    runner: &dyn ProcessRunner,
148    req: &RemoteRunRequest,
149) -> Result<RemoteRunOutcome> {
150    let opts = &req.options;
151    let (session, bins, remote_bin) = prepare_remote_agent(runner, opts)?;
152
153    let remote_cmd = build_remote_command_at(&remote_bin, &req.cli_args, req.use_sudo);
154    remote_progress(
155        &opts.host,
156        &remote_run_banner_detail(&remote_cmd, req.use_sudo),
157    );
158    let stdio = if req.capture_output {
159        StdioMode::Capture
160    } else {
161        // Inherit so SSH/sudo password prompts work on a TTY (CLI/TUI apply).
162        StdioMode::Inherit
163    };
164    let out = session.exec(runner, &remote_cmd, stdio)?;
165    let log = merge_command_log(&out, &remote_cmd);
166
167    let api_token = if req.install_payload_on_success && req.ecosystem.any() {
168        remote_install_payload(runner, opts, &bins, req.ecosystem)?
169    } else {
170        None
171    };
172    if req.offer_reboot_on_success {
173        offer_remote_reboot(runner, &session)?;
174    }
175    Ok(RemoteRunOutcome { log, api_token })
176}
177
178/// Convenience: remote `setup run` with optional payload install.
179///
180/// # Errors
181///
182/// Returns [`crate::HortoError`] when the remote setup fails.
183pub fn remote_setup_run(
184    runner: &dyn ProcessRunner,
185    opts: RemoteOptions,
186    apply: bool,
187    full: bool,
188    skip_piper: bool,
189    ecosystem: EcosystemInstallChoice,
190) -> Result<RemoteRunOutcome> {
191    let mut cli_args = Vec::new();
192    if apply {
193        cli_args.push("--apply".into());
194    }
195    if skip_piper {
196        cli_args.push("--skip-piper".into());
197    }
198    cli_args.push("setup".into());
199    cli_args.push("run".into());
200    if full {
201        cli_args.push("--full".into());
202    } else {
203        cli_args.push("--minimal".into());
204    }
205    remote_run_cli(
206        runner,
207        &RemoteRunRequest {
208            options: opts,
209            cli_args,
210            ecosystem,
211            flags: RemoteRunFlags {
212                use_sudo: apply,
213                install_payload_on_success: apply && ecosystem.any(),
214                offer_reboot_on_success: apply,
215                capture_output: false,
216            },
217        },
218    )
219}
220
221/// Copy CLI + TUI + status-api (+ MCP when present) into `install_dir` and enable units.
222///
223/// Missing `horto-os-ui-mcp` skips MCP transfer/enable; other units still install.
224/// Returns the captured hex bearer when the drop file can be read.
225///
226/// # Errors
227///
228/// Returns [`crate::HortoError`] when transfer or remote enable fails.
229pub fn remote_install_payload(
230    runner: &dyn ProcessRunner,
231    opts: &RemoteOptions,
232    bins: &LocalBins,
233    choice: EcosystemInstallChoice,
234) -> Result<Option<String>> {
235    let choice = EcosystemInstallChoice {
236        status_api: choice.status_api,
237        mcp: choice.mcp && bins.mcp.is_some(),
238    };
239    if !choice.any() {
240        return Ok(None);
241    }
242    let session = session_from(opts)?;
243    let staging = format!("{}/payload", opts.remote_agent_dir.trim_end_matches('/'));
244    remote_progress(
245        &opts.host,
246        "install box payload (SCP/SSH; may ask password)",
247    );
248    session.exec(
249        runner,
250        &format!("mkdir -p {}", shell_quote(&staging)),
251        StdioMode::Capture,
252    )?;
253
254    let mut locals: Vec<&Path> = vec![&bins.cli, &bins.tui, &bins.status_api];
255    if let Some(ref mcp) = bins.mcp {
256        locals.push(mcp.as_path());
257    }
258    transfer_files(runner, &session, &locals, &staging)?;
259
260    let install = opts.install_dir.trim_end_matches('/');
261    remote_progress(
262        &opts.host,
263        "enable selected ecosystem services on box (SSH + sudo; may ask password)",
264    );
265    let enable = remote_enable_ecosystem_script(&staging, install, choice);
266    session.exec(runner, &enable, StdioMode::Inherit)?;
267
268    let mut api_token = None;
269    if choice.status_api {
270        let drop_path = format!("$HOME/{API_TOKEN_DROP_BASENAME}");
271        let cat_out = session.exec(
272            runner,
273            &format!("cat {drop_path} 2>/dev/null || true"),
274            StdioMode::Capture,
275        )?;
276        api_token = parse_api_token_drop(&cat_out.stdout);
277        let _ = session.exec(runner, &format!("rm -f {drop_path}"), StdioMode::Capture);
278    }
279
280    Ok(api_token)
281}