use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_complete::Shell;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum FormatoSaida {
#[default]
Text,
Json,
}
#[derive(Debug, Parser)]
#[command(
name = "ssh-cli",
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
about = "CLI Rust one-shot multi-host XDG para LLMs operarem servidores via SSH.",
long_about = "ssh-cli: binário leve one-shot (nascer→executar→morrer). Multi-host em storage XDG sem .env. \
Auth por senha ou chave. Sem telemetria."
)]
pub struct Argumentos {
#[arg(long, global = true, value_name = "LOCALE")]
pub lang: Option<String>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(long, global = true, value_name = "DIR")]
pub config_dir: Option<PathBuf>,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long, global = true, value_enum)]
pub output_format: Option<FormatoSaida>,
#[arg(long, global = true, alias = "disableSudo")]
pub disable_sudo: bool,
#[arg(long, global = true)]
pub replace_host_key: bool,
#[command(subcommand)]
pub comando: Comando,
}
#[derive(Debug, Subcommand)]
pub enum Comando {
Vps {
#[command(subcommand)]
acao: AcaoVps,
},
Connect {
nome: String,
},
Exec {
vps_nome: String,
comando: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
#[arg(long)]
key: Option<String>,
#[arg(long, conflicts_with = "key_passphrase_stdin")]
key_passphrase: Option<String>,
#[arg(long)]
key_passphrase_stdin: bool,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
description: Option<String>,
},
SudoExec {
vps_nome: String,
comando: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
#[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
sudo_password: Option<String>,
#[arg(long)]
sudo_password_stdin: bool,
#[arg(long)]
key: Option<String>,
#[arg(long, conflicts_with = "key_passphrase_stdin")]
key_passphrase: Option<String>,
#[arg(long)]
key_passphrase_stdin: bool,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
description: Option<String>,
},
SuExec {
vps_nome: String,
comando: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
#[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
su_password: Option<String>,
#[arg(long)]
su_password_stdin: bool,
#[arg(long)]
key: Option<String>,
#[arg(long, conflicts_with = "key_passphrase_stdin")]
key_passphrase: Option<String>,
#[arg(long)]
key_passphrase_stdin: bool,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
description: Option<String>,
},
Scp {
#[command(subcommand)]
acao: AcaoScp,
},
Tunnel {
vps_nome: String,
porta_local: u16,
host_remoto: String,
porta_remota: u16,
#[arg(long)]
timeout_ms: u64,
#[arg(long)]
password: Option<String>,
#[arg(long)]
key: Option<String>,
},
HealthCheck {
vps_nome: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
password: Option<String>,
#[arg(long)]
timeout: Option<u64>,
},
Secrets {
#[command(subcommand)]
acao: AcaoSecrets,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
}
#[derive(Debug, Subcommand)]
pub enum AcaoVps {
Add {
#[arg(long)]
name: String,
#[arg(long)]
host: String,
#[arg(long, default_value_t = 22)]
port: u16,
#[arg(long)]
user: String,
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
#[arg(long)]
key: Option<String>,
#[arg(long)]
key_passphrase: Option<String>,
#[arg(long, default_value_t = 60_000)]
timeout: u64,
#[arg(long)]
max_command_chars: Option<String>,
#[arg(long)]
max_output_chars: Option<String>,
#[arg(long, alias = "maxChars")]
max_chars: Option<String>,
#[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
sudo_password: Option<String>,
#[arg(long)]
sudo_password_stdin: bool,
#[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
su_password: Option<String>,
#[arg(long)]
su_password_stdin: bool,
#[arg(long, default_value_t = false)]
disable_sudo: bool,
#[arg(long)]
check: bool,
},
List {
#[arg(long)]
json: bool,
},
Remove {
nome: String,
},
Edit {
nome: String,
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
user: Option<String>,
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
#[arg(long)]
key: Option<String>,
#[arg(long)]
key_passphrase: Option<String>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
max_command_chars: Option<String>,
#[arg(long)]
max_output_chars: Option<String>,
#[arg(long, alias = "maxChars")]
max_chars: Option<String>,
#[arg(long, alias = "sudoPassword", alias = "sudo_password", conflicts_with = "sudo_password_stdin")]
sudo_password: Option<String>,
#[arg(long)]
sudo_password_stdin: bool,
#[arg(long, alias = "suPassword", alias = "su_password", conflicts_with = "su_password_stdin")]
su_password: Option<String>,
#[arg(long)]
su_password_stdin: bool,
#[arg(long)]
disable_sudo: Option<bool>,
},
Show {
nome: String,
#[arg(long)]
json: bool,
},
Path,
Doctor {
#[arg(long)]
json: bool,
},
Export {
#[arg(long)]
include_secrets: bool,
#[arg(long, short)]
output: Option<String>,
},
Import {
#[arg(long)]
file: PathBuf,
#[arg(long)]
allow_incomplete: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum AcaoScp {
Upload {
vps_nome: String,
local: PathBuf,
remote: PathBuf,
#[arg(long)]
password: Option<String>,
},
Download {
vps_nome: String,
remote: PathBuf,
local: PathBuf,
#[arg(long)]
password: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum AcaoSecrets {
Status {
#[arg(long)]
json: bool,
},
Init {
#[arg(long)]
keyring: bool,
#[arg(long)]
force: bool,
},
Reencrypt,
}
#[must_use]
pub fn parse_args() -> Argumentos {
Argumentos::parse()
}
pub fn inicializar_logs(args: &Argumentos) {
use tracing_subscriber::{fmt, EnvFilter};
let filter = if std::env::var("RUST_LOG").is_ok() {
EnvFilter::from_default_env()
} else if args.verbose {
EnvFilter::new("debug")
} else {
let _ = args.quiet;
EnvFilter::new("error")
};
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.with_ansi(false)
.try_init();
}
pub fn gerar_completions(shell: Shell) {
use clap::CommandFactory;
use std::io::Write;
let mut cmd = Argumentos::command();
let mut buf: Vec<u8> = Vec::new();
clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
let mut out = std::io::stdout().lock();
if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
}
}
fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
if flag {
Ok(Some(crate::vps::ler_segredo_stdin()?))
} else {
Ok(valor)
}
}
#[must_use]
pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
if let Some(f) = explicit {
return f;
}
if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
return FormatoSaida::Text;
}
if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
FormatoSaida::Json
} else {
FormatoSaida::Text
}
}
pub async fn executar(args: Argumentos) -> Result<()> {
let config_override = args.config_dir.clone();
crate::secrets::definir_diretorio_config(config_override.clone());
let formato = resolver_formato(args.output_format);
crate::output::definir_quiet(args.quiet);
crate::output::definir_json_erros(formato == FormatoSaida::Json);
let disable_sudo = args.disable_sudo;
let replace_host_key = args.replace_host_key;
match args.comando {
Comando::Vps { acao } => {
crate::vps::executar_comando_vps(acao, config_override, formato).await
}
Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
Comando::Exec {
vps_nome,
comando,
json,
password,
password_stdin,
key,
key_passphrase,
key_passphrase_stdin,
timeout,
description,
} => {
let password = ler_stdin_se(password_stdin, password)?;
let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
let opts = crate::vps::OpcoesExec {
password,
key,
key_passphrase,
timeout,
description,
replace_host_key,
disable_sudo,
..Default::default()
};
crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
.await
}
Comando::SudoExec {
vps_nome,
comando,
json,
password,
password_stdin,
sudo_password,
sudo_password_stdin,
key,
key_passphrase,
key_passphrase_stdin,
timeout,
description,
} => {
let password = ler_stdin_se(password_stdin, password)?;
let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
let opts = crate::vps::OpcoesExec {
password,
sudo_password,
key,
key_passphrase,
timeout,
description,
replace_host_key,
disable_sudo,
..Default::default()
};
crate::vps::executar_sudo_exec(
&vps_nome,
&comando,
config_override,
formato,
json,
opts,
)
.await
}
Comando::SuExec {
vps_nome,
comando,
json,
password,
password_stdin,
su_password,
su_password_stdin,
key,
key_passphrase,
key_passphrase_stdin,
timeout,
description,
} => {
let password = ler_stdin_se(password_stdin, password)?;
let su_password = ler_stdin_se(su_password_stdin, su_password)?;
let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
let opts = crate::vps::OpcoesExec {
password,
su_password,
key,
key_passphrase,
timeout,
description,
replace_host_key,
disable_sudo,
..Default::default()
};
crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
.await
}
Comando::Scp { acao } => {
let pwd = match &acao {
AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
password.clone()
}
};
crate::scp::executar_scp(acao, config_override, pwd).await
}
Comando::Tunnel {
vps_nome,
porta_local,
host_remoto,
porta_remota,
timeout_ms,
password,
key,
} => {
crate::tunnel::executar_tunnel(
&vps_nome,
porta_local,
&host_remoto,
porta_remota,
config_override,
password,
key,
timeout_ms,
replace_host_key,
)
.await
}
Comando::HealthCheck {
vps_nome,
json,
password,
timeout,
} => {
crate::vps::executar_health_check(
vps_nome.as_deref(),
config_override,
formato,
json,
password,
timeout,
)
.await
}
Comando::Secrets { acao } => {
crate::vps::executar_comando_secrets(acao, config_override, formato).await
}
Comando::Completions { shell } => {
gerar_completions(shell);
Ok(())
}
}
}
#[cfg(test)]
mod testes {
use super::*;
use clap::Parser;
#[test]
fn parser_entende_tunnel_com_timeout() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"tunnel",
"vps-a",
"8080",
"127.0.0.1",
"5432",
"--timeout-ms",
"5000",
])
.expect("tunnel");
match args.comando {
Comando::Tunnel {
timeout_ms,
porta_local,
..
} => {
assert_eq!(timeout_ms, 5000);
assert_eq!(porta_local, 8080);
}
_ => panic!("esperado tunnel"),
}
}
#[test]
fn parser_vps_add_key() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"vps",
"add",
"--name",
"x",
"--host",
"h",
"--user",
"u",
"--key",
"/tmp/id_ed25519",
])
.expect("add key");
match args.comando {
Comando::Vps {
acao: AcaoVps::Add { key, password, .. },
} => {
assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
assert!(password.is_none());
}
_ => panic!("esperado add"),
}
}
#[test]
fn parser_sudo_exec_description() {
let args = Argumentos::try_parse_from([
"ssh-cli",
"sudo-exec",
"v",
"id",
"--description",
"who am i",
])
.unwrap();
match args.comando {
Comando::SudoExec { description, .. } => {
assert_eq!(description.as_deref(), Some("who am i"));
}
_ => panic!("sudo-exec"),
}
}
#[test]
fn parser_su_exec() {
let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
assert!(matches!(args.comando, Comando::SuExec { .. }));
}
#[test]
fn parser_disable_sudo_global() {
let args =
Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
assert!(args.disable_sudo);
}
#[test]
fn parser_doctor() {
let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
match args.comando {
Comando::Vps {
acao: AcaoVps::Doctor { json },
} => assert!(json),
_ => panic!("doctor"),
}
}
}