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/// 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}