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/// Usage block for `exec`, spelling the three ways to designate a target.
15///
16/// # Why this is overridden rather than left to clap
17///
18/// The positional is one vector (`num_args = 1..=2`) because clap fills positionals
19/// by order and cannot see which selector is active — two named slots would bind
20/// `exec --all uptime` to a `VPS` slot and leave the command empty. The cost of that
21/// correct choice is a generated usage line that reads `--use-active <VPS>
22/// [COMMAND]...`, advertising a shape the parser rejects. That line is printed at
23/// exactly the moment the caller already got it wrong, so it teaches the error twice.
24///
25/// Layout follows the clap contract: first line unindented, the rest indented by
26/// seven spaces, no trailing newline.
27const EXEC_USAGE_BLOCK: &str = "ssh-cli exec [OPTIONS] <VPS> <COMMAND>\n \
28 ssh-cli exec [OPTIONS] --use-active <COMMAND>\n \
29 ssh-cli exec [OPTIONS] --all|--hosts <LIST>|--tags <LIST> <COMMAND>";
30
31/// Usage block for `sudo-exec`. Same three forms, different verb.
32const SUDO_EXEC_USAGE_BLOCK: &str = "ssh-cli sudo-exec [OPTIONS] <VPS> <COMMAND>\n \
33 ssh-cli sudo-exec [OPTIONS] --use-active <COMMAND>\n \
34 ssh-cli sudo-exec [OPTIONS] --all|--hosts <LIST>|--tags <LIST> <COMMAND>";
35
36/// Usage block for `su-exec`. Same three forms, different verb.
37const SU_EXEC_USAGE_BLOCK: &str = "ssh-cli su-exec [OPTIONS] <VPS> <COMMAND>\n \
38 ssh-cli su-exec [OPTIONS] --use-active <COMMAND>\n \
39 ssh-cli su-exec [OPTIONS] --all|--hosts <LIST>|--tags <LIST> <COMMAND>";
40
41/// Usage block for `health-check`, which has a target but no command.
42const HEALTH_CHECK_USAGE: &str = "ssh-cli health-check [OPTIONS] <VPS_NAME>\n \
43 ssh-cli health-check [OPTIONS] --use-active\n \
44 ssh-cli health-check [OPTIONS] --all|--hosts <LIST>";
45
46/// Top-level subcommands.
47#[derive(Debug, Subcommand)]
48pub enum Command {
49 /// Manages registered VPS hosts.
50 Vps {
51 /// Specific VPS CRUD action.
52 #[command(subcommand)]
53 action: VpsAction,
54 },
55
56 /// Sets the active VPS (writes sibling `active` file in the config directory).
57 Connect {
58 /// Name of the VPS previously added via `vps add`.
59 name: String,
60 },
61
62 /// Runs a command on the VPS over SSH (stdout/stderr captured).
63 ///
64 /// The target is always designated explicitly, in one of three ways: two
65 /// positionals `VPS COMMAND`; a selector with one positional
66 /// (`--all`/`--hosts <LIST>`/`--tags <LIST>` `COMMAND`); or the active marker
67 /// under the deliberate opt-in (`--use-active COMMAND`). A lone positional with
68 /// no selector is a usage error, never a command aimed at whatever `connect`
69 /// last wrote (GAP-SSH-EXEC-ARGC-001).
70 ///
71 /// Extra steps on the **same** SSH session: `--step cmd2 --step cmd3` (G-O3).
72 /// Exit 127 on the first step aborts the batch, because that code there is the
73 /// signature of a host name having been read as a command.
74 #[command(override_usage = EXEC_USAGE_BLOCK)]
75 Exec {
76 /// Run on every registered host (bounded concurrency). When set, pass
77 /// only the shell command as the single positional.
78 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["hosts", "tags"])]
79 all: bool,
80 /// Comma-separated host subset (bounded fan-out). Batch JSON even for one name.
81 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "tags"])]
82 hosts: Option<String>,
83 /// Select hosts that have **any** of these tags (OR). Batch JSON (G-O2).
84 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "hosts"])]
85 tags: Option<String>,
86 /// Run against the host recorded by `connect`, deliberately (GAP-SSH-EXEC-ARGC-001).
87 ///
88 /// Inheriting the target from on-disk state is ambient authority, so it must be
89 /// asked for. Without this flag a lone positional is a usage error, never a
90 /// command aimed at whatever host `connect` last wrote.
91 #[arg(long = "use-active", action = ArgAction::SetTrue, conflicts_with_all = ["all", "hosts", "tags"])]
92 use_active: bool,
93 /// `VPS COMMAND`, or `COMMAND` alone with `--all`/`--hosts`/`--tags`/`--use-active`.
94 ///
95 /// Kept as a vector rather than two named positional slots on purpose: `clap`
96 /// fills positionals by order and cannot know which selector is active, so
97 /// `exec --all uptime` would bind `uptime` to a `VPS` slot and leave the
98 /// command empty — silently accepting a shape worse than the one being fixed.
99 /// The arity rule therefore lives in `parse_exec_target`, which can see the
100 /// flags. See GAP-SSH-EXEC-ARGC-001.
101 #[arg(required = true, num_args = 1..=2, value_names = ["VPS", "COMMAND"])]
102 target: Vec<String>,
103 /// Additional commands on the same SSH session after the primary (G-O3).
104 #[arg(long = "step", value_name = "CMD", action = ArgAction::Append)]
105 steps: Vec<String>,
106 /// JSON output (from global `--json` / format; G-AUD-01).
107 #[arg(from_global)]
108 json: bool,
109 /// SSH authentication overrides (password/key/passphrase).
110 #[command(flatten)]
111 auth: SshAuthArgs,
112 /// Timeout override in milliseconds.
113 #[arg(long, value_name = "MS")]
114 timeout: Option<u64>,
115 /// Shell comment appended for audit trails.
116 #[arg(long)]
117 description: Option<String>,
118 },
119
120 /// Runs a command with `sudo` (safe `sh -c` packing).
121 ///
122 /// Same target rules as `exec`: `VPS COMMAND`, or one positional with a selector
123 /// (`--all`/`--hosts`/`--tags`), or `--use-active COMMAND`. Elevation makes an
124 /// undesignated target worse, not better, so nothing here is inferred.
125 #[command(override_usage = SUDO_EXEC_USAGE_BLOCK)]
126 SudoExec {
127 /// Run on every registered host (bounded concurrency).
128 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["hosts", "tags"])]
129 all: bool,
130 /// Comma-separated host subset (bounded fan-out).
131 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "tags"])]
132 hosts: Option<String>,
133 /// Select hosts by tag (OR). Batch JSON (G-O2).
134 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "hosts"])]
135 tags: Option<String>,
136 /// Run against the host recorded by `connect`, deliberately (GAP-SSH-EXEC-ARGC-001).
137 ///
138 /// Elevation makes ambient authority worse, not better: a misdirected `sudo`
139 /// step writes root-owned state on a host the caller never named.
140 #[arg(long = "use-active", action = ArgAction::SetTrue, conflicts_with_all = ["all", "hosts", "tags"])]
141 use_active: bool,
142 /// `VPS COMMAND`, or `COMMAND` alone with a selector or `--use-active`.
143 ///
144 /// See the note on `Exec::target` for why this stays a vector.
145 #[arg(required = true, num_args = 1..=2, value_names = ["VPS", "COMMAND"])]
146 target: Vec<String>,
147 /// Extra commands on the same session (G-O3).
148 #[arg(long = "step", value_name = "CMD", action = ArgAction::Append)]
149 steps: Vec<String>,
150 /// JSON output (from global `--json` / format; G-AUD-01).
151 #[arg(from_global)]
152 json: bool,
153 /// SSH authentication overrides (password/key/passphrase).
154 #[command(flatten)]
155 auth: SshAuthArgs,
156 /// Sudo password override.
157 #[arg(
158 long,
159 alias = "sudoPassword",
160 alias = "sudo_password",
161 conflicts_with = "sudo_password_stdin"
162 )]
163 sudo_password: Option<String>,
164 /// Reads the sudo password from stdin.
165 #[arg(long, action = ArgAction::SetTrue)]
166 sudo_password_stdin: bool,
167 /// Timeout override in milliseconds.
168 #[arg(long, value_name = "MS")]
169 timeout: Option<u64>,
170 /// Shell comment appended for audit.
171 #[arg(long)]
172 description: Option<String>,
173 },
174
175 /// Runs a command with one-shot `su -` elevation.
176 ///
177 /// Same target rules as `exec`: `VPS COMMAND`, or one positional with a selector
178 /// (`--all`/`--hosts`/`--tags`), or `--use-active COMMAND`.
179 #[command(override_usage = SU_EXEC_USAGE_BLOCK)]
180 SuExec {
181 /// Run on every registered host (bounded concurrency).
182 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["hosts", "tags"])]
183 all: bool,
184 /// Comma-separated host subset (bounded fan-out).
185 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "tags"])]
186 hosts: Option<String>,
187 /// Select hosts by tag (OR). Batch JSON (G-O2).
188 #[arg(long, value_name = "LIST", conflicts_with_all = ["all", "hosts"])]
189 tags: Option<String>,
190 /// Run against the host recorded by `connect`, deliberately (GAP-SSH-EXEC-ARGC-001).
191 ///
192 /// Elevation makes ambient authority worse, not better: a misdirected `su -`
193 /// step writes root-owned state on a host the caller never named.
194 #[arg(long = "use-active", action = ArgAction::SetTrue, conflicts_with_all = ["all", "hosts", "tags"])]
195 use_active: bool,
196 /// `VPS COMMAND`, or `COMMAND` alone with a selector or `--use-active`.
197 ///
198 /// See the note on `Exec::target` for why this stays a vector.
199 #[arg(required = true, num_args = 1..=2, value_names = ["VPS", "COMMAND"])]
200 target: Vec<String>,
201 /// Extra commands on the same session (G-O3).
202 #[arg(long = "step", value_name = "CMD", action = ArgAction::Append)]
203 steps: Vec<String>,
204 /// JSON output (from global `--json` / format; G-AUD-01).
205 #[arg(from_global)]
206 json: bool,
207 /// SSH authentication overrides (password/key/passphrase).
208 #[command(flatten)]
209 auth: SshAuthArgs,
210 /// Su password override.
211 #[arg(
212 long,
213 alias = "suPassword",
214 alias = "su_password",
215 conflicts_with = "su_password_stdin"
216 )]
217 su_password: Option<String>,
218 /// Reads the su password from stdin.
219 #[arg(long, action = ArgAction::SetTrue)]
220 su_password_stdin: bool,
221 /// Timeout override.
222 #[arg(long, value_name = "MS")]
223 timeout: Option<u64>,
224 /// Shell comment appended for audit.
225 #[arg(long)]
226 description: Option<String>,
227 },
228
229 /// SCP file transfer (upload/download).
230 Scp {
231 /// Specific SCP action.
232 #[command(subcommand)]
233 action: ScpAction,
234 },
235
236 /// SFTP subsystem transfer and remote filesystem ops (G-SFTP).
237 Sftp {
238 /// Specific SFTP action.
239 #[command(subcommand)]
240 action: SftpAction,
241 },
242
243 /// SSH tunnel with mandatory deadline (bounded one-shot).
244 ///
245 /// Contract: **one** local bind + **one** SSH session per invocation (G-PAR-30).
246 /// Multi-host tunnels = N one-shots with distinct `--bind`/ports. Forward
247 /// accepts still use JoinSet + Semaphore (`--max-concurrency`).
248 Tunnel {
249 /// VPS name (single host only — no `--all` / `--hosts`).
250 vps_name: String,
251 /// Local port to bind — with `--reverse`, the local port that receives.
252 local_port: u16,
253 /// Remote host — with `--reverse`, the address the **server** binds.
254 ///
255 /// Optional since 0.5.4: `--socks5` chooses a destination per connection
256 /// and `--remote-socket` names a Unix socket, so neither has one.
257 remote_host: Option<String>,
258 /// Remote port — with `--reverse`, the server port (`0` = server allocates).
259 ///
260 /// Reverse accepts `0` because the server then reports the port it bound;
261 /// a local forward cannot, since there is nothing to connect to.
262 #[arg(value_parser = clap::value_parser!(u16).range(0..=65535))]
263 remote_port: Option<u16>,
264 /// Serve a SOCKS5 proxy locally instead of a fixed forward (G-TUN-R02).
265 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["reverse", "remote_socket"])]
266 socks5: bool,
267 /// Forward to a Unix domain socket on the remote host (G-TUN-R03).
268 #[arg(long, value_name = "PATH", conflicts_with_all = ["reverse", "socks5"])]
269 remote_socket: Option<String>,
270 /// Ask the server to listen and deliver connections back here (G-TUN-R01).
271 #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["socks5", "remote_socket"])]
272 reverse: bool,
273 /// Mandatory tunnel timeout in milliseconds.
274 #[arg(long, value_name = "MS")]
275 timeout_ms: u64,
276 /// SSH authentication overrides (password/key/passphrase).
277 #[command(flatten)]
278 auth: SshAuthArgs,
279 /// Agent-first JSON output when the local listener is up (GAP-SSH-IO-008).
280 #[arg(from_global)]
281 json: bool,
282 /// Local bind address (default loopback for security).
283 ///
284 /// G-TUN-R08: validated by clap as an IP address, so a typo like
285 /// `127.0.0..1` fails at parse time (exit 2) instead of after resolving the
286 /// host, opening the SSH session and authenticating — which on a host with
287 /// MFA or slow auth meant paying a full handshake to learn about a typo.
288 #[arg(
289 long,
290 default_value = crate::constants::DEFAULT_TUNNEL_BIND_ADDR,
291 value_name = "ADDR",
292 value_parser = clap::value_parser!(std::net::IpAddr)
293 )]
294 bind: std::net::IpAddr,
295 /// Acknowledge that a non-loopback bind exposes the forwarded service.
296 ///
297 /// G-TUN-R13: required for any routable bind. Without it, `--bind 0.0.0.0`
298 /// silently published the remote service to the local network. Under
299 /// `--reverse` it guards the **server's** bind address instead, which is
300 /// the end that is exposed in that direction.
301 #[arg(long, action = ArgAction::SetTrue)]
302 i_accept_network_exposure: bool,
303 },
304
305 /// Checks SSH connectivity to a VPS (`--all` / `--hosts` / `--use-active`).
306 ///
307 /// The target must be designated: a name, a selector, or the explicit opt-in.
308 /// Omitting all three is a usage error rather than a probe against whatever
309 /// `connect` last wrote (GAP-SSH-EXEC-ARGC-001).
310 #[command(override_usage = HEALTH_CHECK_USAGE)]
311 HealthCheck {
312 /// VPS name. Required unless a selector or `--use-active` is present.
313 #[arg(conflicts_with_all = ["all", "hosts", "use_active"])]
314 vps_name: Option<String>,
315 /// Probe every registered host in parallel (bounded concurrency).
316 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "hosts")]
317 all: bool,
318 /// Comma-separated host subset (bounded fan-out). Batch JSON even for one name.
319 #[arg(long, value_name = "LIST", conflicts_with = "all")]
320 hosts: Option<String>,
321 /// Probe the host recorded by `connect`, deliberately.
322 ///
323 /// This surface used to inherit the marker with no opt-in, defended as safe
324 /// because a probe is an idempotent read. The probe is safe; the habit is
325 /// not. An operator who learns that a target is optional here carries the
326 /// expectation to `exec`, where the same shortcut sent a day of work to the
327 /// wrong machine. The cheap half of the asymmetry is the one to make strict.
328 #[arg(long = "use-active", action = ArgAction::SetTrue, conflicts_with_all = ["all", "hosts"])]
329 use_active: bool,
330 /// JSON output (GAP-SSH-IO-002). Single host: classic object; multi: batch.
331 #[arg(from_global)]
332 json: bool,
333 /// SSH authentication overrides (password/key/passphrase).
334 #[command(flatten)]
335 auth: SshAuthArgs,
336 /// SSH timeout override in milliseconds (GAP-SSH-CLI-004).
337 #[arg(long, value_name = "MS")]
338 timeout: Option<u64>,
339 },
340
341 /// Manages the primary key and at-rest secret encryption (one-shot).
342 Secrets {
343 /// Secrets action.
344 #[command(subcommand)]
345 action: SecretsAction,
346 },
347
348 /// Generates shell completions.
349 Completions {
350 /// Target shell.
351 #[arg(value_enum)]
352 shell: Shell,
353 },
354
355 /// Emits the full command tree as JSON (agent discovery / rules `mycli commands`).
356 Commands {
357 /// JSON output (from global `--json`).
358 #[arg(from_global)]
359 json: bool,
360 },
361
362 /// Emits embedded JSON Schema catalog or one schema body (G-E2E-02).
363 Schema {
364 /// Schema name (omit to list catalog). Example: `vps-list`.
365 name: Option<String>,
366 /// JSON catalog envelope when listing (from global `--json`).
367 #[arg(from_global)]
368 json: bool,
369 },
370
371 /// Root alias for `vps doctor` (XDG / schema diagnostics; G-E2E-03).
372 Doctor {
373 /// JSON output (from global `--json`).
374 #[arg(from_global)]
375 json: bool,
376 /// Also probe SSH health on registered hosts.
377 #[arg(long, action = ArgAction::SetTrue)]
378 probe_ssh: bool,
379 /// Comma-separated host subset for `--probe-ssh`.
380 #[arg(long, value_name = "LIST")]
381 hosts: Option<String>,
382 },
383
384 /// Diagnoses and manages UI language (locale resolution / XDG preference).
385 Locale {
386 /// JSON diagnostics (from global `--json` / format).
387 #[arg(from_global)]
388 json: bool,
389 /// Optional locale action (default: show status).
390 #[command(subcommand)]
391 action: Option<LocaleAction>,
392 },
393 /// TLS stack: provider status, mTLS identities, ACME certs (XDG; rustls only).
394 Tls {
395 /// JSON output (from global `--json`).
396 #[arg(from_global)]
397 json: bool,
398 /// TLS action.
399 #[command(subcommand)]
400 action: TlsAction,
401 },
402}
403
404/// Actions of the `tls` subcommand (SSH-over-TLS / mTLS / ACME).
405#[derive(Debug, Subcommand)]
406pub enum TlsAction {
407 /// Shows rustls CryptoProvider status (`aws_lc_rs`).
408 Provider,
409 /// Prints XDG TLS directory layout paths.
410 Paths,
411 /// Manages imported mTLS client identities under XDG `tls/mtls/`.
412 Mtls {
413 /// mTLS action.
414 #[command(subcommand)]
415 action: TlsMtlsAction,
416 },
417 /// ACME (Let's Encrypt) account + DNS-01 certificate lifecycle.
418 Acme {
419 /// ACME action.
420 #[command(subcommand)]
421 action: TlsAcmeAction,
422 },
423}
424
425/// mTLS identity store actions.
426#[derive(Debug, Subcommand)]
427pub enum TlsMtlsAction {
428 /// Lists imported identity names.
429 List,
430 /// Imports PEM cert+key as a named identity.
431 Import {
432 /// Identity name (XDG leaf).
433 #[arg(long)]
434 name: String,
435 /// Certificate chain PEM path.
436 #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
437 cert: PathBuf,
438 /// Private key PEM path.
439 #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
440 key: PathBuf,
441 },
442 /// Shows paths for one identity.
443 Show {
444 /// Identity name.
445 name: String,
446 },
447 /// Removes an identity directory.
448 Remove {
449 /// Identity name.
450 name: String,
451 },
452}
453
454/// ACME actions (DNS-01, agent two-step).
455#[derive(Debug, Subcommand)]
456pub enum TlsAcmeAction {
457 /// ACME account management.
458 Account {
459 /// Account action.
460 #[command(subcommand)]
461 action: TlsAcmeAccountAction,
462 },
463 /// Starts DNS-01 order and prints the TXT challenge (persists order URL under XDG).
464 Issue {
465 /// Domain name (DNS identifier).
466 #[arg(long)]
467 domain: String,
468 /// Use Let's Encrypt staging directory.
469 #[arg(long, action = ArgAction::SetTrue)]
470 staging: bool,
471 /// Required: print challenge and exit (agent-friendly; no interactive wait).
472 #[arg(long, action = ArgAction::SetTrue)]
473 print_challenge: bool,
474 },
475 /// Completes a pending order after DNS TXT is published.
476 Complete {
477 /// Domain name.
478 #[arg(long)]
479 domain: String,
480 },
481 /// Shows certificate / pending status for one domain or all.
482 Status {
483 /// Optional domain filter.
484 #[arg(long)]
485 domain: Option<String>,
486 },
487 /// Lists ACME domain directories under XDG.
488 List,
489}
490
491/// ACME account sub-actions.
492#[derive(Debug, Subcommand)]
493pub enum TlsAcmeAccountAction {
494 /// Creates an ACME account (credentials under XDG `tls/acme/account.json`, 0o600).
495 Create {
496 /// Use Let's Encrypt staging.
497 #[arg(long, action = ArgAction::SetTrue)]
498 staging: bool,
499 /// Contact URLs (e.g. `mailto:ops@example.com`). Required; repeatable (G-AUD-06).
500 #[arg(long = "contact", value_name = "URL", action = ArgAction::Append, required = true, num_args = 1..)]
501 contact: Vec<String>,
502 /// Replace existing account credentials.
503 #[arg(long, action = ArgAction::SetTrue)]
504 force: bool,
505 },
506 /// Shows whether an account exists and its path.
507 Show,
508}
509
510/// Actions of the `locale` subcommand.
511#[derive(Debug, Subcommand)]
512pub enum LocaleAction {
513 /// Shows resolved language, winning layer, and available locales (default).
514 Show,
515 /// Persists preferred language under the config directory (`lang` file, 0o600).
516 Set {
517 /// BCP47 tag that negotiates to a supported locale (`en`, `pt-BR`, …).
518 #[arg(value_name = "LOCALE", value_parser = crate::locale::parse_lang_cli_arg)]
519 lang: String,
520 },
521 /// Removes the persisted language preference.
522 Clear,
523}
524
525/// Actions of the `secrets` subcommand (primary-key / AEAD).
526#[derive(Debug, Subcommand)]
527pub enum SecretsAction {
528 /// Shows encryption status (no sensitive material).
529 Status {
530 /// JSON output (from global `--json`).
531 #[arg(from_global)]
532 json: bool,
533 },
534 /// Generates and stores the primary key (`secrets.key` or keyring). Never prints the key.
535 Init {
536 /// Store in the OS keyring instead of `secrets.key`.
537 #[arg(long)]
538 keyring: bool,
539 /// Overwrites an existing key.
540 #[arg(long)]
541 force: bool,
542 /// JSON success envelope (`event: secrets-init`; from global `--json`).
543 #[arg(from_global)]
544 json: bool,
545 },
546 /// Rewrites `config.toml` re-encrypting secrets with the current key.
547 Reencrypt {
548 /// JSON success envelope (`event: secrets-reencrypt`; from global `--json`).
549 #[arg(from_global)]
550 json: bool,
551 },
552}