use anyhow::{Result, bail};
use clap::Args;
use console::style;
use dialoguer::Confirm;
use indicatif::{ProgressBar, ProgressStyle};
use opencode_cloud_core::{
HostConfig, HostError, detect_distro, get_docker_install_commands, host_exists_in_ssh_config,
install_docker, load_hosts, query_ssh_config, save_hosts, test_connection,
verify_docker_installed, write_ssh_config_entry,
};
#[derive(Args)]
pub struct HostAddArgs {
pub name: String,
pub hostname: String,
#[arg(short, long)]
pub user: Option<String>,
#[arg(short, long)]
pub port: Option<u16>,
#[arg(short, long)]
pub identity_file: Option<String>,
#[arg(short = 'J', long)]
pub jump_host: Option<String>,
#[arg(short, long)]
pub group: Vec<String>,
#[arg(short, long)]
pub description: Option<String>,
#[arg(long)]
pub no_verify: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub no_ssh_config: bool,
}
pub async fn cmd_host_add(args: &HostAddArgs, quiet: bool, _verbose: u8) -> Result<()> {
let mut hosts = load_hosts()?;
if hosts.has_host(&args.name) && !args.force {
bail!(
"Host '{}' already exists. Use --force to overwrite, or choose a different name.",
args.name
);
}
let ssh_config_match = query_ssh_config(&args.hostname).unwrap_or_default();
if !quiet && ssh_config_match.has_settings() {
println!(
"{} Found in ~/.ssh/config: {}",
style("SSH Config:").cyan(),
ssh_config_match.display_settings()
);
}
let mut config = HostConfig::new(&args.hostname);
let effective_user = args.user.clone().or_else(|| ssh_config_match.user.clone());
if let Some(user) = &effective_user {
config = config.with_user(user);
}
let effective_port = args.port.or(ssh_config_match.port);
if let Some(port) = effective_port {
config = config.with_port(port);
}
let effective_identity = args
.identity_file
.clone()
.or_else(|| ssh_config_match.identity_file.clone());
if let Some(key) = &effective_identity {
config = config.with_identity_file(key);
}
let effective_jump = args
.jump_host
.clone()
.or_else(|| ssh_config_match.proxy_jump.clone());
if let Some(jump) = &effective_jump {
config = config.with_jump_host(jump);
}
for group in &args.group {
config = config.with_group(group);
}
if let Some(desc) = &args.description {
config = config.with_description(desc);
}
let has_custom_settings = args.user.is_some()
|| args.identity_file.is_some()
|| args.port.is_some()
|| args.jump_host.is_some();
let mut verification_succeeded = false;
if !args.no_verify {
if !quiet {
println!(
"{} {}",
style("SSH Command:").cyan(),
style(config.format_ssh_command()).dim()
);
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.expect("valid template"),
);
spinner.set_message(format!(
"Testing connection to {}@{}...",
config.user, args.hostname
));
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
match test_connection(&config).await {
Ok(docker_version) => {
spinner.finish_with_message(format!(
"{} Connected (Docker {})",
style("✓").green(),
docker_version
));
verification_succeeded = true;
}
Err(HostError::RemoteDockerUnavailable(_)) => {
spinner.finish_with_message(format!(
"{} Docker not installed",
style("!").yellow()
));
eprintln!();
if let Some(installed) =
offer_docker_installation(&config, &args.hostname, quiet)?
{
if installed {
verification_succeeded = true;
}
} else {
bail!("Docker is required on the remote host");
}
}
Err(e) => {
spinner.finish_with_message(format!("{} Connection failed", style("✗").red()));
eprintln!();
eprintln!(" {e}");
eprintln!();
print_connection_failure_tips(
&config,
&args.hostname,
args.user.is_none(),
args.identity_file.is_none(),
);
bail!("Connection verification failed");
}
}
} else {
test_connection(&config).await?;
verification_succeeded = true;
}
}
let is_overwrite = hosts.has_host(&args.name);
hosts.add_host(&args.name, config.clone());
save_hosts(&hosts)?;
if !quiet {
if is_overwrite {
println!(
"{} Host '{}' updated ({}).",
style("Updated:").yellow(),
style(&args.name).cyan(),
args.hostname
);
} else {
println!(
"{} Host '{}' added ({}).",
style("Added:").green(),
style(&args.name).cyan(),
args.hostname
);
}
if args.no_verify {
println!(
" {} Connection not verified. Run {} to test.",
style("Note:").dim(),
style(format!("occ host test {}", args.name)).yellow()
);
}
if verification_succeeded
&& has_custom_settings
&& !args.no_ssh_config
&& !host_exists_in_ssh_config(&args.name)
{
println!();
let should_add = Confirm::new()
.with_prompt(format!(
"Add '{}' to ~/.ssh/config for easier SSH access?",
args.name
))
.default(true)
.interact()?;
if should_add {
match write_ssh_config_entry(
&args.name,
&args.hostname,
args.user.as_deref(),
args.port,
args.identity_file.as_deref(),
args.jump_host.as_deref(),
) {
Ok(path) => {
println!(
" {} Added to {}",
style("SSH Config:").green(),
path.display()
);
println!(
" {} You can now use: {}",
style("Tip:").dim(),
style(format!("ssh {}", args.name)).yellow()
);
}
Err(e) => {
eprintln!(
" {} Failed to update SSH config: {}",
style("Warning:").yellow(),
e
);
}
}
}
}
}
Ok(())
}
fn offer_docker_installation(
config: &HostConfig,
hostname: &str,
quiet: bool,
) -> Result<Option<bool>> {
if quiet {
return Ok(None);
}
println!(
" {} Docker is not installed on {}",
style("Detected:").yellow(),
style(hostname).cyan()
);
println!();
let distro = match detect_distro(config) {
Ok(d) => d,
Err(e) => {
eprintln!(
" {} Could not detect Linux distribution: {}",
style("Error:").red(),
e
);
return Ok(None);
}
};
println!(
" {} {} ({})",
style("Distribution:").dim(),
distro.pretty_name,
distro.family
);
println!();
let commands = match get_docker_install_commands(&distro) {
Ok(c) => c,
Err(e) => {
eprintln!(" {} {}", style("Error:").red(), e);
println!();
println!(
" {} Install Docker manually, then re-run this command.",
style("Tip:").dim()
);
return Ok(None);
}
};
println!(
" {} The following commands will be run:",
style("Installation:").cyan()
);
for cmd in &commands {
println!(" {}", style(cmd).dim());
}
println!();
let should_install = Confirm::new()
.with_prompt("Install Docker on the remote host?")
.default(true)
.interact()?;
if !should_install {
println!();
println!(
" {} You can install Docker manually, then run:",
style("Tip:").dim()
);
println!(
" {}",
style(format!("occ host add {hostname} {hostname}")).yellow()
);
return Ok(None);
}
println!();
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.expect("valid template"),
);
spinner.set_message("Installing Docker...");
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
match install_docker(config, &distro, |line| {
let trimmed = line.trim();
if !trimmed.is_empty() {
spinner.set_message(format!("Installing: {}", truncate_str(trimmed, 50)));
}
}) {
Ok(()) => {
spinner.finish_with_message(format!("{} Docker installed", style("✓").green()));
}
Err(e) => {
spinner.finish_with_message(format!("{} Installation failed: {}", style("✗").red(), e));
return Ok(Some(false));
}
}
println!();
println!(
" {} Group membership changes require a new SSH session.",
style("Note:").yellow()
);
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.expect("valid template"),
);
spinner.set_message("Verifying Docker installation...");
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
match verify_docker_installed(config) {
Ok(version) => {
spinner.finish_with_message(format!(
"{} Docker {} verified",
style("✓").green(),
version
));
Ok(Some(true))
}
Err(e) => {
spinner.finish_with_message(format!("{} Verification: {}", style("!").yellow(), e));
println!();
println!(
" {} Docker was installed but verification failed.",
style("Note:").yellow()
);
println!(
" This is often because the user needs to reconnect for group membership."
);
println!(
" Try: {}",
style("ssh <host> docker --version").yellow()
);
Ok(Some(true))
}
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
fn print_connection_failure_tips(
config: &HostConfig,
hostname: &str,
no_user_specified: bool,
no_identity_specified: bool,
) {
println!("{}", style("Troubleshooting tips:").yellow());
let mut tip_num = 1;
if no_user_specified {
println!(
" {} Cloud instances often use specific usernames:",
style(format!("{tip_num}.")).dim()
);
println!(" • AWS EC2: {}", style("--user ubuntu").yellow());
println!(
" • AWS EC2 (Amazon Linux): {}",
style("--user ec2-user").yellow()
);
println!(
" • GCP: {}",
style("--user <your-gcp-username>").yellow()
);
println!(" • Azure: {}", style("--user azureuser").yellow());
println!(" • DigitalOcean: {}", style("--user root").yellow());
println!();
tip_num += 1;
}
if no_identity_specified {
let keys = find_ssh_keys();
if !keys.is_empty() {
println!(
" {} Try specifying an identity file:",
style(format!("{tip_num}.")).dim()
);
for key in keys.iter().take(5) {
println!(" {}", style(format!("--identity-file {key}")).yellow());
}
if keys.len() > 5 {
println!(
" {} ({} more keys in ~/.ssh/)",
style("...").dim(),
keys.len() - 5
);
}
println!();
tip_num += 1;
}
}
let ssh_cmd = if let Some(key) = &config.identity_file {
format!("ssh -i {} {}@{}", key, config.user, hostname)
} else {
format!("ssh {}@{}", config.user, hostname)
};
println!(
" {} Verify SSH access manually: {}",
style(format!("{tip_num}.")).dim(),
style(&ssh_cmd).yellow()
);
tip_num += 1;
println!(
" {} Ensure Docker is running on the remote host",
style(format!("{tip_num}.")).dim()
);
tip_num += 1;
println!(
" {} Use {} to add the host without verification",
style(format!("{tip_num}.")).dim(),
style("--no-verify").yellow()
);
}
fn find_ssh_keys() -> Vec<String> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
let ssh_dir = home.join(".ssh");
if !ssh_dir.is_dir() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(&ssh_dir) else {
return Vec::new();
};
let mut keys = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with(".pub")
|| name == "known_hosts"
|| name == "known_hosts.old"
|| name == "config"
|| name == "authorized_keys"
|| name.starts_with(".")
{
continue;
}
let is_likely_key = name.starts_with("id_")
|| name.ends_with(".pem")
|| name.ends_with("_rsa")
|| name.ends_with("_ed25519")
|| name.ends_with("_ecdsa")
|| name.ends_with("_dsa")
|| name.contains("key");
let is_key = if is_likely_key {
true
} else {
std::fs::read_to_string(&path)
.map(|content| content.contains("PRIVATE KEY"))
.unwrap_or(false)
};
if is_key {
keys.push(path.display().to_string());
}
}
keys.sort();
keys
}