ssh_cli/cli/commands.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: top-level clap Command tree extracted from cli/mod (SRP; line budget).
3#![forbid(unsafe_code)]
4//! Top-level and nested clap action enums (except `vps` / `scp` / `sftp`).
5
6use super::scp_args::ScpAction;
7use super::sftp_args::SftpAction;
8use super::vps_action::VpsAction;
9use super::SshAuthArgs;
10use clap::{ArgAction, Subcommand, ValueHint};
11use clap_complete::Shell;
12use std::path::PathBuf;
13
14/// Top-level subcommands.
15#[derive(Debug, Subcommand)]
16pub enum Command {
17 /// Manages registered VPS hosts.
18 Vps {
19 /// Specific VPS CRUD action.
20 #[command(subcommand)]
21 action: VpsAction,
22 },
23
24 /// Sets the active VPS (writes sibling `active` file in the config directory).
25 Connect {
26 /// Name of the VPS previously added via `vps add`.
27 name: String,
28 },
29
30 /// Runs a command on the VPS over SSH (stdout/stderr captured).
31 ///
32 /// Positionals: `VPS COMMAND`, or with `--all`/`--hosts`, only `COMMAND`
33 /// (`ssh-cli exec --all uptime`, `ssh-cli exec --hosts a,b uptime`).
34 /// Extra steps on the **same** SSH session: `--step cmd2 --step cmd3` (G-O3).
35 Exec {
36 /// Run on every registered host (bounded concurrency). When set, pass
37 /// only the shell command as the single positional.
38 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["hosts", "tags"])]
39 all: bool,
40 /// Comma-separated host subset (bounded fan-out). Batch JSON even for one name.
41 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "tags"])]
42 hosts: Option<String>,
43 /// Select hosts that have **any** of these tags (OR). Batch JSON (G-O2).
44 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "hosts"])]
45 tags: Option<String>,
46 /// `VPS COMMAND` (one host) or `COMMAND` only when `--all` / `--hosts` / `--tags`.
47 #[arg(required = true, num_args = 1..=2, value_names = ["VPS", "COMMAND"])]
48 target: Vec<String>,
49 /// Additional commands on the same SSH session after the primary (G-O3).
50 #[arg(long = "step", value_name = "CMD", action = ArgAction::Append)]
51 steps: Vec<String>,
52 /// JSON output (from global `--json` / format; G-AUD-01).
53 #[arg(from_global)]
54 json: bool,
55 /// SSH authentication overrides (password/key/passphrase).
56 #[command(flatten)]
57 auth: SshAuthArgs,
58 /// Timeout override in milliseconds.
59 #[arg(long, value_name = "MS")]
60 timeout: Option<u64>,
61 /// Shell comment appended for audit trails.
62 #[arg(long)]
63 description: Option<String>,
64 },
65
66 /// Runs a command with `sudo` (safe `sh -c` packing).
67 ///
68 /// Positionals: `VPS COMMAND` or, with `--all`/`--hosts`/`--tags`, only `COMMAND`.
69 SudoExec {
70 /// Run on every registered host (bounded concurrency).
71 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["hosts", "tags"])]
72 all: bool,
73 /// Comma-separated host subset (bounded fan-out).
74 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "tags"])]
75 hosts: Option<String>,
76 /// Select hosts by tag (OR). Batch JSON (G-O2).
77 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "hosts"])]
78 tags: Option<String>,
79 /// `VPS COMMAND` (one host) or `COMMAND` only when batch selection.
80 #[arg(required = true, num_args = 1..=2, value_names = ["VPS", "COMMAND"])]
81 target: Vec<String>,
82 /// Extra commands on the same session (G-O3).
83 #[arg(long = "step", value_name = "CMD", action = ArgAction::Append)]
84 steps: Vec<String>,
85 /// JSON output (from global `--json` / format; G-AUD-01).
86 #[arg(from_global)]
87 json: bool,
88 /// SSH authentication overrides (password/key/passphrase).
89 #[command(flatten)]
90 auth: SshAuthArgs,
91 /// Sudo password override.
92 #[arg(
93 long,
94 alias = "sudoPassword",
95 alias = "sudo_password",
96 conflicts_with = "sudo_password_stdin"
97 )]
98 sudo_password: Option<String>,
99 /// Reads the sudo password from stdin.
100 #[arg(long, action = ArgAction::SetTrue)]
101 sudo_password_stdin: bool,
102 /// Timeout override in milliseconds.
103 #[arg(long, value_name = "MS")]
104 timeout: Option<u64>,
105 /// Shell comment appended for audit.
106 #[arg(long)]
107 description: Option<String>,
108 },
109
110 /// Runs a command with one-shot `su -` elevation.
111 ///
112 /// Positionals: `VPS COMMAND` or, with `--all`/`--hosts`, only `COMMAND`.
113 SuExec {
114 /// Run on every registered host (bounded concurrency).
115 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["hosts", "tags"])]
116 all: bool,
117 /// Comma-separated host subset (bounded fan-out).
118 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "tags"])]
119 hosts: Option<String>,
120 /// Select hosts by tag (OR). Batch JSON (G-O2).
121 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "hosts"])]
122 tags: Option<String>,
123 /// `VPS COMMAND` (one host) or `COMMAND` only when batch selection.
124 #[arg(required = true, num_args = 1..=2, value_names = ["VPS", "COMMAND"])]
125 target: Vec<String>,
126 /// Extra commands on the same session (G-O3).
127 #[arg(long = "step", value_name = "CMD", action = ArgAction::Append)]
128 steps: Vec<String>,
129 /// JSON output (from global `--json` / format; G-AUD-01).
130 #[arg(from_global)]
131 json: bool,
132 /// SSH authentication overrides (password/key/passphrase).
133 #[command(flatten)]
134 auth: SshAuthArgs,
135 /// Su password override.
136 #[arg(
137 long,
138 alias = "suPassword",
139 alias = "su_password",
140 conflicts_with = "su_password_stdin"
141 )]
142 su_password: Option<String>,
143 /// Reads the su password from stdin.
144 #[arg(long, action = ArgAction::SetTrue)]
145 su_password_stdin: bool,
146 /// Timeout override.
147 #[arg(long, value_name = "MS")]
148 timeout: Option<u64>,
149 /// Shell comment appended for audit.
150 #[arg(long)]
151 description: Option<String>,
152 },
153
154 /// SCP file transfer (upload/download).
155 Scp {
156 /// Specific SCP action.
157 #[command(subcommand)]
158 action: ScpAction,
159 },
160
161 /// SFTP subsystem transfer and remote filesystem ops (G-SFTP).
162 Sftp {
163 /// Specific SFTP action.
164 #[command(subcommand)]
165 action: SftpAction,
166 },
167
168 /// SSH tunnel with mandatory deadline (bounded one-shot).
169 ///
170 /// Contract: **one** local bind + **one** SSH session per invocation (G-PAR-30).
171 /// Multi-host tunnels = N one-shots with distinct `--bind`/ports. Forward
172 /// accepts still use JoinSet + Semaphore (`--max-concurrency`).
173 Tunnel {
174 /// VPS name (single host only — no `--all` / `--hosts`).
175 vps_name: String,
176 /// Local port to bind — with `--reverse`, the local port that receives.
177 local_port: u16,
178 /// Remote host — with `--reverse`, the address the **server** binds.
179 ///
180 /// Optional since 0.5.4: `--socks5` chooses a destination per connection
181 /// and `--remote-socket` names a Unix socket, so neither has one.
182 remote_host: Option<String>,
183 /// Remote port — with `--reverse`, the server port (`0` = server allocates).
184 ///
185 /// Reverse accepts `0` because the server then reports the port it bound;
186 /// a local forward cannot, since there is nothing to connect to.
187 #[arg(value_parser = clap::value_parser!(u16).range(0..=65535))]
188 remote_port: Option<u16>,
189 /// Serve a SOCKS5 proxy locally instead of a fixed forward (G-TUN-R02).
190 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["reverse", "remote_socket"])]
191 socks5: bool,
192 /// Forward to a Unix domain socket on the remote host (G-TUN-R03).
193 #[arg(long, value_name = "PATH", conflicts_with_all = ["reverse", "socks5"])]
194 remote_socket: Option<String>,
195 /// Ask the server to listen and deliver connections back here (G-TUN-R01).
196 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["socks5", "remote_socket"])]
197 reverse: bool,
198 /// Mandatory tunnel timeout in milliseconds.
199 #[arg(long, value_name = "MS")]
200 timeout_ms: u64,
201 /// SSH authentication overrides (password/key/passphrase).
202 #[command(flatten)]
203 auth: SshAuthArgs,
204 /// Agent-first JSON output when the local listener is up (GAP-SSH-IO-008).
205 #[arg(from_global)]
206 json: bool,
207 /// Local bind address (default loopback for security).
208 ///
209 /// G-TUN-R08: validated by clap as an IP address, so a typo like
210 /// `127.0.0..1` fails at parse time (exit 2) instead of after resolving the
211 /// host, opening the SSH session and authenticating — which on a host with
212 /// MFA or slow auth meant paying a full handshake to learn about a typo.
213 #[arg(
214 long,
215 default_value = crate::constants::DEFAULT_TUNNEL_BIND_ADDR,
216 value_name = "ADDR",
217 value_parser = clap::value_parser!(std::net::IpAddr)
218 )]
219 bind: std::net::IpAddr,
220 /// Acknowledge that a non-loopback bind exposes the forwarded service.
221 ///
222 /// G-TUN-R13: required for any routable bind. Without it, `--bind 0.0.0.0`
223 /// silently published the remote service to the local network. Under
224 /// `--reverse` it guards the **server's** bind address instead, which is
225 /// the end that is exposed in that direction.
226 #[arg(long, action = ArgAction::SetTrue)]
227 i_accept_network_exposure: bool,
228 },
229
230 /// Checks SSH connectivity to a VPS (or multi-host with `--all` / `--hosts`).
231 HealthCheck {
232 /// VPS name (uses active if omitted; ignored with `--all` / `--hosts`).
233 #[arg(conflicts_with_all = ["all", "hosts"])]
234 vps_name: Option<String>,
235 /// Probe every registered host in parallel (bounded concurrency).
236 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "hosts")]
237 all: bool,
238 /// Comma-separated host subset (bounded fan-out). Batch JSON even for one name.
239 #[arg(long, value_name = "LIST", conflicts_with = "all")]
240 hosts: Option<String>,
241 /// JSON output (GAP-SSH-IO-002). Single host: classic object; multi: batch.
242 #[arg(from_global)]
243 json: bool,
244 /// SSH authentication overrides (password/key/passphrase).
245 #[command(flatten)]
246 auth: SshAuthArgs,
247 /// SSH timeout override in milliseconds (GAP-SSH-CLI-004).
248 #[arg(long, value_name = "MS")]
249 timeout: Option<u64>,
250 },
251
252 /// Manages the primary key and at-rest secret encryption (one-shot).
253 Secrets {
254 /// Secrets action.
255 #[command(subcommand)]
256 action: SecretsAction,
257 },
258
259 /// Generates shell completions.
260 Completions {
261 /// Target shell.
262 #[arg(value_enum)]
263 shell: Shell,
264 },
265
266 /// Emits the full command tree as JSON (agent discovery / rules `mycli commands`).
267 Commands {
268 /// JSON output (from global `--json`).
269 #[arg(from_global)]
270 json: bool,
271 },
272
273 /// Emits embedded JSON Schema catalog or one schema body (G-E2E-02).
274 Schema {
275 /// Schema name (omit to list catalog). Example: `vps-list`.
276 name: Option<String>,
277 /// JSON catalog envelope when listing (from global `--json`).
278 #[arg(from_global)]
279 json: bool,
280 },
281
282 /// Root alias for `vps doctor` (XDG / schema diagnostics; G-E2E-03).
283 Doctor {
284 /// JSON output (from global `--json`).
285 #[arg(from_global)]
286 json: bool,
287 /// Also probe SSH health on registered hosts.
288 #[arg(long, action = ArgAction::SetTrue)]
289 probe_ssh: bool,
290 /// Comma-separated host subset for `--probe-ssh`.
291 #[arg(long, value_name = "LIST")]
292 hosts: Option<String>,
293 },
294
295 /// Diagnoses and manages UI language (locale resolution / XDG preference).
296 Locale {
297 /// JSON diagnostics (from global `--json` / format).
298 #[arg(from_global)]
299 json: bool,
300 /// Optional locale action (default: show status).
301 #[command(subcommand)]
302 action: Option<LocaleAction>,
303 },
304 /// TLS stack: provider status, mTLS identities, ACME certs (XDG; rustls only).
305 Tls {
306 /// JSON output (from global `--json`).
307 #[arg(from_global)]
308 json: bool,
309 /// TLS action.
310 #[command(subcommand)]
311 action: TlsAction,
312 },
313}
314
315/// Actions of the `tls` subcommand (SSH-over-TLS / mTLS / ACME).
316#[derive(Debug, Subcommand)]
317pub enum TlsAction {
318 /// Shows rustls CryptoProvider status (`aws_lc_rs`).
319 Provider,
320 /// Prints XDG TLS directory layout paths.
321 Paths,
322 /// Manages imported mTLS client identities under XDG `tls/mtls/`.
323 Mtls {
324 /// mTLS action.
325 #[command(subcommand)]
326 action: TlsMtlsAction,
327 },
328 /// ACME (Let's Encrypt) account + DNS-01 certificate lifecycle.
329 Acme {
330 /// ACME action.
331 #[command(subcommand)]
332 action: TlsAcmeAction,
333 },
334}
335
336/// mTLS identity store actions.
337#[derive(Debug, Subcommand)]
338pub enum TlsMtlsAction {
339 /// Lists imported identity names.
340 List,
341 /// Imports PEM cert+key as a named identity.
342 Import {
343 /// Identity name (XDG leaf).
344 #[arg(long)]
345 name: String,
346 /// Certificate chain PEM path.
347 #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
348 cert: PathBuf,
349 /// Private key PEM path.
350 #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
351 key: PathBuf,
352 },
353 /// Shows paths for one identity.
354 Show {
355 /// Identity name.
356 name: String,
357 },
358 /// Removes an identity directory.
359 Remove {
360 /// Identity name.
361 name: String,
362 },
363}
364
365/// ACME actions (DNS-01, agent two-step).
366#[derive(Debug, Subcommand)]
367pub enum TlsAcmeAction {
368 /// ACME account management.
369 Account {
370 /// Account action.
371 #[command(subcommand)]
372 action: TlsAcmeAccountAction,
373 },
374 /// Starts DNS-01 order and prints the TXT challenge (persists order URL under XDG).
375 Issue {
376 /// Domain name (DNS identifier).
377 #[arg(long)]
378 domain: String,
379 /// Use Let's Encrypt staging directory.
380 #[arg(long, action = ArgAction::SetTrue)]
381 staging: bool,
382 /// Required: print challenge and exit (agent-friendly; no interactive wait).
383 #[arg(long, action = ArgAction::SetTrue)]
384 print_challenge: bool,
385 },
386 /// Completes a pending order after DNS TXT is published.
387 Complete {
388 /// Domain name.
389 #[arg(long)]
390 domain: String,
391 },
392 /// Shows certificate / pending status for one domain or all.
393 Status {
394 /// Optional domain filter.
395 #[arg(long)]
396 domain: Option<String>,
397 },
398 /// Lists ACME domain directories under XDG.
399 List,
400}
401
402/// ACME account sub-actions.
403#[derive(Debug, Subcommand)]
404pub enum TlsAcmeAccountAction {
405 /// Creates an ACME account (credentials under XDG `tls/acme/account.json`, 0o600).
406 Create {
407 /// Use Let's Encrypt staging.
408 #[arg(long, action = ArgAction::SetTrue)]
409 staging: bool,
410 /// Contact URLs (e.g. `mailto:ops@example.com`). Required; repeatable (G-AUD-06).
411 #[arg(long = "contact", value_name = "URL", action = ArgAction::Append, required = true, num_args = 1..)]
412 contact: Vec<String>,
413 /// Replace existing account credentials.
414 #[arg(long, action = ArgAction::SetTrue)]
415 force: bool,
416 },
417 /// Shows whether an account exists and its path.
418 Show,
419}
420
421/// Actions of the `locale` subcommand.
422#[derive(Debug, Subcommand)]
423pub enum LocaleAction {
424 /// Shows resolved language, winning layer, and available locales (default).
425 Show,
426 /// Persists preferred language under the config directory (`lang` file, 0o600).
427 Set {
428 /// BCP47 tag that negotiates to a supported locale (`en`, `pt-BR`, …).
429 #[arg(value_name = "LOCALE", value_parser = crate::locale::parse_lang_cli_arg)]
430 lang: String,
431 },
432 /// Removes the persisted language preference.
433 Clear,
434}
435
436/// Actions of the `secrets` subcommand (primary-key / AEAD).
437#[derive(Debug, Subcommand)]
438pub enum SecretsAction {
439 /// Shows encryption status (no sensitive material).
440 Status {
441 /// JSON output (from global `--json`).
442 #[arg(from_global)]
443 json: bool,
444 },
445 /// Generates and stores the primary key (`secrets.key` or keyring). Never prints the key.
446 Init {
447 /// Store in the OS keyring instead of `secrets.key`.
448 #[arg(long)]
449 keyring: bool,
450 /// Overwrites an existing key.
451 #[arg(long)]
452 force: bool,
453 /// JSON success envelope (`event: secrets-init`; from global `--json`).
454 #[arg(from_global)]
455 json: bool,
456 },
457 /// Rewrites `config.toml` re-encrypting secrets with the current key.
458 Reencrypt {
459 /// JSON success envelope (`event: secrets-reencrypt`; from global `--json`).
460 #[arg(from_global)]
461 json: bool,
462 },
463}