Skip to main content

ssh_cli/cli/
dispatch.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Subcommand dispatch (G-COMP-06b) — kept separate from clap type definitions.
3//!
4//! Sequential local handlers (CRUD, locale, completions) are justified: work ≪ SSH RTT.
5//! Multi-host I/O uses domain modules with [`crate::concurrency::map_bounded`].
6#![forbid(unsafe_code)]
7
8use super::{
9    command_tree_json, effective_timeout_ms, generate_completions, parse_exec_target,
10    parse_hosts_list, parse_remote_steps, read_stdin_if, warn_if_password_argv, CliArgs, Command,
11    LocaleAction, OutputFormat, ScpAction, SftpAction,
12};
13use anyhow::Result;
14
15/// Runs the requested subcommand.
16///
17/// Prefer [`crate::commands::run`] from new call sites; this remains the
18/// shared implementation used by both layers.
19pub async fn dispatch(args: CliArgs) -> Result<()> {
20    dispatch_impl(args).await
21}
22
23/// Shared dispatch implementation (cli + commands layers).
24pub async fn dispatch_impl(args: CliArgs) -> Result<()> {
25    let config_override = args.config_dir.clone();
26    // Aligns `secrets.key` with `--config-dir` / isolated tests.
27    crate::secrets::set_config_dir(config_override.clone());
28    crate::secrets::set_runtime_flags(
29        args.allow_plaintext_secrets,
30        args.secrets_key_file.clone(),
31        args.use_keyring,
32    );
33    // Bounded multi-host fan-out budget (Rules Rust — paralelismo).
34    let limit = crate::concurrency::resolve_limit(args.max_concurrency.map(usize::from));
35    crate::concurrency::install_process_limit(limit);
36    crate::concurrency::install_fail_fast(args.fail_fast);
37    if let Some(n) = args.scp_file_concurrency {
38        crate::concurrency::install_scp_file_concurrency(usize::from(n));
39    }
40    // G-AUD-01: global `--json` forces JSON; conflicts with `--output-format text`.
41    let formato = super::resolve_format_from_cli(args.json, args.output_format)?;
42    // GAP-SSH-IO-003 / IO-004: centralized I/O policy.
43    crate::output::set_quiet(args.quiet);
44    crate::output::set_json_errors(formato == OutputFormat::Json);
45    let disable_sudo = args.disable_sudo;
46    let replace_host_key = args.replace_host_key;
47    // G-OS-02: global `--timeout` fills missing local timeouts (local wins).
48    let global_timeout = args.timeout;
49
50    // GAP-AUD-010 / G-AUD-08: warn only when secrets appear on argv (visible in `ps`).
51    warn_if_password_argv(&args);
52
53    match args.command {
54        Command::Vps { action } => {
55            // Sequential: local TOML CRUD (work ≪ SSH RTT; no multi-host I/O)
56            // except `vps doctor --probe-ssh` which reuses health-check fan-out.
57            crate::vps::run_vps_command(action, config_override, formato).await
58        }
59        Command::Connect { name } => {
60            // Sequential: writes active marker only (no SSH fan-out).
61            crate::vps::run_connect(&name, config_override, formato).await
62        }
63        Command::Exec {
64            all,
65            hosts,
66            tags,
67            target,
68            steps,
69            json,
70            auth,
71            timeout,
72            description,
73        } => {
74            let (selection, command) = parse_exec_target(
75                all,
76                hosts,
77                tags,
78                target,
79                crate::vps::read_active_vps(config_override.as_deref())?,
80            )
81            .map_err(|s| {
82                if s.starts_with("no active VPS") {
83                    crate::errors::SshCliError::NoActiveVps
84                } else {
85                    crate::errors::SshCliError::InvalidArgument(s)
86                }
87            })?;
88            let key = auth.key_path_string();
89            let password = read_stdin_if(auth.password_stdin, auth.password)?;
90            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
91            let steps =
92                parse_remote_steps(steps).map_err(crate::errors::SshCliError::InvalidArgument)?;
93            let opts = crate::vps::ExecOptions {
94                password,
95                key,
96                key_passphrase,
97                timeout: effective_timeout_ms(timeout, global_timeout)
98                    .map_err(crate::errors::SshCliError::InvalidArgument)?,
99                description,
100                replace_host_key,
101                disable_sudo,
102                steps,
103                use_agent: auth.use_agent,
104                agent_socket: auth
105                    .agent_socket
106                    .as_ref()
107                    .map(|p| p.to_string_lossy().into_owned()),
108                ..Default::default()
109            };
110            crate::vps::run_exec(selection, &command, config_override, formato, json, opts).await
111        }
112        Command::SudoExec {
113            all,
114            hosts,
115            tags,
116            target,
117            steps,
118            json,
119            auth,
120            sudo_password,
121            sudo_password_stdin,
122            timeout,
123            description,
124        } => {
125            let (selection, command) = parse_exec_target(
126                all,
127                hosts,
128                tags,
129                target,
130                crate::vps::read_active_vps(config_override.as_deref())?,
131            )
132            .map_err(|s| {
133                if s.starts_with("no active VPS") {
134                    crate::errors::SshCliError::NoActiveVps
135                } else {
136                    crate::errors::SshCliError::InvalidArgument(s)
137                }
138            })?;
139            let key = auth.key_path_string();
140            let password = read_stdin_if(auth.password_stdin, auth.password)?;
141            let sudo_password = read_stdin_if(sudo_password_stdin, sudo_password)?;
142            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
143            let steps =
144                parse_remote_steps(steps).map_err(crate::errors::SshCliError::InvalidArgument)?;
145            let opts = crate::vps::ExecOptions {
146                password,
147                sudo_password,
148                key,
149                key_passphrase,
150                timeout: effective_timeout_ms(timeout, global_timeout)
151                    .map_err(crate::errors::SshCliError::InvalidArgument)?,
152                description,
153                replace_host_key,
154                disable_sudo,
155                steps,
156                use_agent: auth.use_agent,
157                agent_socket: auth
158                    .agent_socket
159                    .as_ref()
160                    .map(|p| p.to_string_lossy().into_owned()),
161                ..Default::default()
162            };
163            crate::vps::run_sudo_exec(selection, &command, config_override, formato, json, opts)
164                .await
165        }
166        Command::SuExec {
167            all,
168            hosts,
169            tags,
170            target,
171            steps,
172            json,
173            auth,
174            su_password,
175            su_password_stdin,
176            timeout,
177            description,
178        } => {
179            let (selection, command) = parse_exec_target(
180                all,
181                hosts,
182                tags,
183                target,
184                crate::vps::read_active_vps(config_override.as_deref())?,
185            )
186            .map_err(|s| {
187                if s.starts_with("no active VPS") {
188                    crate::errors::SshCliError::NoActiveVps
189                } else {
190                    crate::errors::SshCliError::InvalidArgument(s)
191                }
192            })?;
193            let key = auth.key_path_string();
194            let password = read_stdin_if(auth.password_stdin, auth.password)?;
195            let su_password = read_stdin_if(su_password_stdin, su_password)?;
196            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
197            let steps =
198                parse_remote_steps(steps).map_err(crate::errors::SshCliError::InvalidArgument)?;
199            let opts = crate::vps::ExecOptions {
200                password,
201                su_password,
202                key,
203                key_passphrase,
204                timeout: effective_timeout_ms(timeout, global_timeout)
205                    .map_err(crate::errors::SshCliError::InvalidArgument)?,
206                description,
207                replace_host_key,
208                disable_sudo,
209                steps,
210                use_agent: auth.use_agent,
211                agent_socket: auth
212                    .agent_socket
213                    .as_ref()
214                    .map(|p| p.to_string_lossy().into_owned()),
215                ..Default::default()
216            };
217            crate::vps::run_su_exec(selection, &command, config_override, formato, json, opts).await
218        }
219        Command::Scp { action } => {
220            let (auth, timeout, json_local) = match &action {
221                ScpAction::Upload {
222                    auth,
223                    timeout,
224                    json,
225                    ..
226                }
227                | ScpAction::Download {
228                    auth,
229                    timeout,
230                    json,
231                    ..
232                } => (auth.clone(), *timeout, *json),
233            };
234            let key = auth.key_path_string();
235            let password = read_stdin_if(auth.password_stdin, auth.password)?;
236            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
237            // GAP-SSH-IO-007b: local --json or global --format json → JSON error envelope.
238            let json_efetivo = json_local || formato == OutputFormat::Json;
239            if json_efetivo {
240                crate::output::set_json_errors(true);
241            }
242            crate::scp::run_scp(
243                action,
244                config_override,
245                crate::scp::ScpOptions {
246                    password,
247                    key,
248                    key_passphrase,
249                    timeout: effective_timeout_ms(timeout, global_timeout)
250                        .map_err(crate::errors::SshCliError::InvalidArgument)?,
251                    replace_host_key,
252                    json: json_efetivo,
253                    use_agent: auth.use_agent,
254                    agent_socket: auth
255                        .agent_socket
256                        .as_ref()
257                        .map(|p| p.to_string_lossy().into_owned()),
258                },
259            )
260            .await
261        }
262        Command::Sftp { action } => {
263            let (auth, timeout, json_local) = match &action {
264                SftpAction::Upload {
265                    auth,
266                    timeout,
267                    json,
268                    ..
269                }
270                | SftpAction::Download {
271                    auth,
272                    timeout,
273                    json,
274                    ..
275                }
276                | SftpAction::Ls {
277                    auth,
278                    timeout,
279                    json,
280                    ..
281                }
282                | SftpAction::Mkdir {
283                    auth,
284                    timeout,
285                    json,
286                    ..
287                }
288                | SftpAction::Rmdir {
289                    auth,
290                    timeout,
291                    json,
292                    ..
293                }
294                | SftpAction::Rm {
295                    auth,
296                    timeout,
297                    json,
298                    ..
299                }
300                | SftpAction::Stat {
301                    auth,
302                    timeout,
303                    json,
304                    ..
305                }
306                | SftpAction::Rename {
307                    auth,
308                    timeout,
309                    json,
310                    ..
311                } => (auth.clone(), *timeout, *json),
312            };
313            let key = auth.key_path_string();
314            let password = read_stdin_if(auth.password_stdin, auth.password)?;
315            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
316            let json_efetivo = json_local || formato == OutputFormat::Json;
317            if json_efetivo {
318                crate::output::set_json_errors(true);
319            }
320            crate::sftp::run_sftp(
321                action,
322                config_override,
323                crate::sftp::SftpOptions {
324                    password,
325                    key,
326                    key_passphrase,
327                    timeout: effective_timeout_ms(timeout, global_timeout)
328                        .map_err(crate::errors::SshCliError::InvalidArgument)?,
329                    replace_host_key,
330                    json: json_efetivo,
331                    use_agent: auth.use_agent,
332                    agent_socket: auth
333                        .agent_socket
334                        .as_ref()
335                        .map(|p| p.to_string_lossy().into_owned()),
336                    recursive: false, // set from action in run_sftp for upload/download
337                },
338            )
339            .await
340        }
341        Command::Tunnel {
342            vps_name,
343            local_port,
344            remote_host,
345            remote_port,
346            timeout_ms,
347            auth,
348            json,
349            bind,
350        } => {
351            // GAP-SSH-IO-008: --json local or global format.
352            let json_efetivo = json || formato == OutputFormat::Json;
353            if json_efetivo {
354                crate::output::set_json_errors(true);
355            }
356            // GAP-SSH-CLI-005: auth parity with exec/scp (stdin + passphrase).
357            // Tunnel deadline remains explicit `--timeout-ms` (mandatory bound).
358            // Forwards: JoinSet + Semaphore (concurrency::effective_limit).
359            let key = auth.key_path_string();
360            let password = read_stdin_if(auth.password_stdin, auth.password)?;
361            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
362            crate::tunnel::run_tunnel(
363                &vps_name,
364                local_port,
365                &remote_host,
366                remote_port,
367                config_override,
368                password,
369                key,
370                key_passphrase,
371                timeout_ms,
372                replace_host_key,
373                json_efetivo,
374                &bind,
375            )
376            .await
377        }
378        Command::HealthCheck {
379            vps_name,
380            all,
381            hosts,
382            json,
383            auth,
384            timeout,
385        } => {
386            // GAP-SSH-CLI-006: auth parity with exec/scp (stdin + key + passphrase).
387            let key = auth.key_path_string();
388            let password = read_stdin_if(auth.password_stdin, auth.password)?;
389            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
390            let selection = if all {
391                crate::vps::HostSelection::All
392            } else if let Some(h) = hosts {
393                let names = parse_hosts_list(&h);
394                if names.is_empty() {
395                    return Err(crate::errors::SshCliError::InvalidArgument(
396                        "--hosts requires at least one host name".into(),
397                    )
398                    .into());
399                }
400                let names = names
401                    .into_iter()
402                    .map(crate::domain::VpsName::try_new)
403                    .collect::<Result<Vec<_>, _>>()
404                    .map_err(|e| crate::errors::SshCliError::InvalidArgument(e.to_string()))?;
405                crate::vps::HostSelection::Named(names)
406            } else {
407                let name = match vps_name {
408                    Some(n) => n,
409                    None => {
410                        // GAP-SSH-EXIT-002: typed → exit 66.
411                        let active = crate::vps::read_active_vps(config_override.as_deref())?;
412                        active.ok_or(crate::errors::SshCliError::NoActiveVps)?
413                    }
414                };
415                let name = crate::domain::VpsName::try_new(name)
416                    .map_err(|e| crate::errors::SshCliError::InvalidArgument(e.to_string()))?;
417                crate::vps::HostSelection::Single(name)
418            };
419            crate::vps::run_health_check(
420                selection,
421                config_override,
422                formato,
423                json,
424                password,
425                effective_timeout_ms(timeout, global_timeout)
426                    .map_err(crate::errors::SshCliError::InvalidArgument)?,
427                key,
428                key_passphrase,
429                replace_host_key,
430            )
431            .await
432        }
433        Command::Secrets { action } => {
434            // Sequential: local crypto / key file (work ≪ SSH RTT).
435            crate::vps::run_secrets_command(action, config_override, formato).await
436        }
437        Command::Completions { shell } => {
438            // Sequential: emit shell script metadata only.
439            generate_completions(shell)
440        }
441        Command::Commands { json: _ } => {
442            // Sequential: clap tree walk (agent discovery; no I/O fan-out).
443            // Always JSON: agent discovery surface (rules checklist `mycli commands`).
444            crate::output::print_json_value(&command_tree_json())?;
445            Ok(())
446        }
447        Command::Schema { name, json } => {
448            // Sequential: embedded catalog lookup (G-E2E-02).
449            let wants_json = json || formato == OutputFormat::Json;
450            crate::cli::run_schema(name.as_deref(), wants_json).map_err(Into::into)
451        }
452        Command::Doctor {
453            json,
454            probe_ssh,
455            hosts,
456        } => {
457            // G-E2E-03: root alias → same handler as `vps doctor`.
458            crate::vps::run_vps_command(
459                crate::cli::VpsAction::Doctor {
460                    json,
461                    probe_ssh,
462                    hosts,
463                },
464                config_override,
465                formato,
466            )
467            .await
468        }
469        Command::Tls { json, action } => {
470            #[cfg(feature = "tls")]
471            {
472                crate::tls::commands::run_tls_command(action, config_override, formato, json).await
473            }
474            #[cfg(not(feature = "tls"))]
475            {
476                let _ = (action, json);
477                Err(crate::errors::SshCliError::tls_msg(
478                    "TLS feature disabled; rebuild with --features tls (default)".into(),
479                )
480                .into())
481            }
482        }
483        Command::Locale { json, action } => {
484            // Sequential: locale preference file / diagnostics (local only).
485            run_locale_command(
486                action,
487                config_override.as_deref(),
488                formato,
489                args.lang.as_deref(),
490                json,
491            )
492        }
493    }
494}
495
496/// Implements `ssh-cli locale [show|set|clear]`.
497pub(crate) fn run_locale_command(
498    action: Option<LocaleAction>,
499    config_override: Option<&std::path::Path>,
500    formato: OutputFormat,
501    force_lang: Option<&str>,
502    json_flag: bool,
503) -> Result<()> {
504    use crate::i18n::{self, Message};
505    use crate::locale::{
506        clear_persisted_lang, current_language, lang_preference_path, negotiate_code,
507        resolve_language_detailed, write_persisted_lang,
508    };
509
510    let action = action.unwrap_or(LocaleAction::Show);
511    let override_ref = config_override;
512    let wants_json = json_flag || formato == OutputFormat::Json;
513
514    match action {
515        LocaleAction::Show => {
516            // Re-resolve for diagnostics (global already set at init; layers still useful).
517            let detailed = resolve_language_detailed(force_lang, override_ref);
518            let available: Vec<&str> = crate::i18n::Language::AVAILABLE
519                .iter()
520                .map(|l| l.bcp47())
521                .collect();
522            let pref_path = lang_preference_path(override_ref)
523                .map(|p| p.display().to_string())
524                .unwrap_or_else(|| String::from("(unavailable)"));
525
526            if wants_json {
527                let body = serde_json::json!({
528                    "resolved": detailed.language.bcp47(),
529                    "current": current_language().bcp47(),
530                    "source": detailed.source.as_str(),
531                    "available": available,
532                    "system_raw": detailed.system_raw,
533                    "persisted_raw": detailed.persisted_raw,
534                    "preference_path": pref_path,
535                    "direction": match detailed.language.direction() {
536                        crate::i18n::TextDirection::Ltr => "ltr",
537                        crate::i18n::TextDirection::Rtl => "rtl",
538                    },
539                    "script": detailed.language.script(),
540                });
541                crate::output::print_json_value(&body)?;
542            } else {
543                crate::output::write_line(&i18n::t(Message::LocaleStatusTitle))?;
544                crate::output::write_line_fmt(format_args!(
545                    "  resolved:   {} ({})",
546                    detailed.language.bcp47(),
547                    detailed.source.as_str()
548                ))?;
549                crate::output::write_line_fmt(format_args!(
550                    "  current:    {}",
551                    current_language().bcp47()
552                ))?;
553                crate::output::write_line_fmt(format_args!(
554                    "  available:  {}",
555                    available.join(", ")
556                ))?;
557                crate::output::write_line_fmt(format_args!(
558                    "  system:     {}",
559                    detailed.system_raw.as_deref().unwrap_or("(none)")
560                ))?;
561                crate::output::write_line_fmt(format_args!(
562                    "  persisted:  {}",
563                    detailed.persisted_raw.as_deref().unwrap_or("(none)")
564                ))?;
565                crate::output::write_line_fmt(format_args!("  pref_path:  {pref_path}"))?;
566            }
567            Ok(())
568        }
569        LocaleAction::Set { lang } => {
570            let language = negotiate_code(&lang)
571                .ok_or_else(|| anyhow::anyhow!("unsupported language after validation: {lang}"))?;
572            let path = write_persisted_lang(language, override_ref)?;
573            // Note: OnceLock already set for this process; preference applies next run
574            // unless --lang/env override.
575            if wants_json {
576                crate::output::print_json_value(&serde_json::json!({
577                    "ok": true,
578                    "lang": language.bcp47(),
579                    "path": path.display().to_string(),
580                    "applies": "next_invocation_unless_overridden",
581                }))?;
582            } else {
583                crate::output::print_success(&i18n::t(Message::LocalePreferenceSaved {
584                    lang: language.bcp47().to_string(),
585                    path: path.display().to_string(),
586                }));
587            }
588            Ok(())
589        }
590        LocaleAction::Clear => {
591            clear_persisted_lang(override_ref)?;
592            if wants_json {
593                crate::output::print_json_value(&serde_json::json!({
594                    "ok": true,
595                    "cleared": true,
596                }))?;
597            } else {
598                crate::output::print_success(&i18n::t(Message::LocalePreferenceCleared));
599            }
600            Ok(())
601        }
602    }
603}