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, conflicts_with = "password_stdin")]
229 password: Option<String>,
230 #[arg(long)]
232 password_stdin: bool,
233 #[arg(long)]
235 key: Option<String>,
236 #[arg(long, conflicts_with = "key_passphrase_stdin")]
238 key_passphrase: Option<String>,
239 #[arg(long)]
241 key_passphrase_stdin: bool,
242 #[arg(long)]
244 json: bool,
245 },
246
247 HealthCheck {
249 vps_nome: Option<String>,
251 #[arg(long)]
253 json: bool,
254 #[arg(long, conflicts_with = "password_stdin")]
256 password: Option<String>,
257 #[arg(long)]
259 password_stdin: bool,
260 #[arg(long)]
262 key: Option<String>,
263 #[arg(long, conflicts_with = "key_passphrase_stdin")]
265 key_passphrase: Option<String>,
266 #[arg(long)]
268 key_passphrase_stdin: bool,
269 #[arg(long)]
271 timeout: Option<u64>,
272 },
273
274 Secrets {
276 #[command(subcommand)]
278 acao: AcaoSecrets,
279 },
280
281 Completions {
283 #[arg(value_enum)]
285 shell: Shell,
286 },
287}
288
289#[derive(Debug, Subcommand)]
291pub enum AcaoVps {
292 Add {
294 #[arg(long)]
296 name: String,
297 #[arg(long)]
299 host: String,
300 #[arg(long, default_value_t = 22)]
302 port: u16,
303 #[arg(long)]
305 user: String,
306 #[arg(long, conflicts_with = "password_stdin")]
308 password: Option<String>,
309 #[arg(long)]
311 password_stdin: bool,
312 #[arg(long)]
314 key: Option<String>,
315 #[arg(long)]
317 key_passphrase: Option<String>,
318 #[arg(long, default_value_t = 60_000)]
320 timeout: u64,
321 #[arg(long)]
323 max_command_chars: Option<String>,
324 #[arg(long)]
326 max_output_chars: Option<String>,
327 #[arg(long, alias = "maxChars")]
329 max_chars: Option<String>,
330 #[arg(
332 long,
333 alias = "sudoPassword",
334 alias = "sudo_password",
335 conflicts_with = "sudo_password_stdin"
336 )]
337 sudo_password: Option<String>,
338 #[arg(long)]
340 sudo_password_stdin: bool,
341 #[arg(
343 long,
344 alias = "suPassword",
345 alias = "su_password",
346 conflicts_with = "su_password_stdin"
347 )]
348 su_password: Option<String>,
349 #[arg(long)]
351 su_password_stdin: bool,
352 #[arg(long, default_value_t = false)]
354 disable_sudo: bool,
355 #[arg(long)]
357 check: bool,
358 },
359
360 List {
362 #[arg(long)]
364 json: bool,
365 },
366
367 Remove {
369 nome: String,
371 },
372
373 Edit {
375 nome: String,
377 #[arg(long)]
379 host: Option<String>,
380 #[arg(long)]
382 port: Option<u16>,
383 #[arg(long)]
385 user: Option<String>,
386 #[arg(long, conflicts_with = "password_stdin")]
388 password: Option<String>,
389 #[arg(long)]
391 password_stdin: bool,
392 #[arg(long)]
394 key: Option<String>,
395 #[arg(long)]
397 key_passphrase: Option<String>,
398 #[arg(long)]
400 timeout: Option<u64>,
401 #[arg(long)]
403 max_command_chars: Option<String>,
404 #[arg(long)]
406 max_output_chars: Option<String>,
407 #[arg(long, alias = "maxChars")]
409 max_chars: Option<String>,
410 #[arg(
412 long,
413 alias = "sudoPassword",
414 alias = "sudo_password",
415 conflicts_with = "sudo_password_stdin"
416 )]
417 sudo_password: Option<String>,
418 #[arg(long)]
420 sudo_password_stdin: bool,
421 #[arg(
423 long,
424 alias = "suPassword",
425 alias = "su_password",
426 conflicts_with = "su_password_stdin"
427 )]
428 su_password: Option<String>,
429 #[arg(long)]
431 su_password_stdin: bool,
432 #[arg(long)]
434 disable_sudo: Option<bool>,
435 },
436
437 Show {
439 nome: String,
441 #[arg(long)]
443 json: bool,
444 },
445
446 Path,
448
449 Doctor {
451 #[arg(long)]
453 json: bool,
454 },
455
456 Export {
458 #[arg(long)]
460 include_secrets: bool,
461 #[arg(long, short)]
463 output: Option<String>,
464 },
465
466 Import {
468 #[arg(long)]
470 file: PathBuf,
471 #[arg(long)]
473 allow_incomplete: bool,
474 },
475}
476
477#[derive(Debug, Subcommand)]
479pub enum AcaoScp {
480 Upload {
482 vps_nome: String,
484 local: PathBuf,
486 remote: PathBuf,
488 #[arg(long, conflicts_with = "password_stdin")]
490 password: Option<String>,
491 #[arg(long)]
493 password_stdin: bool,
494 #[arg(long)]
496 key: Option<String>,
497 #[arg(long, conflicts_with = "key_passphrase_stdin")]
499 key_passphrase: Option<String>,
500 #[arg(long)]
502 key_passphrase_stdin: bool,
503 #[arg(long)]
505 timeout: Option<u64>,
506 #[arg(long)]
508 json: bool,
509 },
510
511 Download {
513 vps_nome: String,
515 remote: PathBuf,
517 local: PathBuf,
519 #[arg(long, conflicts_with = "password_stdin")]
521 password: Option<String>,
522 #[arg(long)]
524 password_stdin: bool,
525 #[arg(long)]
527 key: Option<String>,
528 #[arg(long, conflicts_with = "key_passphrase_stdin")]
530 key_passphrase: Option<String>,
531 #[arg(long)]
533 key_passphrase_stdin: bool,
534 #[arg(long)]
536 timeout: Option<u64>,
537 #[arg(long)]
539 json: bool,
540 },
541}
542
543#[derive(Debug, Subcommand)]
545pub enum AcaoSecrets {
546 Status {
548 #[arg(long)]
550 json: bool,
551 },
552 Init {
554 #[arg(long)]
556 keyring: bool,
557 #[arg(long)]
559 force: bool,
560 },
561 Reencrypt,
563}
564
565#[must_use]
567pub fn parse_args() -> Argumentos {
568 Argumentos::parse()
569}
570
571pub fn inicializar_logs(args: &Argumentos) {
576 use tracing_subscriber::{fmt, EnvFilter};
577
578 let filter = if std::env::var("RUST_LOG").is_ok() {
579 EnvFilter::from_default_env()
580 } else if args.verbose {
581 EnvFilter::new("debug")
582 } else {
583 let _ = args.quiet;
585 EnvFilter::new("error")
586 };
587
588 let _ = fmt()
589 .with_env_filter(filter)
590 .with_writer(std::io::stderr)
591 .with_target(false)
592 .with_ansi(false)
593 .try_init();
594}
595
596pub fn gerar_completions(shell: Shell) {
600 use clap::CommandFactory;
601 use std::io::Write;
602 let mut cmd = Argumentos::command();
603 let mut buf: Vec<u8> = Vec::new();
604 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
605 let mut out = std::io::stdout().lock();
606 if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
607 if e.kind() == std::io::ErrorKind::BrokenPipe {
608 return;
609 }
610 let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
612 }
613}
614
615fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
616 if flag {
617 Ok(Some(crate::vps::ler_segredo_stdin()?))
618 } else {
619 Ok(valor)
620 }
621}
622
623#[must_use]
625pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
626 if let Some(f) = explicit {
627 return f;
628 }
629 if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
631 return FormatoSaida::Text;
632 }
633 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
634 FormatoSaida::Json
635 } else {
636 FormatoSaida::Text
637 }
638}
639
640pub async fn executar(args: Argumentos) -> Result<()> {
642 let config_override = args.config_dir.clone();
643 crate::secrets::definir_diretorio_config(config_override.clone());
645 let formato = resolver_formato(args.output_format);
646 crate::output::definir_quiet(args.quiet);
648 crate::output::definir_json_erros(formato == FormatoSaida::Json);
649 let disable_sudo = args.disable_sudo;
650 let replace_host_key = args.replace_host_key;
651
652 match args.comando {
653 Comando::Vps { acao } => {
654 crate::vps::executar_comando_vps(acao, config_override, formato).await
655 }
656 Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
657 Comando::Exec {
658 vps_nome,
659 comando,
660 json,
661 password,
662 password_stdin,
663 key,
664 key_passphrase,
665 key_passphrase_stdin,
666 timeout,
667 description,
668 } => {
669 let password = ler_stdin_se(password_stdin, password)?;
670 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
671 let opts = crate::vps::OpcoesExec {
672 password,
673 key,
674 key_passphrase,
675 timeout,
676 description,
677 replace_host_key,
678 disable_sudo,
679 ..Default::default()
680 };
681 crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
682 .await
683 }
684 Comando::SudoExec {
685 vps_nome,
686 comando,
687 json,
688 password,
689 password_stdin,
690 sudo_password,
691 sudo_password_stdin,
692 key,
693 key_passphrase,
694 key_passphrase_stdin,
695 timeout,
696 description,
697 } => {
698 let password = ler_stdin_se(password_stdin, password)?;
699 let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
700 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
701 let opts = crate::vps::OpcoesExec {
702 password,
703 sudo_password,
704 key,
705 key_passphrase,
706 timeout,
707 description,
708 replace_host_key,
709 disable_sudo,
710 ..Default::default()
711 };
712 crate::vps::executar_sudo_exec(
713 &vps_nome,
714 &comando,
715 config_override,
716 formato,
717 json,
718 opts,
719 )
720 .await
721 }
722 Comando::SuExec {
723 vps_nome,
724 comando,
725 json,
726 password,
727 password_stdin,
728 su_password,
729 su_password_stdin,
730 key,
731 key_passphrase,
732 key_passphrase_stdin,
733 timeout,
734 description,
735 } => {
736 let password = ler_stdin_se(password_stdin, password)?;
737 let su_password = ler_stdin_se(su_password_stdin, su_password)?;
738 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
739 let opts = crate::vps::OpcoesExec {
740 password,
741 su_password,
742 key,
743 key_passphrase,
744 timeout,
745 description,
746 replace_host_key,
747 disable_sudo,
748 ..Default::default()
749 };
750 crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
751 .await
752 }
753 Comando::Scp { acao } => {
754 let (
755 password,
756 password_stdin,
757 key,
758 key_passphrase,
759 key_passphrase_stdin,
760 timeout,
761 json_local,
762 ) = match &acao {
763 AcaoScp::Upload {
764 password,
765 password_stdin,
766 key,
767 key_passphrase,
768 key_passphrase_stdin,
769 timeout,
770 json,
771 ..
772 }
773 | AcaoScp::Download {
774 password,
775 password_stdin,
776 key,
777 key_passphrase,
778 key_passphrase_stdin,
779 timeout,
780 json,
781 ..
782 } => (
783 password.clone(),
784 *password_stdin,
785 key.clone(),
786 key_passphrase.clone(),
787 *key_passphrase_stdin,
788 *timeout,
789 *json,
790 ),
791 };
792 let password = ler_stdin_se(password_stdin, password)?;
793 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
794 let json_efetivo = json_local || formato == FormatoSaida::Json;
796 if json_efetivo {
797 crate::output::definir_json_erros(true);
798 }
799 crate::scp::executar_scp(
800 acao,
801 config_override,
802 crate::scp::OpcoesScp {
803 password,
804 key,
805 key_passphrase,
806 timeout,
807 replace_host_key,
808 json: json_efetivo,
809 },
810 )
811 .await
812 }
813 Comando::Tunnel {
814 vps_nome,
815 porta_local,
816 host_remoto,
817 porta_remota,
818 timeout_ms,
819 password,
820 password_stdin,
821 key,
822 key_passphrase,
823 key_passphrase_stdin,
824 json,
825 } => {
826 let json_efetivo = json || formato == FormatoSaida::Json;
828 if json_efetivo {
829 crate::output::definir_json_erros(true);
830 }
831 let password = ler_stdin_se(password_stdin, password)?;
833 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
834 crate::tunnel::executar_tunnel(
835 &vps_nome,
836 porta_local,
837 &host_remoto,
838 porta_remota,
839 config_override,
840 password,
841 key,
842 key_passphrase,
843 timeout_ms,
844 replace_host_key,
845 json_efetivo,
846 )
847 .await
848 }
849 Comando::HealthCheck {
850 vps_nome,
851 json,
852 password,
853 password_stdin,
854 key,
855 key_passphrase,
856 key_passphrase_stdin,
857 timeout,
858 } => {
859 let password = ler_stdin_se(password_stdin, password)?;
861 let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
862 crate::vps::executar_health_check(
863 vps_nome.as_deref(),
864 config_override,
865 formato,
866 json,
867 password,
868 timeout,
869 key,
870 key_passphrase,
871 replace_host_key,
872 )
873 .await
874 }
875 Comando::Secrets { acao } => {
876 crate::vps::executar_comando_secrets(acao, config_override, formato).await
877 }
878 Comando::Completions { shell } => {
879 gerar_completions(shell);
880 Ok(())
881 }
882 }
883}
884
885#[cfg(test)]
886mod testes {
887 use super::*;
888 use clap::Parser;
889
890 #[test]
891 fn parser_entende_tunnel_com_timeout() {
892 let args = Argumentos::try_parse_from([
893 "ssh-cli",
894 "tunnel",
895 "vps-a",
896 "8080",
897 "127.0.0.1",
898 "5432",
899 "--timeout-ms",
900 "5000",
901 "--json",
902 ])
903 .expect("tunnel");
904 match args.comando {
905 Comando::Tunnel {
906 timeout_ms,
907 porta_local,
908 json,
909 ..
910 } => {
911 assert_eq!(timeout_ms, 5000);
912 assert_eq!(porta_local, 8080);
913 assert!(json);
914 }
915 _ => panic!("esperado tunnel"),
916 }
917 }
918
919 #[test]
920 fn parser_vps_add_key() {
921 let args = Argumentos::try_parse_from([
922 "ssh-cli",
923 "vps",
924 "add",
925 "--name",
926 "x",
927 "--host",
928 "h",
929 "--user",
930 "u",
931 "--key",
932 "/tmp/id_ed25519",
933 ])
934 .expect("add key");
935 match args.comando {
936 Comando::Vps {
937 acao: AcaoVps::Add { key, password, .. },
938 } => {
939 assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
940 assert!(password.is_none());
941 }
942 _ => panic!("esperado add"),
943 }
944 }
945
946 #[test]
947 fn parser_sudo_exec_description() {
948 let args = Argumentos::try_parse_from([
949 "ssh-cli",
950 "sudo-exec",
951 "v",
952 "id",
953 "--description",
954 "who am i",
955 ])
956 .unwrap();
957 match args.comando {
958 Comando::SudoExec { description, .. } => {
959 assert_eq!(description.as_deref(), Some("who am i"));
960 }
961 _ => panic!("sudo-exec"),
962 }
963 }
964
965 #[test]
966 fn parser_su_exec() {
967 let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
968 assert!(matches!(args.comando, Comando::SuExec { .. }));
969 }
970
971 #[test]
972 fn parser_disable_sudo_global() {
973 let args =
974 Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
975 assert!(args.disable_sudo);
976 }
977
978 #[test]
979 fn parser_doctor() {
980 let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
981 match args.comando {
982 Comando::Vps {
983 acao: AcaoVps::Doctor { json },
984 } => assert!(json),
985 _ => panic!("doctor"),
986 }
987 }
988}