pub mod app;
pub mod auto_commit;
pub mod commands;
pub mod error;
pub mod features;
pub mod handlers;
pub mod router;
pub mod ui;
pub use handlers::*;
use crate::cli::app::AppContext;
use crate::cli::error::{CliResult, ErrorFactory};
use crate::commands::curl;
use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
use crate::commands::redeploy_v2::run_redeploy_v2;
use crate::commands::{
install_package, list_services, open_global_config, run_commit, run_config,
run_config_linear_select_initiative, run_config_secret_delete, run_config_secret_set,
run_config_secret_show, run_generate_config, run_init, run_login, run_redeploy,
run_redeploy_service, run_service_command, run_setup, run_version_command,
run_version_release_command, show_service_help, CommitArgs, GenerateConfigArgs,
ReleaseLatestPolicy, VersionReleaseOptions,
};
use crate::commands::{run_diag, run_nginx};
use crate::config::sync_versioning_files_registry;
use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
use clap::{error::ErrorKind as ClapErrorKind, CommandFactory, Parser};
use colored::Colorize;
use commands::Cli;
pub async fn run() -> CliResult<()> {
let cli: Cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
let kind = err.kind();
let rendered = err.to_string();
let _ = err.print();
if matches!(
kind,
ClapErrorKind::DisplayHelp
| ClapErrorKind::DisplayVersion
| ClapErrorKind::MissingSubcommand
) || rendered.contains("Manage the XBP API server")
{
return Ok(());
}
return Err(ErrorFactory::clap_parse(err));
}
};
if should_print_help(&cli) {
let mut cmd = Cli::command();
let _ = cmd.print_help();
println!();
return Ok(());
}
let debug: bool = cli.debug;
ui::configure_color_output();
let command_name = cli
.command
.as_ref()
.map(commands::command_label)
.unwrap_or("interactive");
ui::print_cli_header(command_name, debug);
if let Err(e) = init_logger(debug).await {
let _ = log_error(
"system",
"Failed to initialize logger",
Some(&e.to_string()),
)
.await;
}
if let Err(e) = sync_versioning_files_registry() {
let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
}
let mut ctx = AppContext::new(debug);
router::dispatch(cli, &mut ctx).await
}
pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
if let Err(e) = run_init(debug).await {
let _ = log_error("init", "Init failed", Some(&e)).await;
return Err(e.into());
}
Ok(())
}
pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
let args = CommitArgs {
dry_run: cmd.dry_run,
no_ai: cmd.no_ai,
model: cmd.model,
scope: cmd.scope,
};
if let Err(e) = run_commit(args).await {
let _ = log_error("commit", "Commit command failed", Some(&e)).await;
return Err(ErrorFactory::operation(
"commit",
"create conventional commit",
e,
Some("Run `xbp commit --dry-run` to inspect the generated message first."),
));
}
Ok(())
}
pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
let _ = log_error("setup", "Setup failed", Some(&e)).await;
return Err(ErrorFactory::operation(
"setup",
"setup environment",
e,
Some("Run with `--debug` for command-level output."),
));
}
Ok(())
}
pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
if let Some(name) = service_name {
if let Err(e) = ui::with_loader(
&format!("Redeploying service `{}`", name),
run_redeploy_service(&name, debug),
)
.await
{
let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
return Err(ErrorFactory::operation(
"redeploy",
&format!("redeploy service `{}`", name),
e,
Some("Verify service name with `xbp services`."),
));
}
} else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
return Err(ErrorFactory::operation(
"redeploy",
"redeploy project",
e,
Some("Try `xbp redeploy <service>` for scoped retries."),
));
}
Ok(())
}
pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
Ok(()) => Ok(()),
Err(e) => {
let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
Err(e.into())
}
}
}
pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
let commands::ConfigCmd {
project,
no_open,
provider,
} = cmd;
if provider.is_some() && (project || no_open) {
return Err(ErrorFactory::validation(
"config",
"`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
Some("Run either provider key management OR project/global config actions."),
));
}
if let Some(provider_cmd) = provider {
match provider_cmd {
commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
commands::ConfigSecretAction::SetKey { key } => {
if let Err(e) = run_config_secret_set("openrouter", key).await {
let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"set OpenRouter key",
e,
Some("Run `xbp config openrouter show` to confirm key state."),
));
}
}
commands::ConfigSecretAction::DeleteKey => {
if let Err(e) = run_config_secret_delete("openrouter").await {
let _ =
log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"delete OpenRouter key",
e,
Some("Use `xbp config openrouter show` to verify removal."),
));
}
}
commands::ConfigSecretAction::Show { raw } => {
if let Err(e) = run_config_secret_show("openrouter", raw).await {
let _ =
log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"show OpenRouter key",
e,
None,
));
}
}
},
commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
commands::ConfigSecretAction::SetKey { key } => {
if let Err(e) = run_config_secret_set("github", key).await {
let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"set GitHub token",
e,
Some("Use a token with repo scope for private repos."),
));
}
}
commands::ConfigSecretAction::DeleteKey => {
if let Err(e) = run_config_secret_delete("github").await {
let _ =
log_error("config", "Failed to delete GitHub token", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"delete GitHub token",
e,
None,
));
}
}
commands::ConfigSecretAction::Show { raw } => {
if let Err(e) = run_config_secret_show("github", raw).await {
let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"show GitHub token",
e,
None,
));
}
}
},
commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
commands::LinearConfigAction::SetKey { key } => {
if let Err(e) = run_config_secret_set("linear", key).await {
let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"set Linear API key",
e,
Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
));
}
}
commands::LinearConfigAction::DeleteKey => {
if let Err(e) = run_config_secret_delete("linear").await {
let _ =
log_error("config", "Failed to delete Linear API key", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"delete Linear API key",
e,
None,
));
}
}
commands::LinearConfigAction::Show { raw } => {
if let Err(e) = run_config_secret_show("linear", raw).await {
let _ =
log_error("config", "Failed to show Linear API key", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"show Linear API key",
e,
None,
));
}
}
commands::LinearConfigAction::SelectInitiative => {
if let Err(e) = run_config_linear_select_initiative().await {
let _ = log_error(
"config",
"Failed to select repo Linear initiative",
Some(&e),
)
.await;
return Err(ErrorFactory::operation(
"config",
"select repo Linear initiative",
e,
Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
));
}
}
},
}
} else if project {
if let Err(e) = run_config(debug).await {
return Err(ErrorFactory::operation(
"config",
"read project config",
e,
Some("Ensure you're inside an XBP project root."),
));
}
} else if let Err(e) = open_global_config(no_open).await {
let _ = log_error("config", "Failed to open global config", Some(&e)).await;
return Err(ErrorFactory::operation(
"config",
"open global config",
e,
None,
));
}
Ok(())
}
pub(super) async fn handle_install(
package: Option<String>,
list: bool,
debug: bool,
) -> CliResult<()> {
if list {
crate::commands::print_install_targets_help();
return Ok(());
}
let Some(package) = package else {
crate::commands::print_install_empty_state();
return Ok(());
};
if package.trim().is_empty() {
crate::commands::print_install_empty_state();
return Ok(());
}
if crate::commands::is_install_listing_request(&package) {
crate::commands::print_install_targets_help();
return Ok(());
}
let install_msg: String = format!("Installing package: {}", package);
let _ = log_info("install", &install_msg, None).await;
match ui::with_loader(
&format!("Installing package `{}`", package),
install_package(&package, debug),
)
.await
{
Ok(()) => {
let success_msg = format!("Successfully installed: {}", package);
let _ = log_success("install", &success_msg, None).await;
Ok(())
}
Err(e) => Err(e.into()),
}
}
pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
let url = cmd
.url
.unwrap_or_else(|| "https://example.com/api".to_string());
if let Err(e) = ui::with_loader(
&format!("Requesting {}", url),
curl::run_curl(&url, cmd.no_timeout, debug),
)
.await
{
let _ = log_error("curl", "Curl command failed", Some(&e)).await;
return Err(ErrorFactory::operation(
"curl",
"execute request",
e,
Some("Double-check the URL and network connectivity."),
));
}
Ok(())
}
pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
let _ = log_error("services", "Failed to list services", Some(&e)).await;
return Err(ErrorFactory::operation(
"services",
"list services",
e,
Some("Ensure xbp config is present and valid."),
));
}
Ok(())
}
pub(super) async fn handle_service(
command: Option<String>,
service_name: Option<String>,
debug: bool,
) -> CliResult<()> {
if let Some(cmd) = command {
if cmd == "--help" || cmd == "help" {
if let Some(name) = service_name {
if let Err(e) = show_service_help(&name).await {
let _ = log_error("service", "Failed to show service help", Some(&e)).await;
return Err(ErrorFactory::operation(
"service",
"show service help",
e,
None,
));
}
} else {
print_service_usage();
}
} else if let Some(name) = service_name {
if let Err(e) = run_service_command(&cmd, &name, debug).await {
let _ = log_error(
"service",
&format!("Service command '{}' failed", cmd),
Some(&e),
)
.await;
return Err(ErrorFactory::operation(
"service",
&format!("run `{}` for `{}`", cmd, name),
e,
Some("Check available services via `xbp services`."),
));
}
} else {
let _ = log_error("service", "Service name required", None).await;
return Err(ErrorFactory::validation(
"service",
"Service name required.",
Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
));
}
} else {
print_service_usage();
}
Ok(())
}
pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
if let Err(e) = run_nginx(cmd, debug).await {
let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
return Err(ErrorFactory::operation(
"nginx",
"execute nginx command",
e.to_string(),
Some("Try `xbp nginx --help` for command syntax."),
));
}
Ok(())
}
pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
return Err(ErrorFactory::operation(
"diag",
"run diagnostics",
e.to_string(),
Some("Re-run with `--debug` for more context."),
));
}
Ok(())
}
pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
match cmd.command {
commands::GenerateSubCommand::Config(subcmd) => {
let args = GenerateConfigArgs {
force: subcmd.force,
update: subcmd.update,
from_json: subcmd.from_json,
};
if let Err(e) = run_generate_config(args, debug).await {
let _ = log_error(
"generate-config",
"Failed to generate project config",
Some(&e),
)
.await;
return Err(ErrorFactory::operation(
"generate-config",
"generate .xbp/xbp.yaml",
e,
Some("Use --update to refresh an existing config or --force to overwrite it."),
));
}
}
commands::GenerateSubCommand::Systemd(subcmd) => {
let args = GenerateSystemdArgs {
output_dir: subcmd.output_dir,
service: subcmd.service,
api: subcmd.api,
};
if let Err(e) = run_generate_systemd(args, debug).await {
let _ = log_error(
"generate-systemd",
"Failed to generate systemd units",
Some(&e),
)
.await;
return Err(ErrorFactory::operation(
"generate-systemd",
"generate unit files",
e,
Some("Use a writable `--output-dir` or run with elevated permissions."),
));
}
}
}
Ok(())
}
pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
if let Err(e) = crate::commands::run_done(
cmd.root,
cmd.since,
cmd.output,
cmd.no_ai,
cmd.recursive,
cmd.exclude,
)
.await
{
let _ = log_error("done", "Done command failed", Some(&e)).await;
return Err(e.into());
}
Ok(())
}
pub(super) async fn handle_login() -> CliResult<()> {
if let Err(e) = run_login().await {
let _ = log_error("login", "Login failed", Some(&e)).await;
return Err(e.into());
}
Ok(())
}
pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
let commands::VersionCmd {
target,
git,
command,
} = cmd;
if command.is_some() && (target.is_some() || git) {
return Err(ErrorFactory::validation(
"version",
"`xbp version release` cannot be combined with `--git` or positional targets.",
Some("Run `xbp version release` as a standalone command."),
));
}
if let Some(subcommand) = command {
match subcommand {
commands::VersionSubCommand::Release(release_cmd) => {
let options = VersionReleaseOptions {
explicit_version: release_cmd.version,
allow_dirty: release_cmd.allow_dirty,
title: release_cmd.title,
notes: release_cmd.notes,
notes_file: release_cmd.notes_file,
draft: release_cmd.draft,
prerelease: release_cmd.prerelease,
latest_policy: match release_cmd.make_latest {
commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
},
};
if let Err(e) = run_version_release_command(options).await {
return Err(ErrorFactory::operation(
"version",
"release version",
e,
Some("Use `--allow-dirty` if only generated files changed."),
));
}
return Ok(());
}
}
}
if let Err(e) = run_version_command(target, git, debug).await {
return Err(ErrorFactory::operation(
"version",
"run version command",
e,
Some("Run `xbp version -h` to inspect supported usage."),
));
}
Ok(())
}
fn should_print_help(cli: &Cli) -> bool {
cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
}
fn print_service_usage() {
println!(
"\n{} {}",
"Usage:".bright_blue().bold(),
"xbp service <command> <service-name>".bright_white()
);
println!("{} build, install, start, dev", "Commands:".bright_blue(),);
println!("{} xbp service build zeus", "Example:".bright_blue());
println!(
"{} xbp service --help <service-name>",
"Tip:".bright_yellow().bold(),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
#[test]
fn plain_cli_invocation_prints_help() {
let cli = Cli::try_parse_from(["xbp"]).expect("parse");
assert!(should_print_help(&cli));
}
#[test]
fn list_flag_skips_help_short_circuit() {
let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
assert!(!should_print_help(&cli));
}
#[test]
fn install_without_package_parses_and_is_optional() {
let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
let Some(Commands::Install { list, package }) = cli.command else {
panic!("expected install command");
};
assert!(!list);
assert!(package.is_none());
}
#[test]
fn install_with_package_still_parses() {
let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
let Some(Commands::Install { list, package }) = cli.command else {
panic!("expected install command");
};
assert!(!list);
assert_eq!(package.as_deref(), Some("docker"));
}
#[test]
fn install_list_flag_parses() {
let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
let Some(Commands::Install { list, package }) = cli.command else {
panic!("expected install command");
};
assert!(list);
assert!(package.is_none());
}
#[test]
fn install_short_list_flag_parses() {
let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
let Some(Commands::Install { list, package }) = cli.command else {
panic!("expected install command");
};
assert!(list);
assert!(package.is_none());
}
#[test]
fn install_ls_alias_parses_as_package() {
let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
let Some(Commands::Install { list, package }) = cli.command else {
panic!("expected install command");
};
assert!(!list);
assert_eq!(package.as_deref(), Some("ls"));
}
#[test]
fn version_release_make_latest_parses_explicit_value() {
let cli = Cli::try_parse_from([
"xbp",
"version",
"release",
"--version",
"1.2.3",
"--make-latest",
"false",
])
.expect("parse");
let Some(Commands::Version(version_cmd)) = cli.command else {
panic!("expected version command");
};
let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
panic!("expected version release subcommand");
};
assert!(matches!(
release_cmd.make_latest,
VersionReleaseLatest::False
));
}
#[test]
fn version_release_make_latest_defaults_to_legacy() {
let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
.expect("parse");
let Some(Commands::Version(version_cmd)) = cli.command else {
panic!("expected version command");
};
let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
panic!("expected version release subcommand");
};
assert!(matches!(
release_cmd.make_latest,
VersionReleaseLatest::Legacy
));
}
}