1pub mod app;
2pub mod commands;
3pub mod error;
4pub mod features;
5pub mod handlers;
6pub mod router;
7pub mod ui;
8
9pub use handlers::*;
10
11use crate::cli::app::AppContext;
12use crate::cli::error::{CliResult, ErrorFactory};
13use crate::commands::curl;
14use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
15use crate::commands::redeploy_v2::run_redeploy_v2;
16use crate::commands::{
17 install_package, list_services, open_global_config, run_config, run_config_secret_delete,
18 run_config_secret_set, run_config_secret_show, run_generate_config, run_init, run_login,
19 run_redeploy, run_redeploy_service, run_service_command, run_setup, run_version_command,
20 run_version_release_command, show_service_help, GenerateConfigArgs, ReleaseLatestPolicy,
21 VersionReleaseOptions,
22};
23use crate::commands::{run_diag, run_nginx};
24use crate::config::sync_versioning_files_registry;
25use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
26use clap::{error::ErrorKind as ClapErrorKind, CommandFactory, Parser};
27use colored::Colorize;
28use commands::Cli;
29
30pub async fn run() -> CliResult<()> {
31 let cli: Cli = match Cli::try_parse() {
32 Ok(cli) => cli,
33 Err(err) => {
34 let kind = err.kind();
35 let rendered = err.to_string();
36 let _ = err.print();
37 if matches!(
38 kind,
39 ClapErrorKind::DisplayHelp
40 | ClapErrorKind::DisplayVersion
41 | ClapErrorKind::MissingSubcommand
42 ) || rendered.contains("Manage the XBP API server")
43 {
44 return Ok(());
45 }
46 return Err(ErrorFactory::clap_parse(err));
47 }
48 };
49
50 if should_print_help(&cli) {
53 let mut cmd = Cli::command();
54 let _ = cmd.print_help();
55 println!();
56 return Ok(());
57 }
58
59 let debug: bool = cli.debug;
60 ui::configure_color_output();
61 let command_name = cli
62 .command
63 .as_ref()
64 .map(commands::command_label)
65 .unwrap_or("interactive");
66 ui::print_cli_header(command_name, debug);
67
68 if let Err(e) = init_logger(debug).await {
69 let _ = log_error(
70 "system",
71 "Failed to initialize logger",
72 Some(&e.to_string()),
73 )
74 .await;
75 }
76 if let Err(e) = sync_versioning_files_registry() {
77 let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
78 }
79
80 let mut ctx = AppContext::new(debug);
81 router::dispatch(cli, &mut ctx).await
82}
83
84pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
85 if let Err(e) = run_init(debug).await {
86 let _ = log_error("init", "Init failed", Some(&e)).await;
87 return Err(e.into());
88 }
89 Ok(())
90}
91
92pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
93 if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
94 let _ = log_error("setup", "Setup failed", Some(&e)).await;
95 return Err(ErrorFactory::operation(
96 "setup",
97 "setup environment",
98 e,
99 Some("Run with `--debug` for command-level output."),
100 ));
101 }
102 Ok(())
103}
104
105pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
106 if let Some(name) = service_name {
107 if let Err(e) = ui::with_loader(
108 &format!("Redeploying service `{}`", name),
109 run_redeploy_service(&name, debug),
110 )
111 .await
112 {
113 let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
114 return Err(ErrorFactory::operation(
115 "redeploy",
116 &format!("redeploy service `{}`", name),
117 e,
118 Some("Verify service name with `xbp services`."),
119 ));
120 }
121 } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
122 let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
123 return Err(ErrorFactory::operation(
124 "redeploy",
125 "redeploy project",
126 e,
127 Some("Try `xbp redeploy <service>` for scoped retries."),
128 ));
129 }
130 Ok(())
131}
132
133pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
134 let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
135 match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
136 Ok(()) => Ok(()),
137 Err(e) => {
138 let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
139 Err(e.into())
140 }
141 }
142}
143
144pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
145 let commands::ConfigCmd {
146 project,
147 no_open,
148 provider,
149 } = cmd;
150
151 if provider.is_some() && (project || no_open) {
152 return Err(ErrorFactory::validation(
153 "config",
154 "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
155 Some("Run either provider key management OR project/global config actions."),
156 ));
157 }
158
159 if let Some(provider_cmd) = provider {
160 match provider_cmd {
161 commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
162 commands::ConfigSecretAction::SetKey { key } => {
163 if let Err(e) = run_config_secret_set("openrouter", key).await {
164 let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
165 return Err(ErrorFactory::operation(
166 "config",
167 "set OpenRouter key",
168 e,
169 Some("Run `xbp config openrouter show` to confirm key state."),
170 ));
171 }
172 }
173 commands::ConfigSecretAction::DeleteKey => {
174 if let Err(e) = run_config_secret_delete("openrouter").await {
175 let _ =
176 log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
177 return Err(ErrorFactory::operation(
178 "config",
179 "delete OpenRouter key",
180 e,
181 Some("Use `xbp config openrouter show` to verify removal."),
182 ));
183 }
184 }
185 commands::ConfigSecretAction::Show { raw } => {
186 if let Err(e) = run_config_secret_show("openrouter", raw).await {
187 let _ =
188 log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
189 return Err(ErrorFactory::operation(
190 "config",
191 "show OpenRouter key",
192 e,
193 None,
194 ));
195 }
196 }
197 },
198 commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
199 commands::ConfigSecretAction::SetKey { key } => {
200 if let Err(e) = run_config_secret_set("github", key).await {
201 let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
202 return Err(ErrorFactory::operation(
203 "config",
204 "set GitHub token",
205 e,
206 Some("Use a token with repo scope for private repos."),
207 ));
208 }
209 }
210 commands::ConfigSecretAction::DeleteKey => {
211 if let Err(e) = run_config_secret_delete("github").await {
212 let _ =
213 log_error("config", "Failed to delete GitHub token", Some(&e)).await;
214 return Err(ErrorFactory::operation(
215 "config",
216 "delete GitHub token",
217 e,
218 None,
219 ));
220 }
221 }
222 commands::ConfigSecretAction::Show { raw } => {
223 if let Err(e) = run_config_secret_show("github", raw).await {
224 let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
225 return Err(ErrorFactory::operation(
226 "config",
227 "show GitHub token",
228 e,
229 None,
230 ));
231 }
232 }
233 },
234 }
235 } else if project {
236 if let Err(e) = run_config(debug).await {
237 return Err(ErrorFactory::operation(
238 "config",
239 "read project config",
240 e,
241 Some("Ensure you're inside an XBP project root."),
242 ));
243 }
244 } else if let Err(e) = open_global_config(no_open).await {
245 let _ = log_error("config", "Failed to open global config", Some(&e)).await;
246 return Err(ErrorFactory::operation(
247 "config",
248 "open global config",
249 e,
250 None,
251 ));
252 }
253 Ok(())
254}
255
256pub(super) async fn handle_install(package: Option<String>, debug: bool) -> CliResult<()> {
257 let Some(package) = package else {
258 crate::commands::print_install_targets_help();
259 return Ok(());
260 };
261
262 if package.is_empty() || package == "--help" || package == "help" {
263 crate::commands::print_install_targets_help();
264 return Ok(());
265 }
266
267 let install_msg: String = format!("Installing package: {}", package);
268 let _ = log_info("install", &install_msg, None).await;
269 match ui::with_loader(
270 &format!("Installing package `{}`", package),
271 install_package(&package, debug),
272 )
273 .await
274 {
275 Ok(()) => {
276 let success_msg = format!("Successfully installed: {}", package);
277 let _ = log_success("install", &success_msg, None).await;
278 Ok(())
279 }
280 Err(e) => Err(e.into()),
281 }
282}
283
284pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
285 let url = cmd
286 .url
287 .unwrap_or_else(|| "https://example.com/api".to_string());
288 if let Err(e) = ui::with_loader(
289 &format!("Requesting {}", url),
290 curl::run_curl(&url, cmd.no_timeout, debug),
291 )
292 .await
293 {
294 let _ = log_error("curl", "Curl command failed", Some(&e)).await;
295 return Err(ErrorFactory::operation(
296 "curl",
297 "execute request",
298 e,
299 Some("Double-check the URL and network connectivity."),
300 ));
301 }
302 Ok(())
303}
304
305pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
306 if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
307 let _ = log_error("services", "Failed to list services", Some(&e)).await;
308 return Err(ErrorFactory::operation(
309 "services",
310 "list services",
311 e,
312 Some("Ensure xbp config is present and valid."),
313 ));
314 }
315 Ok(())
316}
317
318pub(super) async fn handle_service(
319 command: Option<String>,
320 service_name: Option<String>,
321 debug: bool,
322) -> CliResult<()> {
323 if let Some(cmd) = command {
324 if cmd == "--help" || cmd == "help" {
325 if let Some(name) = service_name {
326 if let Err(e) = show_service_help(&name).await {
327 let _ = log_error("service", "Failed to show service help", Some(&e)).await;
328 return Err(ErrorFactory::operation(
329 "service",
330 "show service help",
331 e,
332 None,
333 ));
334 }
335 } else {
336 print_service_usage();
337 }
338 } else if let Some(name) = service_name {
339 if let Err(e) = run_service_command(&cmd, &name, debug).await {
340 let _ = log_error(
341 "service",
342 &format!("Service command '{}' failed", cmd),
343 Some(&e),
344 )
345 .await;
346 return Err(ErrorFactory::operation(
347 "service",
348 &format!("run `{}` for `{}`", cmd, name),
349 e,
350 Some("Check available services via `xbp services`."),
351 ));
352 }
353 } else {
354 let _ = log_error("service", "Service name required", None).await;
355 return Err(ErrorFactory::validation(
356 "service",
357 "Service name required.",
358 Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
359 ));
360 }
361 } else {
362 print_service_usage();
363 }
364 Ok(())
365}
366
367pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
368 if let Err(e) = run_nginx(cmd, debug).await {
369 let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
370 return Err(ErrorFactory::operation(
371 "nginx",
372 "execute nginx command",
373 e.to_string(),
374 Some("Try `xbp nginx --help` for command syntax."),
375 ));
376 }
377 Ok(())
378}
379
380pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
381 if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
382 let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
383 return Err(ErrorFactory::operation(
384 "diag",
385 "run diagnostics",
386 e.to_string(),
387 Some("Re-run with `--debug` for more context."),
388 ));
389 }
390 Ok(())
391}
392
393pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
394 match cmd.command {
395 commands::GenerateSubCommand::Config(subcmd) => {
396 let args = GenerateConfigArgs {
397 force: subcmd.force,
398 update: subcmd.update,
399 from_json: subcmd.from_json,
400 };
401 if let Err(e) = run_generate_config(args, debug).await {
402 let _ = log_error(
403 "generate-config",
404 "Failed to generate project config",
405 Some(&e),
406 )
407 .await;
408 return Err(ErrorFactory::operation(
409 "generate-config",
410 "generate .xbp/xbp.yaml",
411 e,
412 Some("Use --update to refresh an existing config or --force to overwrite it."),
413 ));
414 }
415 }
416 commands::GenerateSubCommand::Systemd(subcmd) => {
417 let args = GenerateSystemdArgs {
418 output_dir: subcmd.output_dir,
419 service: subcmd.service,
420 api: subcmd.api,
421 };
422 if let Err(e) = run_generate_systemd(args, debug).await {
423 let _ = log_error(
424 "generate-systemd",
425 "Failed to generate systemd units",
426 Some(&e),
427 )
428 .await;
429 return Err(ErrorFactory::operation(
430 "generate-systemd",
431 "generate unit files",
432 e,
433 Some("Use a writable `--output-dir` or run with elevated permissions."),
434 ));
435 }
436 }
437 }
438 Ok(())
439}
440
441pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
442 if let Err(e) = crate::commands::run_done(
443 cmd.root,
444 cmd.since,
445 cmd.output,
446 cmd.no_ai,
447 cmd.recursive,
448 cmd.exclude,
449 )
450 .await
451 {
452 let _ = log_error("done", "Done command failed", Some(&e)).await;
453 return Err(e.into());
454 }
455 Ok(())
456}
457
458pub(super) async fn handle_login() -> CliResult<()> {
459 if let Err(e) = run_login().await {
460 let _ = log_error("login", "Login failed", Some(&e)).await;
461 return Err(e.into());
462 }
463 Ok(())
464}
465
466pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
467 let commands::VersionCmd {
468 target,
469 git,
470 command,
471 } = cmd;
472
473 if command.is_some() && (target.is_some() || git) {
474 return Err(ErrorFactory::validation(
475 "version",
476 "`xbp version release` cannot be combined with `--git` or positional targets.",
477 Some("Run `xbp version release` as a standalone command."),
478 ));
479 }
480
481 if let Some(subcommand) = command {
482 match subcommand {
483 commands::VersionSubCommand::Release(release_cmd) => {
484 let options = VersionReleaseOptions {
485 explicit_version: release_cmd.version,
486 allow_dirty: release_cmd.allow_dirty,
487 title: release_cmd.title,
488 notes: release_cmd.notes,
489 notes_file: release_cmd.notes_file,
490 draft: release_cmd.draft,
491 prerelease: release_cmd.prerelease,
492 latest_policy: match release_cmd.make_latest {
493 commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
494 commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
495 commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
496 },
497 };
498 if let Err(e) = run_version_release_command(options).await {
499 return Err(ErrorFactory::operation(
500 "version",
501 "release version",
502 e,
503 Some("Use `--allow-dirty` if only generated files changed."),
504 ));
505 }
506 return Ok(());
507 }
508 }
509 }
510
511 if let Err(e) = run_version_command(target, git, debug).await {
512 return Err(ErrorFactory::operation(
513 "version",
514 "run version command",
515 e,
516 Some("Run `xbp version -h` to inspect supported usage."),
517 ));
518 }
519 Ok(())
520}
521
522fn should_print_help(cli: &Cli) -> bool {
523 cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
524}
525
526fn print_service_usage() {
527 println!(
528 "\n{} {}",
529 "Usage:".bright_blue().bold(),
530 "xbp service <command> <service-name>".bright_white()
531 );
532 println!("{} build, install, start, dev", "Commands:".bright_blue(),);
533 println!("{} xbp service build zeus", "Example:".bright_blue());
534 println!(
535 "{} xbp service --help <service-name>",
536 "Tip:".bright_yellow().bold(),
537 );
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
544
545 #[test]
546 fn plain_cli_invocation_prints_help() {
547 let cli = Cli::try_parse_from(["xbp"]).expect("parse");
548 assert!(should_print_help(&cli));
549 }
550
551 #[test]
552 fn list_flag_skips_help_short_circuit() {
553 let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
554 assert!(!should_print_help(&cli));
555 }
556
557 #[test]
558 fn install_without_package_parses_and_is_optional() {
559 let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
560 let Some(Commands::Install { package }) = cli.command else {
561 panic!("expected install command");
562 };
563 assert!(package.is_none());
564 }
565
566 #[test]
567 fn install_with_package_still_parses() {
568 let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
569 let Some(Commands::Install { package }) = cli.command else {
570 panic!("expected install command");
571 };
572 assert_eq!(package.as_deref(), Some("docker"));
573 }
574
575 #[test]
576 fn version_release_make_latest_parses_explicit_value() {
577 let cli = Cli::try_parse_from([
578 "xbp",
579 "version",
580 "release",
581 "--version",
582 "1.2.3",
583 "--make-latest",
584 "false",
585 ])
586 .expect("parse");
587 let Some(Commands::Version(version_cmd)) = cli.command else {
588 panic!("expected version command");
589 };
590 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
591 panic!("expected version release subcommand");
592 };
593 assert!(matches!(
594 release_cmd.make_latest,
595 VersionReleaseLatest::False
596 ));
597 }
598
599 #[test]
600 fn version_release_make_latest_defaults_to_legacy() {
601 let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
602 .expect("parse");
603 let Some(Commands::Version(version_cmd)) = cli.command else {
604 panic!("expected version command");
605 };
606 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
607 panic!("expected version release subcommand");
608 };
609 assert!(matches!(
610 release_cmd.make_latest,
611 VersionReleaseLatest::Legacy
612 ));
613 }
614}