Skip to main content

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}