Skip to main content

sc/cli/commands/
remote.rs

1//! Remote SSH proxy command.
2//!
3//! Runs sc commands on a remote host via SSH, using the configuration
4//! stored by `sc config remote set`.
5//!
6//! All arguments after `sc remote` are forwarded as-is to the remote sc binary.
7//! Each argument is individually shell-quoted to prevent injection.
8//! Example: `sc remote status` → `ssh user@host 'sc' 'status' '--json'`
9
10use crate::cli::commands::config::{build_ssh_base_args, load_remote_config, shell_quote};
11use crate::error::{Error, Result};
12use std::path::PathBuf;
13use std::process::Command;
14use tracing::debug;
15
16/// Execute a remote command via SSH proxy.
17pub fn execute(args: &[String], _db_path: Option<&PathBuf>, json: bool) -> Result<()> {
18    if args.is_empty() {
19        return Err(Error::InvalidArgument(
20            "No command specified. Usage: sc remote <command> [args...]".to_string(),
21        ));
22    }
23
24    let config = load_remote_config()?;
25
26    // Build the remote sc command with each arg individually quoted
27    let sc_path = config.remote_sc_path.as_deref().unwrap_or("sc");
28    let mut quoted_parts: Vec<String> = vec![shell_quote(sc_path)];
29    for arg in args {
30        quoted_parts.push(shell_quote(arg));
31    }
32
33    // Always add --json for structured output if not already present
34    let has_json_flag = args.iter().any(|a| a == "--json" || a == "--format=json");
35    if json && !has_json_flag {
36        quoted_parts.push(shell_quote("--json"));
37    }
38
39    let remote_cmd = quoted_parts.join(" ");
40    debug!(remote_cmd = %remote_cmd, "Executing remote command");
41
42    // Build SSH command using shared helper
43    let mut ssh_args = build_ssh_base_args(&config);
44    ssh_args.push(remote_cmd);
45
46    debug!(ssh_args = ?ssh_args, "SSH command");
47
48    let output = Command::new("ssh")
49        .args(&ssh_args)
50        .output()
51        .map_err(|e| {
52            Error::Remote(format!(
53                "Failed to execute ssh: {e}. Is ssh installed and in PATH?"
54            ))
55        })?;
56
57    // Relay stdout directly
58    if !output.stdout.is_empty() {
59        print!("{}", String::from_utf8_lossy(&output.stdout));
60    }
61
62    // Relay stderr
63    if !output.stderr.is_empty() {
64        eprint!("{}", String::from_utf8_lossy(&output.stderr));
65    }
66
67    if !output.status.success() {
68        let code = output.status.code().unwrap_or(1);
69        return Err(Error::Remote(format!(
70            "Remote command failed with exit code {code}"
71        )));
72    }
73
74    Ok(())
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::cli::commands::config::RemoteConfig;
81
82    #[test]
83    fn test_build_ssh_base_args_default_port() {
84        let config = RemoteConfig {
85            host: "example.com".to_string(),
86            user: "shane".to_string(),
87            port: 22,
88            identity_file: None,
89            remote_sc_path: Some("sc".to_string()),
90            remote_project_path: None,
91            remote_db_path: None,
92        };
93
94        let args = build_ssh_base_args(&config);
95        assert!(args.contains(&"shane@example.com".to_string()));
96        assert!(!args.contains(&"-p".to_string()));
97    }
98
99    #[test]
100    fn test_build_ssh_base_args_custom_port() {
101        let config = RemoteConfig {
102            host: "example.com".to_string(),
103            user: "shane".to_string(),
104            port: 2222,
105            identity_file: None,
106            remote_sc_path: None,
107            remote_project_path: None,
108            remote_db_path: None,
109        };
110
111        let args = build_ssh_base_args(&config);
112        assert!(args.contains(&"-p".to_string()));
113        assert!(args.contains(&"2222".to_string()));
114    }
115
116    #[test]
117    fn test_build_ssh_base_args_with_identity() {
118        let config = RemoteConfig {
119            host: "example.com".to_string(),
120            user: "shane".to_string(),
121            port: 22,
122            identity_file: Some("~/.ssh/id_rsa".to_string()),
123            remote_sc_path: None,
124            remote_project_path: None,
125            remote_db_path: None,
126        };
127
128        let args = build_ssh_base_args(&config);
129        assert!(args.contains(&"-i".to_string()));
130        assert!(args.contains(&"~/.ssh/id_rsa".to_string()));
131    }
132}