1use anyhow::Result;
12use clap::{Parser, Subcommand};
13use clap_complete::Shell;
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
18pub enum FormatoSaida {
19 #[default]
21 Text,
22 Json,
24}
25
26#[derive(Debug, Parser)]
28#[command(
29 name = "ssh-cli",
30 version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
31 about = "CLI Rust one-shot multi-host XDG para LLMs operarem servidores via SSH.",
32 long_about = "ssh-cli: binário leve one-shot (nascer→executar→morrer). Multi-host em storage XDG sem .env. \
33Auth por senha ou chave. Sem telemetria."
34)]
35pub struct Argumentos {
36 #[arg(long, global = true, value_name = "LOCALE")]
38 pub lang: Option<String>,
39
40 #[arg(short, long, global = true)]
42 pub verbose: bool,
43
44 #[arg(short, long, global = true)]
46 pub quiet: bool,
47
48 #[arg(long, global = true, value_name = "DIR")]
50 pub config_dir: Option<PathBuf>,
51
52 #[arg(long, global = true)]
54 pub no_color: bool,
55
56 #[arg(long, global = true, value_enum)]
58 pub output_format: Option<FormatoSaida>,
59
60 #[arg(long, global = true, alias = "disableSudo")]
62 pub disable_sudo: bool,
63
64 #[arg(long, global = true)]
66 pub replace_host_key: bool,
67
68 #[command(subcommand)]
70 pub comando: Comando,
71}
72
73#[derive(Debug, Subcommand)]
75pub enum Comando {
76 Vps {
78 #[command(subcommand)]
80 acao: AcaoVps,
81 },
82
83 Connect {
85 nome: String,
87 },
88
89 Exec {
91 vps_nome: String,
93 comando: String,
95 #[arg(long)]
97 json: bool,
98 #[arg(long)]
100 password: Option<String>,
101 #[arg(long)]
103 password_stdin: bool,
104 #[arg(long)]
106 key: Option<String>,
107 #[arg(long)]
109 key_passphrase: Option<String>,
110 #[arg(long)]
112 key_passphrase_stdin: bool,
113 #[arg(long)]
115 timeout: Option<u64>,
116 #[arg(long)]
118 description: Option<String>,
119 },
120
121 SudoExec {
123 vps_nome: String,
125 comando: String,
127 #[arg(long)]
129 json: bool,
130 #[arg(long)]
132 password: Option<String>,
133 #[arg(long)]
135 password_stdin: bool,
136 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
138 sudo_password: Option<String>,
139 #[arg(long)]
141 sudo_password_stdin: bool,
142 #[arg(long)]
144 key: Option<String>,
145 #[arg(long)]
147 key_passphrase: Option<String>,
148 #[arg(long)]
150 key_passphrase_stdin: bool,
151 #[arg(long)]
153 timeout: Option<u64>,
154 #[arg(long)]
156 description: Option<String>,
157 },
158
159 SuExec {
161 vps_nome: String,
163 comando: String,
165 #[arg(long)]
167 json: bool,
168 #[arg(long)]
170 password: Option<String>,
171 #[arg(long, alias = "suPassword", alias = "su_password")]
173 su_password: Option<String>,
174 #[arg(long)]
176 su_password_stdin: bool,
177 #[arg(long)]
179 key: Option<String>,
180 #[arg(long)]
182 key_passphrase: Option<String>,
183 #[arg(long)]
185 key_passphrase_stdin: bool,
186 #[arg(long)]
188 timeout: Option<u64>,
189 #[arg(long)]
191 description: Option<String>,
192 },
193
194 Scp {
196 #[command(subcommand)]
198 acao: AcaoScp,
199 },
200
201 Tunnel {
203 vps_nome: String,
205 porta_local: u16,
207 host_remoto: String,
209 porta_remota: u16,
211 #[arg(long)]
213 timeout_ms: u64,
214 #[arg(long)]
216 password: Option<String>,
217 #[arg(long)]
219 key: Option<String>,
220 },
221
222 HealthCheck {
224 vps_nome: Option<String>,
226 #[arg(long)]
228 password: Option<String>,
229 },
230
231 Secrets {
233 #[command(subcommand)]
235 acao: AcaoSecrets,
236 },
237
238 Completions {
240 #[arg(value_enum)]
242 shell: Shell,
243 },
244}
245
246#[derive(Debug, Subcommand)]
248pub enum AcaoVps {
249 Add {
251 #[arg(long)]
253 name: String,
254 #[arg(long)]
256 host: String,
257 #[arg(long, default_value_t = 22)]
259 port: u16,
260 #[arg(long)]
262 user: String,
263 #[arg(long)]
265 password: Option<String>,
266 #[arg(long)]
268 password_stdin: bool,
269 #[arg(long)]
271 key: Option<String>,
272 #[arg(long)]
274 key_passphrase: Option<String>,
275 #[arg(long, default_value_t = 60_000)]
277 timeout: u64,
278 #[arg(long)]
280 max_command_chars: Option<String>,
281 #[arg(long)]
283 max_output_chars: Option<String>,
284 #[arg(long, alias = "maxChars")]
286 max_chars: Option<String>,
287 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
289 sudo_password: Option<String>,
290 #[arg(long)]
292 sudo_password_stdin: bool,
293 #[arg(long, alias = "suPassword", alias = "su_password")]
295 su_password: Option<String>,
296 #[arg(long)]
298 su_password_stdin: bool,
299 #[arg(long, default_value_t = false)]
301 disable_sudo: bool,
302 #[arg(long)]
304 check: bool,
305 },
306
307 List {
309 #[arg(long)]
311 json: bool,
312 },
313
314 Remove {
316 nome: String,
318 },
319
320 Edit {
322 nome: String,
324 #[arg(long)]
326 host: Option<String>,
327 #[arg(long)]
329 port: Option<u16>,
330 #[arg(long)]
332 user: Option<String>,
333 #[arg(long)]
335 password: Option<String>,
336 #[arg(long)]
338 password_stdin: bool,
339 #[arg(long)]
341 key: Option<String>,
342 #[arg(long)]
344 key_passphrase: Option<String>,
345 #[arg(long)]
347 timeout: Option<u64>,
348 #[arg(long)]
350 max_command_chars: Option<String>,
351 #[arg(long)]
353 max_output_chars: Option<String>,
354 #[arg(long, alias = "maxChars")]
356 max_chars: Option<String>,
357 #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
359 sudo_password: Option<String>,
360 #[arg(long)]
362 sudo_password_stdin: bool,
363 #[arg(long, alias = "suPassword", alias = "su_password")]
365 su_password: Option<String>,
366 #[arg(long)]
368 su_password_stdin: bool,
369 #[arg(long)]
371 disable_sudo: Option<bool>,
372 },
373
374 Show {
376 nome: String,
378 #[arg(long)]
380 json: bool,
381 },
382
383 Path,
385
386 Doctor {
388 #[arg(long)]
390 json: bool,
391 },
392
393 Export {
395 #[arg(long)]
397 include_secrets: bool,
398 #[arg(long, short)]
400 output: Option<String>,
401 },
402
403 Import {
405 #[arg(long)]
407 file: PathBuf,
408 },
409}
410
411#[derive(Debug, Subcommand)]
413pub enum AcaoScp {
414 Upload {
416 vps_nome: String,
418 local: PathBuf,
420 remote: PathBuf,
422 #[arg(long)]
424 password: Option<String>,
425 },
426
427 Download {
429 vps_nome: String,
431 remote: PathBuf,
433 local: PathBuf,
435 #[arg(long)]
437 password: Option<String>,
438 },
439}
440
441#[derive(Debug, Subcommand)]
443pub enum AcaoSecrets {
444 Status {
446 #[arg(long)]
448 json: bool,
449 },
450 Init {
452 #[arg(long)]
454 keyring: bool,
455 #[arg(long)]
457 force: bool,
458 },
459 Reencrypt,
461}
462
463#[must_use]
465pub fn parse_args() -> Argumentos {
466 Argumentos::parse()
467}
468
469pub fn inicializar_logs(args: &Argumentos) {
471 use tracing_subscriber::{fmt, EnvFilter};
472
473 let filter = if std::env::var("RUST_LOG").is_ok() {
474 EnvFilter::from_default_env()
475 } else if args.verbose {
476 EnvFilter::new("debug")
477 } else if args.quiet {
478 EnvFilter::new("error")
479 } else {
480 EnvFilter::new("info")
481 };
482
483 let _ = fmt()
484 .with_env_filter(filter)
485 .with_writer(std::io::stderr)
486 .with_target(false)
487 .with_ansi(false)
488 .try_init();
489}
490
491pub fn gerar_completions(shell: Shell) {
493 use clap::CommandFactory;
494 let mut cmd = Argumentos::command();
495 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut std::io::stdout());
496}
497
498fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
499 if flag {
500 Ok(Some(crate::vps::ler_segredo_stdin()?))
501 } else {
502 Ok(valor)
503 }
504}
505
506#[must_use]
508pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
509 explicit.unwrap_or_else(|| {
510 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
511 FormatoSaida::Json
512 } else {
513 FormatoSaida::Text
514 }
515 })
516}
517
518pub async fn executar(args: Argumentos) -> Result<()> {
520 let config_override = args.config_dir.clone();
521 crate::secrets::definir_diretorio_config(config_override.clone());
523 let formato = resolver_formato(args.output_format);
524 let disable_sudo = args.disable_sudo;
525 let replace_host_key = args.replace_host_key;
526
527 match args.comando {
528 Comando::Vps { acao } => {
529 crate::vps::executar_comando_vps(acao, config_override, formato).await
530 }
531 Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
532 Comando::Exec {
533 vps_nome,
534 comando,
535 json,
536 password,
537 password_stdin,
538 key,
539 key_passphrase,
540 key_passphrase_stdin,
541 timeout,
542 description,
543 } => {
544 let password = ler_stdin_se(password_stdin, password)?;
545 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
546 let opts = crate::vps::OpcoesExec {
547 password,
548 key,
549 key_passphrase,
550 timeout,
551 description,
552 replace_host_key,
553 disable_sudo,
554 ..Default::default()
555 };
556 crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
557 .await
558 }
559 Comando::SudoExec {
560 vps_nome,
561 comando,
562 json,
563 password,
564 password_stdin,
565 sudo_password,
566 sudo_password_stdin,
567 key,
568 key_passphrase,
569 key_passphrase_stdin,
570 timeout,
571 description,
572 } => {
573 let password = ler_stdin_se(password_stdin, password)?;
574 let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
575 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
576 let opts = crate::vps::OpcoesExec {
577 password,
578 sudo_password,
579 key,
580 key_passphrase,
581 timeout,
582 description,
583 replace_host_key,
584 disable_sudo,
585 ..Default::default()
586 };
587 crate::vps::executar_sudo_exec(
588 &vps_nome,
589 &comando,
590 config_override,
591 formato,
592 json,
593 opts,
594 )
595 .await
596 }
597 Comando::SuExec {
598 vps_nome,
599 comando,
600 json,
601 password,
602 su_password,
603 su_password_stdin,
604 key,
605 key_passphrase,
606 key_passphrase_stdin,
607 timeout,
608 description,
609 } => {
610 let su_password = ler_stdin_se(su_password_stdin, su_password)?;
611 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
612 let opts = crate::vps::OpcoesExec {
613 password,
614 su_password,
615 key,
616 key_passphrase,
617 timeout,
618 description,
619 replace_host_key,
620 disable_sudo,
621 ..Default::default()
622 };
623 crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
624 .await
625 }
626 Comando::Scp { acao } => {
627 let pwd = match &acao {
628 AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
629 password.clone()
630 }
631 };
632 crate::scp::executar_scp(acao, config_override, pwd).await
633 }
634 Comando::Tunnel {
635 vps_nome,
636 porta_local,
637 host_remoto,
638 porta_remota,
639 timeout_ms,
640 password,
641 key,
642 } => {
643 crate::tunnel::executar_tunnel(
644 &vps_nome,
645 porta_local,
646 &host_remoto,
647 porta_remota,
648 config_override,
649 password,
650 key,
651 timeout_ms,
652 replace_host_key,
653 )
654 .await
655 }
656 Comando::HealthCheck { vps_nome, password } => {
657 crate::vps::executar_health_check(
658 vps_nome.as_deref(),
659 config_override,
660 formato,
661 password,
662 )
663 .await
664 }
665 Comando::Secrets { acao } => {
666 crate::vps::executar_comando_secrets(acao, config_override, formato).await
667 }
668 Comando::Completions { shell } => {
669 gerar_completions(shell);
670 Ok(())
671 }
672 }
673}
674
675#[cfg(test)]
676mod testes {
677 use super::*;
678 use clap::Parser;
679
680 #[test]
681 fn parser_entende_tunnel_com_timeout() {
682 let args = Argumentos::try_parse_from([
683 "ssh-cli",
684 "tunnel",
685 "vps-a",
686 "8080",
687 "127.0.0.1",
688 "5432",
689 "--timeout-ms",
690 "5000",
691 ])
692 .expect("tunnel");
693 match args.comando {
694 Comando::Tunnel {
695 timeout_ms,
696 porta_local,
697 ..
698 } => {
699 assert_eq!(timeout_ms, 5000);
700 assert_eq!(porta_local, 8080);
701 }
702 _ => panic!("esperado tunnel"),
703 }
704 }
705
706 #[test]
707 fn parser_vps_add_key() {
708 let args = Argumentos::try_parse_from([
709 "ssh-cli",
710 "vps",
711 "add",
712 "--name",
713 "x",
714 "--host",
715 "h",
716 "--user",
717 "u",
718 "--key",
719 "/tmp/id_ed25519",
720 ])
721 .expect("add key");
722 match args.comando {
723 Comando::Vps {
724 acao: AcaoVps::Add { key, password, .. },
725 } => {
726 assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
727 assert!(password.is_none());
728 }
729 _ => panic!("esperado add"),
730 }
731 }
732
733 #[test]
734 fn parser_sudo_exec_description() {
735 let args = Argumentos::try_parse_from([
736 "ssh-cli",
737 "sudo-exec",
738 "v",
739 "id",
740 "--description",
741 "who am i",
742 ])
743 .unwrap();
744 match args.comando {
745 Comando::SudoExec { description, .. } => {
746 assert_eq!(description.as_deref(), Some("who am i"));
747 }
748 _ => panic!("sudo-exec"),
749 }
750 }
751
752 #[test]
753 fn parser_su_exec() {
754 let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
755 assert!(matches!(args.comando, Comando::SuExec { .. }));
756 }
757
758 #[test]
759 fn parser_disable_sudo_global() {
760 let args =
761 Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
762 assert!(args.disable_sudo);
763 }
764
765 #[test]
766 fn parser_doctor() {
767 let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
768 match args.comando {
769 Comando::Vps {
770 acao: AcaoVps::Doctor { json },
771 } => assert!(json),
772 _ => panic!("doctor"),
773 }
774 }
775}