1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
use clap::{Args, Subcommand};
use homeboy::engine::shell;
use homeboy::server::{self, Server};
use homeboy::ssh::{resolve_context, SshClient, SshResolveArgs};
use serde::Serialize;
use super::CmdResult;
#[derive(Args)]
pub struct SshArgs {
/// Target ID (project or server; project wins when ambiguous)
pub target: Option<String>,
/// Command to execute (omit for interactive shell).
///
/// Examples:
/// homeboy ssh my-project -- ls -la
/// homeboy ssh my-project -- wp plugin list
///
/// If you need shell operators (&&, |, redirects), pass a single quoted string:
/// homeboy ssh my-project "cd /var/www && ls | head"
#[arg(num_args = 0.., trailing_var_arg = true)]
pub command: Vec<String>,
/// Force interpretation as server ID
#[arg(long)]
pub as_server: bool,
#[command(subcommand)]
pub subcommand: Option<SshSubcommand>,
}
#[derive(Subcommand)]
pub enum SshSubcommand {
/// List configured SSH server targets
List,
}
#[derive(Debug, Serialize)]
#[serde(tag = "action")]
pub enum SshOutput {
Connect(SshConnectOutput),
List(SshListOutput),
}
#[derive(Debug, Serialize)]
pub struct SshConnectOutput {
pub resolved_type: String,
pub project_id: Option<String>,
pub server_id: String,
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr: Option<String>,
pub success: bool,
}
#[derive(Debug, Serialize)]
pub struct SshListOutput {
pub servers: Vec<Server>,
}
pub fn run(args: SshArgs, _global: &crate::commands::GlobalArgs) -> CmdResult<SshOutput> {
match args.subcommand {
Some(SshSubcommand::List) => {
let servers = server::list()?;
Ok((SshOutput::List(SshListOutput { servers }), 0))
}
None => {
// Build resolve args based on simplified CLI args
let resolve_args = if args.as_server {
SshResolveArgs {
id: None,
project: None,
server: args.target.clone(),
}
} else {
SshResolveArgs {
id: args.target.clone(),
project: None,
server: None,
}
};
let result = resolve_context(&resolve_args)?;
let command_string: Option<String> = if args.command.is_empty() {
None
} else if args.command.len() == 1 {
// Preserve legacy behavior: a single string is treated as a raw shell command.
Some(args.command[0].clone())
} else {
// Multi-arg form (typically from `-- <cmd...>`): quote args safely.
// Note: this intentionally does NOT support shell operators; pass a single string for that.
Some(shell::quote_args(&args.command))
};
// When project is resolved with base_path, auto-cd to project root
let effective_command = match (&result.project_id, &result.base_path, &command_string) {
// Project with base_path and command: cd to base_path then run command
(Some(_), Some(bp), Some(cmd)) => {
Some(format!("cd {} && {}", shell::quote_path(bp), cmd))
}
// Project with base_path, no command: interactive shell starts in base_path
(Some(_), Some(bp), None) => Some(format!("cd {}", shell::quote_path(bp))),
// No project context or no base_path: use command as-is
_ => command_string.clone(),
};
let client = SshClient::from_server(&result.server, &result.server_id)?;
if !args.command.is_empty() {
// Non-interactive: capture output for JSON response
let cmd = effective_command.as_deref().ok_or_else(|| {
homeboy::Error::internal_unexpected(
"No command resolved for non-interactive SSH execution".to_string(),
)
})?;
let output = client.execute(cmd);
Ok((
SshOutput::Connect(SshConnectOutput {
resolved_type: result.resolved_type,
project_id: result.project_id,
server_id: result.server_id,
// Prefer the quoted/normalized command string for JSON output so
// multi-arg invocations remain unambiguous (e.g. args containing spaces).
command: command_string.clone(),
stdout: Some(output.stdout),
stderr: Some(output.stderr),
success: output.success,
}),
output.exit_code,
))
} else {
// Interactive: TTY passthrough
let exit_code = client.execute_interactive(effective_command.as_deref());
Ok((
SshOutput::Connect(SshConnectOutput {
resolved_type: result.resolved_type,
project_id: result.project_id,
server_id: result.server_id,
command: None,
stdout: None,
stderr: None,
success: exit_code == 0,
}),
exit_code,
))
}
}
}
}