mod ansible_assets;
mod commands;
mod config;
mod hosts;
mod key_registry;
mod output;
mod playbook_meta;
mod prompt;
mod services;
mod signal;
mod ssh_config;
mod ssh_session;
mod tool_versions;
use clap::{CommandFactory, Parser, Subcommand};
use commands::ansible::{AnsibleCommands, run_ansible_bootstrap, run_ansible_run};
use commands::backup::{
BackupCommands, RestoreOptions, VerifyOptions, run_backup_create, run_backup_list,
run_backup_prune, run_backup_push, run_backup_restore, run_backup_sync, run_backup_verify,
run_export_opml, run_import_opml,
};
use commands::bichon::{BichonCommands, run_bichon_command};
use commands::config_cmd::{
ConfigCommands, run_config_edit, run_config_get, run_config_init, run_config_list,
run_config_path, run_config_remove, run_config_set,
};
use commands::deploy::{DeployCmd, run_deploy};
use commands::dns::{
DnsCommands, run_dns_delete, run_dns_list, run_dns_migrate, run_dns_set, run_dns_set_all,
run_dns_status,
};
use commands::headscale::{
HeadscaleCommands, run_headscale_add_user, run_headscale_list_nodes, run_headscale_list_users,
run_headscale_remove_user,
};
use commands::host::{
AddHostArgs, HostCommands, run_host_add, run_host_detect_tailscale_ip, run_host_edit,
run_host_list, run_host_remove, run_host_show,
};
use commands::select::{SelectCommands, run_select_host, run_select_playbook};
use commands::ssh::{SshCommands, run_ssh_add_key, run_ssh_keygen};
use commands::sync::{SyncCommands, run_sync_hermes, run_sync_music};
use commands::versions::{VersionsCmd, run_versions};
use eyre::Result;
#[derive(Parser)]
#[command(name = "auberge")]
#[command(about = "CLI for selfhost infrastructure management")]
#[command(version)]
struct Cli {
#[arg(short, long, global = true, help = "Enable verbose output")]
verbose: bool,
#[arg(
short,
long,
global = true,
help = "Suppress non-essential output",
conflicts_with = "verbose"
)]
quiet: bool,
#[arg(
long,
global = true,
help = "Disable colored output (also honored via NO_COLOR env var)"
)]
no_color: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(visible_alias = "dp", about = "Deploy apps to a host")]
Deploy(DeployCmd),
#[command(
subcommand,
visible_alias = "se",
about = "Select hosts or playbooks interactively"
)]
Select(SelectCommands),
#[command(subcommand, visible_alias = "a", about = "Run ansible playbooks")]
Ansible(AnsibleCommands),
#[command(
subcommand,
visible_alias = "b",
about = "Backup and restore application data"
)]
Backup(BackupCommands),
#[command(
subcommand,
visible_alias = "hs",
about = "Manage Headscale VPN users and nodes"
)]
Headscale(HeadscaleCommands),
#[command(subcommand, visible_alias = "h", about = "Manage VPS hosts")]
Host(HostCommands),
#[command(subcommand, visible_alias = "ss", about = "SSH key management")]
Ssh(SshCommands),
#[command(subcommand, visible_alias = "sy", about = "Sync files to remote hosts")]
Sync(SyncCommands),
#[command(
subcommand,
visible_alias = "d",
about = "DNS management via Namecheap"
)]
Dns(DnsCommands),
#[command(subcommand, visible_alias = "c", about = "Manage user configuration")]
Config(ConfigCommands),
#[command(subcommand, about = "Manage Bichon email archive behavior")]
Bichon(BichonCommands),
#[command(
visible_alias = "v",
about = "Report declared App and Tool Versions and upstream drift"
)]
Versions(VersionsCmd),
#[command(about = "Generate shell completion script")]
Completions { shell: clap_complete::Shell },
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let cli = Cli::parse();
output::set_verbose(cli.verbose);
output::set_quiet(cli.quiet);
output::set_no_color(cli.no_color);
match cli.command {
Commands::Deploy(cmd) => signal::with_ctrlc(|| run_deploy(cmd)),
Commands::Select(cmd) => match cmd {
SelectCommands::Host { group } => run_select_host(group),
SelectCommands::Playbook => run_select_playbook(),
},
Commands::Headscale(cmd) => match cmd {
HeadscaleCommands::AddUser {
name,
expiration,
host,
} => run_headscale_add_user(name, expiration, host),
HeadscaleCommands::ListUsers { output, host } => run_headscale_list_users(output, host),
HeadscaleCommands::ListNodes { output, host } => run_headscale_list_nodes(output, host),
HeadscaleCommands::RemoveUser { name, yes, host } => {
run_headscale_remove_user(name, yes, host)
}
},
Commands::Host(cmd) => match cmd {
HostCommands::Add {
name,
address,
user,
port,
ssh_key,
tags,
description,
no_input,
} => run_host_add(AddHostArgs {
name,
address,
user,
port,
ssh_key,
tags,
description,
no_input,
}),
HostCommands::List { tags, output } => run_host_list(tags, output),
HostCommands::Remove { name, yes } => run_host_remove(name, yes),
HostCommands::Show { name } => run_host_show(name),
HostCommands::Edit { name } => run_host_edit(name),
HostCommands::DetectTailscaleIp { name } => run_host_detect_tailscale_ip(name),
},
Commands::Ansible(cmd) => match cmd {
AnsibleCommands::Run {
host,
playbook,
check,
tags,
skip_tags,
user,
ask_pass,
force,
} => signal::with_ctrlc(|| {
run_ansible_run(
host, playbook, check, tags, skip_tags, user, ask_pass, force,
)
}),
AnsibleCommands::Bootstrap {
host,
port,
ip,
user,
force,
} => run_ansible_bootstrap(host, port, ip, user, force),
},
Commands::Backup(cmd) => match cmd {
BackupCommands::Create {
host,
apps,
dest,
ssh_key,
include_music,
dry_run,
} => signal::with_ctrlc(|| {
run_backup_create(host, apps, dest, ssh_key, include_music, dry_run).and_then(
|outcome| {
let failed = outcome.failed_apps();
if failed.is_empty() {
Ok(())
} else {
eyre::bail!("{} backup(s) failed", failed.len());
}
},
)
}),
BackupCommands::Sync {
host,
apps,
ssh_key,
include_music,
dry_run,
} => {
signal::with_ctrlc(|| run_backup_sync(host, apps, ssh_key, include_music, dry_run))
}
BackupCommands::List { host, app, output } => run_backup_list(host, app, output),
BackupCommands::Restore {
backup_id,
host,
from_host,
apps,
ssh_key,
dry_run,
yes,
skip_playbook_unsafe,
} => signal::with_ctrlc(|| {
run_backup_restore(RestoreOptions {
backup_id,
host_arg: host,
from_host_arg: from_host,
apps,
ssh_key,
dry_run,
yes,
skip_playbook_unsafe,
})
}),
BackupCommands::Push { host, backup_id } => {
signal::with_ctrlc(|| run_backup_push(host, backup_id))
}
BackupCommands::Prune { dry_run } => signal::with_ctrlc(|| run_backup_prune(dry_run)),
BackupCommands::Verify {
host,
app,
max_age,
output,
} => std::process::exit(run_backup_verify(VerifyOptions {
host,
app,
max_age,
format: output,
})),
BackupCommands::ExportOpml {
host,
output,
ssh_key,
user,
} => run_export_opml(host, output, ssh_key, user),
BackupCommands::ImportOpml {
host,
input,
ssh_key,
user,
} => run_import_opml(host, input, ssh_key, user),
},
Commands::Ssh(cmd) => match cmd {
SshCommands::Keygen { host, user, force } => run_ssh_keygen(host, user, force),
SshCommands::AddKey {
host,
connect_with,
authorize,
user,
yes,
} => run_ssh_add_key(host, connect_with, authorize, user, yes),
},
Commands::Sync(cmd) => match cmd {
SyncCommands::Music {
host,
source,
dry_run,
} => signal::with_ctrlc(|| run_sync_music(host, source, dry_run)),
SyncCommands::Hermes {
host,
source,
dry_run,
pull,
} => signal::with_ctrlc(|| run_sync_hermes(host, source, dry_run, pull)),
},
Commands::Dns(cmd) => match cmd {
DnsCommands::List {
subdomain,
output,
production,
} => run_dns_list(subdomain, output, production).await,
DnsCommands::Status { output, production } => run_dns_status(output, production).await,
DnsCommands::Set {
subdomain,
ip,
production,
} => run_dns_set(subdomain, ip, production).await,
DnsCommands::Delete {
subdomain,
dry_run,
output,
production,
yes,
} => run_dns_delete(subdomain, dry_run, output, production, yes).await,
DnsCommands::Migrate {
ip,
dry_run,
output,
production,
} => run_dns_migrate(ip, dry_run, output, production).await,
DnsCommands::SetAll {
host,
ip,
dry_run,
yes,
strict,
subdomains,
skip,
output,
continue_on_error,
production,
} => {
run_dns_set_all(
host,
ip,
dry_run,
yes,
strict,
subdomains,
skip,
output,
continue_on_error,
production,
)
.await
}
},
Commands::Config(cmd) => match cmd {
ConfigCommands::Init(args) => run_config_init(args),
ConfigCommands::Set { key, value } => run_config_set(key, value),
ConfigCommands::Get { key } => run_config_get(key),
ConfigCommands::List => run_config_list(),
ConfigCommands::Remove { key } => run_config_remove(key),
ConfigCommands::Edit => run_config_edit(),
ConfigCommands::Path => run_config_path(),
},
Commands::Bichon(cmd) => {
let code = run_bichon_command(cmd).await?;
if code != 0 {
std::process::exit(code);
}
Ok(())
}
Commands::Versions(cmd) => std::process::exit(run_versions(cmd).await),
Commands::Completions { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
"auberge",
&mut std::io::stdout(),
);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn generate_bash_script() -> String {
let mut buf = Vec::new();
clap_complete::generate(
clap_complete::Shell::Bash,
&mut Cli::command(),
"auberge",
&mut buf,
);
String::from_utf8(buf).expect("bash completion script is valid UTF-8")
}
#[test]
fn bash_completion_covers_subcommands() {
let script = generate_bash_script();
for subcommand in [
"deploy",
"select",
"ansible",
"backup",
"headscale",
"host",
"ssh",
"sync",
"dns",
"config",
"bichon",
"versions",
"completions",
] {
assert!(
script.contains(&format!("auberge,{subcommand})")),
"bash completion misses subcommand {subcommand}"
);
}
}
#[test]
fn bash_completion_covers_aliases() {
let script = generate_bash_script();
for alias in ["dp", "se", "a", "b", "hs", "h", "ss", "sy", "d", "c", "v"] {
assert!(
script.contains(&format!("auberge,{alias})")),
"bash completion misses alias {alias}"
);
}
for (parent, alias) in [("backup", "c"), ("ansible", "r"), ("dns", "sa")] {
assert!(
script.contains(&format!("__{parent},{alias})")),
"bash completion misses nested alias {parent} {alias}"
);
}
}
#[test]
fn bash_completion_covers_global_flags_and_output_enum() {
let script = generate_bash_script();
for flag in ["--verbose", "--quiet", "--no-color"] {
assert!(
script.contains(flag),
"bash completion misses global flag {flag}"
);
}
assert!(
script.contains("human json"),
"bash completion misses --output enum values"
);
}
}