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(
138 long,
139 alias = "sudoPassword",
140 alias = "sudo_password",
141 conflicts_with = "sudo_password_stdin"
142 )]
143 sudo_password: Option<String>,
144 #[arg(long)]
146 sudo_password_stdin: bool,
147 #[arg(long)]
149 key: Option<String>,
150 #[arg(long, conflicts_with = "key_passphrase_stdin")]
152 key_passphrase: Option<String>,
153 #[arg(long)]
155 key_passphrase_stdin: bool,
156 #[arg(long)]
158 timeout: Option<u64>,
159 #[arg(long)]
161 description: Option<String>,
162 },
163
164 SuExec {
166 vps_nome: String,
168 comando: String,
170 #[arg(long)]
172 json: bool,
173 #[arg(long, conflicts_with = "password_stdin")]
175 password: Option<String>,
176 #[arg(long)]
178 password_stdin: bool,
179 #[arg(
181 long,
182 alias = "suPassword",
183 alias = "su_password",
184 conflicts_with = "su_password_stdin"
185 )]
186 su_password: Option<String>,
187 #[arg(long)]
189 su_password_stdin: bool,
190 #[arg(long)]
192 key: Option<String>,
193 #[arg(long, conflicts_with = "key_passphrase_stdin")]
195 key_passphrase: Option<String>,
196 #[arg(long)]
198 key_passphrase_stdin: bool,
199 #[arg(long)]
201 timeout: Option<u64>,
202 #[arg(long)]
204 description: Option<String>,
205 },
206
207 Scp {
209 #[command(subcommand)]
211 acao: AcaoScp,
212 },
213
214 Tunnel {
216 vps_nome: String,
218 porta_local: u16,
220 host_remoto: String,
222 porta_remota: u16,
224 #[arg(long)]
226 timeout_ms: u64,
227 #[arg(long)]
229 password: Option<String>,
230 #[arg(long)]
232 key: Option<String>,
233 #[arg(long)]
235 json: bool,
236 },
237
238 HealthCheck {
240 vps_nome: Option<String>,
242 #[arg(long)]
244 json: bool,
245 #[arg(long)]
247 password: Option<String>,
248 #[arg(long)]
250 timeout: Option<u64>,
251 },
252
253 Secrets {
255 #[command(subcommand)]
257 acao: AcaoSecrets,
258 },
259
260 Completions {
262 #[arg(value_enum)]
264 shell: Shell,
265 },
266}
267
268#[derive(Debug, Subcommand)]
270pub enum AcaoVps {
271 Add {
273 #[arg(long)]
275 name: String,
276 #[arg(long)]
278 host: String,
279 #[arg(long, default_value_t = 22)]
281 port: u16,
282 #[arg(long)]
284 user: String,
285 #[arg(long, conflicts_with = "password_stdin")]
287 password: Option<String>,
288 #[arg(long)]
290 password_stdin: bool,
291 #[arg(long)]
293 key: Option<String>,
294 #[arg(long)]
296 key_passphrase: Option<String>,
297 #[arg(long, default_value_t = 60_000)]
299 timeout: u64,
300 #[arg(long)]
302 max_command_chars: Option<String>,
303 #[arg(long)]
305 max_output_chars: Option<String>,
306 #[arg(long, alias = "maxChars")]
308 max_chars: Option<String>,
309 #[arg(
311 long,
312 alias = "sudoPassword",
313 alias = "sudo_password",
314 conflicts_with = "sudo_password_stdin"
315 )]
316 sudo_password: Option<String>,
317 #[arg(long)]
319 sudo_password_stdin: bool,
320 #[arg(
322 long,
323 alias = "suPassword",
324 alias = "su_password",
325 conflicts_with = "su_password_stdin"
326 )]
327 su_password: Option<String>,
328 #[arg(long)]
330 su_password_stdin: bool,
331 #[arg(long, default_value_t = false)]
333 disable_sudo: bool,
334 #[arg(long)]
336 check: bool,
337 },
338
339 List {
341 #[arg(long)]
343 json: bool,
344 },
345
346 Remove {
348 nome: String,
350 },
351
352 Edit {
354 nome: String,
356 #[arg(long)]
358 host: Option<String>,
359 #[arg(long)]
361 port: Option<u16>,
362 #[arg(long)]
364 user: Option<String>,
365 #[arg(long, conflicts_with = "password_stdin")]
367 password: Option<String>,
368 #[arg(long)]
370 password_stdin: bool,
371 #[arg(long)]
373 key: Option<String>,
374 #[arg(long)]
376 key_passphrase: Option<String>,
377 #[arg(long)]
379 timeout: Option<u64>,
380 #[arg(long)]
382 max_command_chars: Option<String>,
383 #[arg(long)]
385 max_output_chars: Option<String>,
386 #[arg(long, alias = "maxChars")]
388 max_chars: Option<String>,
389 #[arg(
391 long,
392 alias = "sudoPassword",
393 alias = "sudo_password",
394 conflicts_with = "sudo_password_stdin"
395 )]
396 sudo_password: Option<String>,
397 #[arg(long)]
399 sudo_password_stdin: bool,
400 #[arg(
402 long,
403 alias = "suPassword",
404 alias = "su_password",
405 conflicts_with = "su_password_stdin"
406 )]
407 su_password: Option<String>,
408 #[arg(long)]
410 su_password_stdin: bool,
411 #[arg(long)]
413 disable_sudo: Option<bool>,
414 },
415
416 Show {
418 nome: String,
420 #[arg(long)]
422 json: bool,
423 },
424
425 Path,
427
428 Doctor {
430 #[arg(long)]
432 json: bool,
433 },
434
435 Export {
437 #[arg(long)]
439 include_secrets: bool,
440 #[arg(long, short)]
442 output: Option<String>,
443 },
444
445 Import {
447 #[arg(long)]
449 file: PathBuf,
450 #[arg(long)]
452 allow_incomplete: bool,
453 },
454}
455
456#[derive(Debug, Subcommand)]
458pub enum AcaoScp {
459 Upload {
461 vps_nome: String,
463 local: PathBuf,
465 remote: PathBuf,
467 #[arg(long, conflicts_with = "password_stdin")]
469 password: Option<String>,
470 #[arg(long)]
472 password_stdin: bool,
473 #[arg(long)]
475 key: Option<String>,
476 #[arg(long, conflicts_with = "key_passphrase_stdin")]
478 key_passphrase: Option<String>,
479 #[arg(long)]
481 key_passphrase_stdin: bool,
482 #[arg(long)]
484 timeout: Option<u64>,
485 #[arg(long)]
487 json: bool,
488 },
489
490 Download {
492 vps_nome: String,
494 remote: PathBuf,
496 local: PathBuf,
498 #[arg(long, conflicts_with = "password_stdin")]
500 password: Option<String>,
501 #[arg(long)]
503 password_stdin: bool,
504 #[arg(long)]
506 key: Option<String>,
507 #[arg(long, conflicts_with = "key_passphrase_stdin")]
509 key_passphrase: Option<String>,
510 #[arg(long)]
512 key_passphrase_stdin: bool,
513 #[arg(long)]
515 timeout: Option<u64>,
516 #[arg(long)]
518 json: bool,
519 },
520}
521
522#[derive(Debug, Subcommand)]
524pub enum AcaoSecrets {
525 Status {
527 #[arg(long)]
529 json: bool,
530 },
531 Init {
533 #[arg(long)]
535 keyring: bool,
536 #[arg(long)]
538 force: bool,
539 },
540 Reencrypt,
542}
543
544#[must_use]
546pub fn parse_args() -> Argumentos {
547 Argumentos::parse()
548}
549
550pub fn inicializar_logs(args: &Argumentos) {
555 use tracing_subscriber::{fmt, EnvFilter};
556
557 let filter = if std::env::var("RUST_LOG").is_ok() {
558 EnvFilter::from_default_env()
559 } else if args.verbose {
560 EnvFilter::new("debug")
561 } else {
562 let _ = args.quiet;
564 EnvFilter::new("error")
565 };
566
567 let _ = fmt()
568 .with_env_filter(filter)
569 .with_writer(std::io::stderr)
570 .with_target(false)
571 .with_ansi(false)
572 .try_init();
573}
574
575pub fn gerar_completions(shell: Shell) {
579 use clap::CommandFactory;
580 use std::io::Write;
581 let mut cmd = Argumentos::command();
582 let mut buf: Vec<u8> = Vec::new();
583 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
584 let mut out = std::io::stdout().lock();
585 if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
586 if e.kind() == std::io::ErrorKind::BrokenPipe {
587 return;
588 }
589 let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
591 }
592}
593
594fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
595 if flag {
596 Ok(Some(crate::vps::ler_segredo_stdin()?))
597 } else {
598 Ok(valor)
599 }
600}
601
602#[must_use]
604pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
605 if let Some(f) = explicit {
606 return f;
607 }
608 if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
610 return FormatoSaida::Text;
611 }
612 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
613 FormatoSaida::Json
614 } else {
615 FormatoSaida::Text
616 }
617}
618
619pub async fn executar(args: Argumentos) -> Result<()> {
621 let config_override = args.config_dir.clone();
622 crate::secrets::definir_diretorio_config(config_override.clone());
624 let formato = resolver_formato(args.output_format);
625 crate::output::definir_quiet(args.quiet);
627 crate::output::definir_json_erros(formato == FormatoSaida::Json);
628 let disable_sudo = args.disable_sudo;
629 let replace_host_key = args.replace_host_key;
630
631 match args.comando {
632 Comando::Vps { acao } => {
633 crate::vps::executar_comando_vps(acao, config_override, formato).await
634 }
635 Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
636 Comando::Exec {
637 vps_nome,
638 comando,
639 json,
640 password,
641 password_stdin,
642 key,
643 key_passphrase,
644 key_passphrase_stdin,
645 timeout,
646 description,
647 } => {
648 let password = ler_stdin_se(password_stdin, password)?;
649 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
650 let opts = crate::vps::OpcoesExec {
651 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_exec(&vps_nome, &comando, config_override, formato, json, opts)
661 .await
662 }
663 Comando::SudoExec {
664 vps_nome,
665 comando,
666 json,
667 password,
668 password_stdin,
669 sudo_password,
670 sudo_password_stdin,
671 key,
672 key_passphrase,
673 key_passphrase_stdin,
674 timeout,
675 description,
676 } => {
677 let password = ler_stdin_se(password_stdin, password)?;
678 let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
679 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
680 let opts = crate::vps::OpcoesExec {
681 password,
682 sudo_password,
683 key,
684 key_passphrase,
685 timeout,
686 description,
687 replace_host_key,
688 disable_sudo,
689 ..Default::default()
690 };
691 crate::vps::executar_sudo_exec(
692 &vps_nome,
693 &comando,
694 config_override,
695 formato,
696 json,
697 opts,
698 )
699 .await
700 }
701 Comando::SuExec {
702 vps_nome,
703 comando,
704 json,
705 password,
706 password_stdin,
707 su_password,
708 su_password_stdin,
709 key,
710 key_passphrase,
711 key_passphrase_stdin,
712 timeout,
713 description,
714 } => {
715 let password = ler_stdin_se(password_stdin, password)?;
716 let su_password = ler_stdin_se(su_password_stdin, su_password)?;
717 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
718 let opts = crate::vps::OpcoesExec {
719 password,
720 su_password,
721 key,
722 key_passphrase,
723 timeout,
724 description,
725 replace_host_key,
726 disable_sudo,
727 ..Default::default()
728 };
729 crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
730 .await
731 }
732 Comando::Scp { acao } => {
733 let (
734 password,
735 password_stdin,
736 key,
737 key_passphrase,
738 key_passphrase_stdin,
739 timeout,
740 json_local,
741 ) = match &acao {
742 AcaoScp::Upload {
743 password,
744 password_stdin,
745 key,
746 key_passphrase,
747 key_passphrase_stdin,
748 timeout,
749 json,
750 ..
751 }
752 | AcaoScp::Download {
753 password,
754 password_stdin,
755 key,
756 key_passphrase,
757 key_passphrase_stdin,
758 timeout,
759 json,
760 ..
761 } => (
762 password.clone(),
763 *password_stdin,
764 key.clone(),
765 key_passphrase.clone(),
766 *key_passphrase_stdin,
767 *timeout,
768 *json,
769 ),
770 };
771 let password = ler_stdin_se(password_stdin, password)?;
772 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
773 let json_efetivo = json_local || formato == FormatoSaida::Json;
775 if json_efetivo {
776 crate::output::definir_json_erros(true);
777 }
778 crate::scp::executar_scp(
779 acao,
780 config_override,
781 crate::scp::OpcoesScp {
782 password,
783 key,
784 key_passphrase,
785 timeout,
786 replace_host_key,
787 json: json_efetivo,
788 },
789 )
790 .await
791 }
792 Comando::Tunnel {
793 vps_nome,
794 porta_local,
795 host_remoto,
796 porta_remota,
797 timeout_ms,
798 password,
799 key,
800 json,
801 } => {
802 let json_efetivo = json || formato == FormatoSaida::Json;
804 if json_efetivo {
805 crate::output::definir_json_erros(true);
806 }
807 crate::tunnel::executar_tunnel(
808 &vps_nome,
809 porta_local,
810 &host_remoto,
811 porta_remota,
812 config_override,
813 password,
814 key,
815 timeout_ms,
816 replace_host_key,
817 json_efetivo,
818 )
819 .await
820 }
821 Comando::HealthCheck {
822 vps_nome,
823 json,
824 password,
825 timeout,
826 } => {
827 crate::vps::executar_health_check(
828 vps_nome.as_deref(),
829 config_override,
830 formato,
831 json,
832 password,
833 timeout,
834 )
835 .await
836 }
837 Comando::Secrets { acao } => {
838 crate::vps::executar_comando_secrets(acao, config_override, formato).await
839 }
840 Comando::Completions { shell } => {
841 gerar_completions(shell);
842 Ok(())
843 }
844 }
845}
846
847#[cfg(test)]
848mod testes {
849 use super::*;
850 use clap::Parser;
851
852 #[test]
853 fn parser_entende_tunnel_com_timeout() {
854 let args = Argumentos::try_parse_from([
855 "ssh-cli",
856 "tunnel",
857 "vps-a",
858 "8080",
859 "127.0.0.1",
860 "5432",
861 "--timeout-ms",
862 "5000",
863 "--json",
864 ])
865 .expect("tunnel");
866 match args.comando {
867 Comando::Tunnel {
868 timeout_ms,
869 porta_local,
870 json,
871 ..
872 } => {
873 assert_eq!(timeout_ms, 5000);
874 assert_eq!(porta_local, 8080);
875 assert!(json);
876 }
877 _ => panic!("esperado tunnel"),
878 }
879 }
880
881 #[test]
882 fn parser_vps_add_key() {
883 let args = Argumentos::try_parse_from([
884 "ssh-cli",
885 "vps",
886 "add",
887 "--name",
888 "x",
889 "--host",
890 "h",
891 "--user",
892 "u",
893 "--key",
894 "/tmp/id_ed25519",
895 ])
896 .expect("add key");
897 match args.comando {
898 Comando::Vps {
899 acao: AcaoVps::Add { key, password, .. },
900 } => {
901 assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
902 assert!(password.is_none());
903 }
904 _ => panic!("esperado add"),
905 }
906 }
907
908 #[test]
909 fn parser_sudo_exec_description() {
910 let args = Argumentos::try_parse_from([
911 "ssh-cli",
912 "sudo-exec",
913 "v",
914 "id",
915 "--description",
916 "who am i",
917 ])
918 .unwrap();
919 match args.comando {
920 Comando::SudoExec { description, .. } => {
921 assert_eq!(description.as_deref(), Some("who am i"));
922 }
923 _ => panic!("sudo-exec"),
924 }
925 }
926
927 #[test]
928 fn parser_su_exec() {
929 let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
930 assert!(matches!(args.comando, Comando::SuExec { .. }));
931 }
932
933 #[test]
934 fn parser_disable_sudo_global() {
935 let args =
936 Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
937 assert!(args.disable_sudo);
938 }
939
940 #[test]
941 fn parser_doctor() {
942 let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
943 match args.comando {
944 Comando::Vps {
945 acao: AcaoVps::Doctor { json },
946 } => assert!(json),
947 _ => panic!("doctor"),
948 }
949 }
950}