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, conflicts_with = "password_stdin")]
100 password: Option<String>,
101 #[arg(long)]
103 password_stdin: bool,
104 #[arg(long)]
106 key: Option<String>,
107 #[arg(long, conflicts_with = "key_passphrase_stdin")]
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, conflicts_with = "password_stdin")]
132 password: Option<String>,
133 #[arg(long)]
135 password_stdin: bool,
136 #[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
138 sudo_password: Option<String>,
139 #[arg(long)]
141 sudo_password_stdin: bool,
142 #[arg(long)]
144 key: Option<String>,
145 #[arg(long, conflicts_with = "key_passphrase_stdin")]
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, conflicts_with = "password_stdin")]
170 password: Option<String>,
171 #[arg(long)]
173 password_stdin: bool,
174 #[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
176 su_password: Option<String>,
177 #[arg(long)]
179 su_password_stdin: bool,
180 #[arg(long)]
182 key: Option<String>,
183 #[arg(long, conflicts_with = "key_passphrase_stdin")]
185 key_passphrase: Option<String>,
186 #[arg(long)]
188 key_passphrase_stdin: bool,
189 #[arg(long)]
191 timeout: Option<u64>,
192 #[arg(long)]
194 description: Option<String>,
195 },
196
197 Scp {
199 #[command(subcommand)]
201 acao: AcaoScp,
202 },
203
204 Tunnel {
206 vps_nome: String,
208 porta_local: u16,
210 host_remoto: String,
212 porta_remota: u16,
214 #[arg(long)]
216 timeout_ms: u64,
217 #[arg(long)]
219 password: Option<String>,
220 #[arg(long)]
222 key: Option<String>,
223 },
224
225 HealthCheck {
227 vps_nome: Option<String>,
229 #[arg(long)]
231 json: bool,
232 #[arg(long)]
234 password: Option<String>,
235 #[arg(long)]
237 timeout: Option<u64>,
238 },
239
240 Secrets {
242 #[command(subcommand)]
244 acao: AcaoSecrets,
245 },
246
247 Completions {
249 #[arg(value_enum)]
251 shell: Shell,
252 },
253}
254
255#[derive(Debug, Subcommand)]
257pub enum AcaoVps {
258 Add {
260 #[arg(long)]
262 name: String,
263 #[arg(long)]
265 host: String,
266 #[arg(long, default_value_t = 22)]
268 port: u16,
269 #[arg(long)]
271 user: String,
272 #[arg(long, conflicts_with = "password_stdin")]
274 password: Option<String>,
275 #[arg(long)]
277 password_stdin: bool,
278 #[arg(long)]
280 key: Option<String>,
281 #[arg(long)]
283 key_passphrase: Option<String>,
284 #[arg(long, default_value_t = 60_000)]
286 timeout: u64,
287 #[arg(long)]
289 max_command_chars: Option<String>,
290 #[arg(long)]
292 max_output_chars: Option<String>,
293 #[arg(long, alias = "maxChars")]
295 max_chars: Option<String>,
296 #[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
298 sudo_password: Option<String>,
299 #[arg(long)]
301 sudo_password_stdin: bool,
302 #[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
304 su_password: Option<String>,
305 #[arg(long)]
307 su_password_stdin: bool,
308 #[arg(long, default_value_t = false)]
310 disable_sudo: bool,
311 #[arg(long)]
313 check: bool,
314 },
315
316 List {
318 #[arg(long)]
320 json: bool,
321 },
322
323 Remove {
325 nome: String,
327 },
328
329 Edit {
331 nome: String,
333 #[arg(long)]
335 host: Option<String>,
336 #[arg(long)]
338 port: Option<u16>,
339 #[arg(long)]
341 user: Option<String>,
342 #[arg(long, conflicts_with = "password_stdin")]
344 password: Option<String>,
345 #[arg(long)]
347 password_stdin: bool,
348 #[arg(long)]
350 key: Option<String>,
351 #[arg(long)]
353 key_passphrase: Option<String>,
354 #[arg(long)]
356 timeout: Option<u64>,
357 #[arg(long)]
359 max_command_chars: Option<String>,
360 #[arg(long)]
362 max_output_chars: Option<String>,
363 #[arg(long, alias = "maxChars")]
365 max_chars: Option<String>,
366 #[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
368 sudo_password: Option<String>,
369 #[arg(long)]
371 sudo_password_stdin: bool,
372 #[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
374 su_password: Option<String>,
375 #[arg(long)]
377 su_password_stdin: bool,
378 #[arg(long)]
380 disable_sudo: Option<bool>,
381 },
382
383 Show {
385 nome: String,
387 #[arg(long)]
389 json: bool,
390 },
391
392 Path,
394
395 Doctor {
397 #[arg(long)]
399 json: bool,
400 },
401
402 Export {
404 #[arg(long)]
406 include_secrets: bool,
407 #[arg(long, short)]
409 output: Option<String>,
410 },
411
412 Import {
414 #[arg(long)]
416 file: PathBuf,
417 #[arg(long)]
419 allow_incomplete: bool,
420 },
421}
422
423#[derive(Debug, Subcommand)]
425pub enum AcaoScp {
426 Upload {
428 vps_nome: String,
430 local: PathBuf,
432 remote: PathBuf,
434 #[arg(long)]
436 password: Option<String>,
437 },
438
439 Download {
441 vps_nome: String,
443 remote: PathBuf,
445 local: PathBuf,
447 #[arg(long)]
449 password: Option<String>,
450 },
451}
452
453#[derive(Debug, Subcommand)]
455pub enum AcaoSecrets {
456 Status {
458 #[arg(long)]
460 json: bool,
461 },
462 Init {
464 #[arg(long)]
466 keyring: bool,
467 #[arg(long)]
469 force: bool,
470 },
471 Reencrypt,
473}
474
475#[must_use]
477pub fn parse_args() -> Argumentos {
478 Argumentos::parse()
479}
480
481pub fn inicializar_logs(args: &Argumentos) {
486 use tracing_subscriber::{fmt, EnvFilter};
487
488 let filter = if std::env::var("RUST_LOG").is_ok() {
489 EnvFilter::from_default_env()
490 } else if args.verbose {
491 EnvFilter::new("debug")
492 } else {
493 let _ = args.quiet;
495 EnvFilter::new("error")
496 };
497
498 let _ = fmt()
499 .with_env_filter(filter)
500 .with_writer(std::io::stderr)
501 .with_target(false)
502 .with_ansi(false)
503 .try_init();
504}
505
506pub fn gerar_completions(shell: Shell) {
510 use clap::CommandFactory;
511 use std::io::Write;
512 let mut cmd = Argumentos::command();
513 let mut buf: Vec<u8> = Vec::new();
514 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
515 let mut out = std::io::stdout().lock();
516 if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
517 if e.kind() == std::io::ErrorKind::BrokenPipe {
518 return;
519 }
520 let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
522 }
523}
524
525fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
526 if flag {
527 Ok(Some(crate::vps::ler_segredo_stdin()?))
528 } else {
529 Ok(valor)
530 }
531}
532
533#[must_use]
535pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
536 if let Some(f) = explicit {
537 return f;
538 }
539 if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
541 return FormatoSaida::Text;
542 }
543 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
544 FormatoSaida::Json
545 } else {
546 FormatoSaida::Text
547 }
548}
549
550pub async fn executar(args: Argumentos) -> Result<()> {
552 let config_override = args.config_dir.clone();
553 crate::secrets::definir_diretorio_config(config_override.clone());
555 let formato = resolver_formato(args.output_format);
556 crate::output::definir_quiet(args.quiet);
558 crate::output::definir_json_erros(formato == FormatoSaida::Json);
559 let disable_sudo = args.disable_sudo;
560 let replace_host_key = args.replace_host_key;
561
562 match args.comando {
563 Comando::Vps { acao } => {
564 crate::vps::executar_comando_vps(acao, config_override, formato).await
565 }
566 Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
567 Comando::Exec {
568 vps_nome,
569 comando,
570 json,
571 password,
572 password_stdin,
573 key,
574 key_passphrase,
575 key_passphrase_stdin,
576 timeout,
577 description,
578 } => {
579 let password = ler_stdin_se(password_stdin, password)?;
580 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
581 let opts = crate::vps::OpcoesExec {
582 password,
583 key,
584 key_passphrase,
585 timeout,
586 description,
587 replace_host_key,
588 disable_sudo,
589 ..Default::default()
590 };
591 crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
592 .await
593 }
594 Comando::SudoExec {
595 vps_nome,
596 comando,
597 json,
598 password,
599 password_stdin,
600 sudo_password,
601 sudo_password_stdin,
602 key,
603 key_passphrase,
604 key_passphrase_stdin,
605 timeout,
606 description,
607 } => {
608 let password = ler_stdin_se(password_stdin, password)?;
609 let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
610 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
611 let opts = crate::vps::OpcoesExec {
612 password,
613 sudo_password,
614 key,
615 key_passphrase,
616 timeout,
617 description,
618 replace_host_key,
619 disable_sudo,
620 ..Default::default()
621 };
622 crate::vps::executar_sudo_exec(
623 &vps_nome,
624 &comando,
625 config_override,
626 formato,
627 json,
628 opts,
629 )
630 .await
631 }
632 Comando::SuExec {
633 vps_nome,
634 comando,
635 json,
636 password,
637 password_stdin,
638 su_password,
639 su_password_stdin,
640 key,
641 key_passphrase,
642 key_passphrase_stdin,
643 timeout,
644 description,
645 } => {
646 let password = ler_stdin_se(password_stdin, password)?;
647 let su_password = ler_stdin_se(su_password_stdin, su_password)?;
648 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
649 let opts = crate::vps::OpcoesExec {
650 password,
651 su_password,
652 key,
653 key_passphrase,
654 timeout,
655 description,
656 replace_host_key,
657 disable_sudo,
658 ..Default::default()
659 };
660 crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
661 .await
662 }
663 Comando::Scp { acao } => {
664 let pwd = match &acao {
665 AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
666 password.clone()
667 }
668 };
669 crate::scp::executar_scp(acao, config_override, pwd).await
670 }
671 Comando::Tunnel {
672 vps_nome,
673 porta_local,
674 host_remoto,
675 porta_remota,
676 timeout_ms,
677 password,
678 key,
679 } => {
680 crate::tunnel::executar_tunnel(
681 &vps_nome,
682 porta_local,
683 &host_remoto,
684 porta_remota,
685 config_override,
686 password,
687 key,
688 timeout_ms,
689 replace_host_key,
690 )
691 .await
692 }
693 Comando::HealthCheck {
694 vps_nome,
695 json,
696 password,
697 timeout,
698 } => {
699 crate::vps::executar_health_check(
700 vps_nome.as_deref(),
701 config_override,
702 formato,
703 json,
704 password,
705 timeout,
706 )
707 .await
708 }
709 Comando::Secrets { acao } => {
710 crate::vps::executar_comando_secrets(acao, config_override, formato).await
711 }
712 Comando::Completions { shell } => {
713 gerar_completions(shell);
714 Ok(())
715 }
716 }
717}
718
719#[cfg(test)]
720mod testes {
721 use super::*;
722 use clap::Parser;
723
724 #[test]
725 fn parser_entende_tunnel_com_timeout() {
726 let args = Argumentos::try_parse_from([
727 "ssh-cli",
728 "tunnel",
729 "vps-a",
730 "8080",
731 "127.0.0.1",
732 "5432",
733 "--timeout-ms",
734 "5000",
735 ])
736 .expect("tunnel");
737 match args.comando {
738 Comando::Tunnel {
739 timeout_ms,
740 porta_local,
741 ..
742 } => {
743 assert_eq!(timeout_ms, 5000);
744 assert_eq!(porta_local, 8080);
745 }
746 _ => panic!("esperado tunnel"),
747 }
748 }
749
750 #[test]
751 fn parser_vps_add_key() {
752 let args = Argumentos::try_parse_from([
753 "ssh-cli",
754 "vps",
755 "add",
756 "--name",
757 "x",
758 "--host",
759 "h",
760 "--user",
761 "u",
762 "--key",
763 "/tmp/id_ed25519",
764 ])
765 .expect("add key");
766 match args.comando {
767 Comando::Vps {
768 acao: AcaoVps::Add { key, password, .. },
769 } => {
770 assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
771 assert!(password.is_none());
772 }
773 _ => panic!("esperado add"),
774 }
775 }
776
777 #[test]
778 fn parser_sudo_exec_description() {
779 let args = Argumentos::try_parse_from([
780 "ssh-cli",
781 "sudo-exec",
782 "v",
783 "id",
784 "--description",
785 "who am i",
786 ])
787 .unwrap();
788 match args.comando {
789 Comando::SudoExec { description, .. } => {
790 assert_eq!(description.as_deref(), Some("who am i"));
791 }
792 _ => panic!("sudo-exec"),
793 }
794 }
795
796 #[test]
797 fn parser_su_exec() {
798 let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
799 assert!(matches!(args.comando, Comando::SuExec { .. }));
800 }
801
802 #[test]
803 fn parser_disable_sudo_global() {
804 let args =
805 Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
806 assert!(args.disable_sudo);
807 }
808
809 #[test]
810 fn parser_doctor() {
811 let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
812 match args.comando {
813 Comando::Vps {
814 acao: AcaoVps::Doctor { json },
815 } => assert!(json),
816 _ => panic!("doctor"),
817 }
818 }
819}