use clap::{ArgMatches, Command, CommandFactory, FromArgMatches, Parser, Subcommand};
use commands::GlobalArgs;
#[derive(Debug, Clone, Copy)]
enum ResponseMode {
Json,
Raw(RawOutputMode),
}
#[derive(Debug, Clone, Copy)]
enum RawOutputMode {
InteractivePassthrough,
Markdown,
PlainText,
}
mod commands;
mod docs;
mod output;
mod tty;
use commands::utils::{args, entity_suggest};
use commands::{
api, audit, auth, build, changelog, changes, cli, component, config, db, deploy, extension,
file, fleet, git, init, lint, logs, project, refactor, release, server, ssh, status, test,
transfer, undo, upgrade, version,
};
use homeboy::extension::load_all_extensions;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
#[command(name = "homeboy")]
#[command(version = VERSION)]
#[command(about = "CLI tool for development and deployment automation")]
struct Cli {
#[arg(long, global = true, value_name = "PATH")]
output: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(visible_alias = "projects")]
Project(project::ProjectArgs),
Ssh(ssh::SshArgs),
#[command(visible_alias = "servers")]
Server(server::ServerArgs),
Test(test::TestArgs),
Lint(lint::LintArgs),
Db(db::DbArgs),
File(file::FileArgs),
#[command(visible_alias = "fleets")]
Fleet(fleet::FleetArgs),
Logs(logs::LogsArgs),
Transfer(transfer::TransferArgs),
Deploy(deploy::DeployArgs),
#[command(visible_alias = "components")]
Component(component::ComponentArgs),
Config(config::ConfigArgs),
#[command(visible_alias = "extensions")]
Extension(extension::ExtensionArgs),
#[command(hide = true)]
Init(init::InitArgs),
Status(status::StatusArgs),
Docs(crate::commands::docs::DocsArgs),
Changelog(changelog::ChangelogArgs),
Git(git::GitArgs),
Version(version::VersionArgs),
Build(build::BuildArgs),
Changes(changes::ChangesArgs),
Release(release::ReleaseArgs),
Audit(audit::AuditArgs),
Refactor(refactor::RefactorArgs),
Undo(undo::UndoArgs),
Auth(auth::AuthArgs),
Api(api::ApiArgs),
Upgrade(upgrade::UpgradeArgs),
Supports(crate::commands::supports::SupportsArgs),
#[command(hide = true)]
Update(upgrade::UpgradeArgs),
List,
}
fn response_mode(command: &Commands) -> ResponseMode {
match command {
Commands::Ssh(args) if args.subcommand.is_none() && args.command.is_empty() => {
ResponseMode::Raw(RawOutputMode::InteractivePassthrough)
}
Commands::Logs(args) if logs::is_interactive(args) => {
ResponseMode::Raw(RawOutputMode::InteractivePassthrough)
}
Commands::File(args) if file::is_raw_read(args) => {
ResponseMode::Raw(RawOutputMode::PlainText)
}
Commands::Docs(args) if crate::commands::docs::is_json_mode(args) => ResponseMode::Json,
Commands::Docs(_) => ResponseMode::Raw(RawOutputMode::Markdown),
Commands::Changelog(args) if changelog::is_show_markdown(args) => {
ResponseMode::Raw(RawOutputMode::Markdown)
}
Commands::List => ResponseMode::Raw(RawOutputMode::Markdown),
Commands::Supports(_) => ResponseMode::Json,
_ => ResponseMode::Json,
}
}
struct ExtensionCliCommand {
tool: String,
project_id: String,
args: Vec<String>,
}
struct ExtensionCliInfo {
tool: String,
display_name: String,
extension_name: String,
project_id_help: Option<String>,
args_help: Option<String>,
examples: Vec<String>,
}
fn collect_extension_cli_info() -> Vec<ExtensionCliInfo> {
load_all_extensions()
.unwrap_or_default()
.into_iter()
.filter_map(|m| {
m.cli.map(|cli| {
let help = cli.help.unwrap_or_default();
ExtensionCliInfo {
tool: cli.tool,
display_name: cli.display_name,
extension_name: m.name,
project_id_help: help.project_id_help,
args_help: help.args_help,
examples: help.examples,
}
})
})
.collect()
}
fn build_augmented_command(extension_info: &[ExtensionCliInfo]) -> Command {
let mut cmd = Cli::command();
for info in extension_info {
let project_id_help = info
.project_id_help
.clone()
.unwrap_or_else(|| "Project ID".to_string());
let args_help = info
.args_help
.clone()
.unwrap_or_else(|| "Command arguments".to_string());
let mut subcommand = Command::new(info.tool.clone())
.about(format!(
"Run {} commands via {}",
info.display_name, info.extension_name
))
.arg(
clap::Arg::new("project_id")
.help(project_id_help)
.required(true)
.index(1),
)
.arg(
clap::Arg::new("args")
.help(args_help)
.index(2)
.num_args(0..)
.allow_hyphen_values(true),
)
.trailing_var_arg(true);
if !info.examples.is_empty() {
let examples_text = format!("Examples:\n {}", info.examples.join("\n "));
subcommand = subcommand.after_help(examples_text);
}
cmd = cmd.subcommand(subcommand);
}
cmd
}
fn try_parse_extension_cli_command(
matches: &ArgMatches,
extension_info: &[ExtensionCliInfo],
) -> Option<ExtensionCliCommand> {
let (tool, sub_matches) = matches.subcommand()?;
if !extension_info.iter().any(|m| m.tool == tool) {
return None;
}
let project_id = sub_matches.get_one::<String>("project_id")?.clone();
let args: Vec<String> = sub_matches
.get_many::<String>("args")
.map(|vals| vals.cloned().collect())
.unwrap_or_default();
Some(ExtensionCliCommand {
tool: tool.to_string(),
project_id,
args,
})
}
fn main() -> std::process::ExitCode {
let extension_info = collect_extension_cli_info();
let cmd = build_augmented_command(&extension_info);
let args: Vec<String> = std::env::args().collect();
let normalized = args::normalize(args);
let matches = match cmd.try_get_matches_from(normalized) {
Ok(m) => m,
Err(e) => {
if let Some(output) = try_augment_clap_error(&e) {
eprintln!("{}", output);
return std::process::ExitCode::from(2);
}
e.exit();
}
};
let global = GlobalArgs {};
let output_file: Option<String> = matches.get_one::<String>("output").cloned();
if let Some(extension_cmd) = try_parse_extension_cli_command(&matches, &extension_info) {
let cli_args = cli::CliArgs {
tool: extension_cmd.tool,
identifier: extension_cmd.project_id,
args: extension_cmd.args,
};
let result = cli::run(cli_args, &global);
let (json_result, exit_code) = output::map_cmd_result_to_json(result);
if let Some(ref path) = output_file {
output::write_json_to_file(&json_result, path);
}
output::print_json_result(json_result).ok();
return std::process::ExitCode::from(exit_code_to_u8(exit_code));
}
let cli = match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
if !matches!(&cli.command, Commands::Upgrade(_) | Commands::Update(_)) {
homeboy::upgrade::update_check::run_startup_check();
homeboy::extension::update_check::run_startup_check();
}
let mode = response_mode(&cli.command);
match mode {
ResponseMode::Json => {}
ResponseMode::Raw(RawOutputMode::InteractivePassthrough) => {
if !tty::require_tty_for_interactive() {
let err = homeboy::Error::validation_invalid_argument(
"tty",
"This command requires an interactive TTY. For non-interactive usage, run: homeboy ssh <target> -- <command...>",
None,
None,
);
output::print_result::<serde_json::Value>(Err(err)).ok();
return std::process::ExitCode::from(exit_code_to_u8(2));
}
}
ResponseMode::Raw(RawOutputMode::Markdown) => {}
ResponseMode::Raw(RawOutputMode::PlainText) => {}
}
if matches!(cli.command, Commands::List) {
let mut cmd = build_augmented_command(&extension_info);
cmd.print_help().expect("Failed to print help");
println!();
return std::process::ExitCode::SUCCESS;
}
if let Commands::Changelog(ref args) = cli.command {
if args.command.is_none() && !args.show_self {
let cmd = build_augmented_command(&extension_info);
if let Some(mut changelog_cmd) = cmd.find_subcommand("changelog").cloned() {
changelog_cmd.print_help().expect("Failed to print help");
println!();
return std::process::ExitCode::SUCCESS;
}
}
}
if let ResponseMode::Raw(RawOutputMode::Markdown) = mode {
let markdown_result = commands::run_markdown(cli.command, &global);
match markdown_result {
Ok((content, exit_code)) => {
print!("{}", content);
return std::process::ExitCode::from(exit_code_to_u8(exit_code));
}
Err(err) => {
output::print_result::<serde_json::Value>(Err(err)).ok();
return std::process::ExitCode::from(exit_code_to_u8(1));
}
}
}
if let ResponseMode::Raw(RawOutputMode::PlainText) = mode {
if let crate::Commands::File(args) = cli.command {
let result = file::run(args, &global);
match result {
Ok((file::FileCommandOutput::Raw(content), exit_code)) => {
print!("{}", content);
return std::process::ExitCode::from(exit_code_to_u8(exit_code));
}
Ok(_) => {
let err =
homeboy::Error::internal_unexpected("Unexpected output type for raw mode");
output::print_result::<serde_json::Value>(Err(err)).ok();
return std::process::ExitCode::from(exit_code_to_u8(1));
}
Err(err) => {
output::print_result::<serde_json::Value>(Err(err)).ok();
return std::process::ExitCode::from(exit_code_to_u8(1));
}
}
}
}
let (json_result, exit_code) = commands::run_json(cli.command, &global);
if let Some(ref path) = output_file {
output::write_json_to_file(&json_result, path);
}
match mode {
ResponseMode::Json => {
output::print_json_result(json_result).ok();
}
ResponseMode::Raw(RawOutputMode::InteractivePassthrough) => {}
ResponseMode::Raw(RawOutputMode::Markdown) => {}
ResponseMode::Raw(RawOutputMode::PlainText) => {}
}
std::process::ExitCode::from(exit_code_to_u8(exit_code))
}
fn exit_code_to_u8(code: i32) -> u8 {
if code <= 0 {
0
} else if code >= 255 {
255
} else {
code as u8
}
}
fn try_augment_clap_error(e: &clap::Error) -> Option<String> {
use clap::error::ErrorKind;
if e.kind() != ErrorKind::InvalidSubcommand {
return None;
}
let unrecognized = extract_unrecognized_from_error(e)?;
let parent_command = extract_parent_command_from_error(e)?;
let entity_match = entity_suggest::find_entity_match(&unrecognized)?;
let hints =
entity_suggest::generate_entity_hints(&entity_match, &parent_command, &unrecognized);
let mut output = format!("error: unrecognized subcommand '{}'\n\n", unrecognized);
for hint in hints {
output.push_str(&format!("hint: {}\n", hint));
}
output.push_str(&format!(
"\nFor more information, try 'homeboy {} --help'",
parent_command
));
Some(output)
}
fn extract_unrecognized_from_error(e: &clap::Error) -> Option<String> {
use clap::error::ContextKind;
for (kind, value) in e.context() {
if matches!(kind, ContextKind::InvalidSubcommand) {
return Some(value.to_string());
}
}
let msg = e.to_string();
if let Some(start) = msg.find("unrecognized subcommand '") {
let rest = &msg[start + 25..];
if let Some(end) = rest.find('\'') {
return Some(rest[..end].to_string());
}
}
None
}
fn extract_parent_command_from_error(e: &clap::Error) -> Option<String> {
use clap::error::ContextKind;
for (kind, value) in e.context() {
if matches!(kind, ContextKind::Usage) {
let usage = value.to_string();
if let Some(rest) = usage.strip_prefix("Usage: homeboy ") {
if let Some(cmd) = rest.split_whitespace().next() {
if !cmd.starts_with('[') && !cmd.starts_with('<') {
return Some(cmd.to_string());
}
}
}
}
}
let msg = e.to_string();
if let Some(start) = msg.find("Usage: homeboy ") {
let rest = &msg[start + 15..];
if let Some(cmd) = rest.split_whitespace().next() {
if !cmd.starts_with('[') && !cmd.starts_with('<') {
return Some(cmd.to_string());
}
}
}
None
}