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 commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
235 commands::ConfigSecretAction::SetKey { key } => {
236 if let Err(e) = run_config_secret_set("linear", key).await {
237 let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
238 return Err(ErrorFactory::operation(
239 "config",
240 "set Linear API key",
241 e,
242 Some("Use this to link Linear issue IDs in generated release notes."),
243 ));
244 }
245 }
246 commands::ConfigSecretAction::DeleteKey => {
247 if let Err(e) = run_config_secret_delete("linear").await {
248 let _ =
249 log_error("config", "Failed to delete Linear API key", Some(&e)).await;
250 return Err(ErrorFactory::operation(
251 "config",
252 "delete Linear API key",
253 e,
254 None,
255 ));
256 }
257 }
258 commands::ConfigSecretAction::Show { raw } => {
259 if let Err(e) = run_config_secret_show("linear", raw).await {
260 let _ =
261 log_error("config", "Failed to show Linear API key", Some(&e)).await;
262 return Err(ErrorFactory::operation(
263 "config",
264 "show Linear API key",
265 e,
266 None,
267 ));
268 }
269 }
270 },
271 }
272 } else if project {
273 if let Err(e) = run_config(debug).await {
274 return Err(ErrorFactory::operation(
275 "config",
276 "read project config",
277 e,
278 Some("Ensure you're inside an XBP project root."),
279 ));
280 }
281 } else if let Err(e) = open_global_config(no_open).await {
282 let _ = log_error("config", "Failed to open global config", Some(&e)).await;
283 return Err(ErrorFactory::operation(
284 "config",
285 "open global config",
286 e,
287 None,
288 ));
289 }
290 Ok(())
291}
292
293pub(super) async fn handle_install(package: Option<String>, debug: bool) -> CliResult<()> {
294 let Some(package) = package else {
295 crate::commands::print_install_targets_help();
296 return Ok(());
297 };
298
299 if package.is_empty() || package == "--help" || package == "help" {
300 crate::commands::print_install_targets_help();
301 return Ok(());
302 }
303
304 let install_msg: String = format!("Installing package: {}", package);
305 let _ = log_info("install", &install_msg, None).await;
306 match ui::with_loader(
307 &format!("Installing package `{}`", package),
308 install_package(&package, debug),
309 )
310 .await
311 {
312 Ok(()) => {
313 let success_msg = format!("Successfully installed: {}", package);
314 let _ = log_success("install", &success_msg, None).await;
315 Ok(())
316 }
317 Err(e) => Err(e.into()),
318 }
319}
320
321pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
322 let url = cmd
323 .url
324 .unwrap_or_else(|| "https://example.com/api".to_string());
325 if let Err(e) = ui::with_loader(
326 &format!("Requesting {}", url),
327 curl::run_curl(&url, cmd.no_timeout, debug),
328 )
329 .await
330 {
331 let _ = log_error("curl", "Curl command failed", Some(&e)).await;
332 return Err(ErrorFactory::operation(
333 "curl",
334 "execute request",
335 e,
336 Some("Double-check the URL and network connectivity."),
337 ));
338 }
339 Ok(())
340}
341
342pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
343 if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
344 let _ = log_error("services", "Failed to list services", Some(&e)).await;
345 return Err(ErrorFactory::operation(
346 "services",
347 "list services",
348 e,
349 Some("Ensure xbp config is present and valid."),
350 ));
351 }
352 Ok(())
353}
354
355pub(super) async fn handle_service(
356 command: Option<String>,
357 service_name: Option<String>,
358 debug: bool,
359) -> CliResult<()> {
360 if let Some(cmd) = command {
361 if cmd == "--help" || cmd == "help" {
362 if let Some(name) = service_name {
363 if let Err(e) = show_service_help(&name).await {
364 let _ = log_error("service", "Failed to show service help", Some(&e)).await;
365 return Err(ErrorFactory::operation(
366 "service",
367 "show service help",
368 e,
369 None,
370 ));
371 }
372 } else {
373 print_service_usage();
374 }
375 } else if let Some(name) = service_name {
376 if let Err(e) = run_service_command(&cmd, &name, debug).await {
377 let _ = log_error(
378 "service",
379 &format!("Service command '{}' failed", cmd),
380 Some(&e),
381 )
382 .await;
383 return Err(ErrorFactory::operation(
384 "service",
385 &format!("run `{}` for `{}`", cmd, name),
386 e,
387 Some("Check available services via `xbp services`."),
388 ));
389 }
390 } else {
391 let _ = log_error("service", "Service name required", None).await;
392 return Err(ErrorFactory::validation(
393 "service",
394 "Service name required.",
395 Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
396 ));
397 }
398 } else {
399 print_service_usage();
400 }
401 Ok(())
402}
403
404pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
405 if let Err(e) = run_nginx(cmd, debug).await {
406 let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
407 return Err(ErrorFactory::operation(
408 "nginx",
409 "execute nginx command",
410 e.to_string(),
411 Some("Try `xbp nginx --help` for command syntax."),
412 ));
413 }
414 Ok(())
415}
416
417pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
418 if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
419 let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
420 return Err(ErrorFactory::operation(
421 "diag",
422 "run diagnostics",
423 e.to_string(),
424 Some("Re-run with `--debug` for more context."),
425 ));
426 }
427 Ok(())
428}
429
430pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
431 match cmd.command {
432 commands::GenerateSubCommand::Config(subcmd) => {
433 let args = GenerateConfigArgs {
434 force: subcmd.force,
435 update: subcmd.update,
436 from_json: subcmd.from_json,
437 };
438 if let Err(e) = run_generate_config(args, debug).await {
439 let _ = log_error(
440 "generate-config",
441 "Failed to generate project config",
442 Some(&e),
443 )
444 .await;
445 return Err(ErrorFactory::operation(
446 "generate-config",
447 "generate .xbp/xbp.yaml",
448 e,
449 Some("Use --update to refresh an existing config or --force to overwrite it."),
450 ));
451 }
452 }
453 commands::GenerateSubCommand::Systemd(subcmd) => {
454 let args = GenerateSystemdArgs {
455 output_dir: subcmd.output_dir,
456 service: subcmd.service,
457 api: subcmd.api,
458 };
459 if let Err(e) = run_generate_systemd(args, debug).await {
460 let _ = log_error(
461 "generate-systemd",
462 "Failed to generate systemd units",
463 Some(&e),
464 )
465 .await;
466 return Err(ErrorFactory::operation(
467 "generate-systemd",
468 "generate unit files",
469 e,
470 Some("Use a writable `--output-dir` or run with elevated permissions."),
471 ));
472 }
473 }
474 }
475 Ok(())
476}
477
478pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
479 if let Err(e) = crate::commands::run_done(
480 cmd.root,
481 cmd.since,
482 cmd.output,
483 cmd.no_ai,
484 cmd.recursive,
485 cmd.exclude,
486 )
487 .await
488 {
489 let _ = log_error("done", "Done command failed", Some(&e)).await;
490 return Err(e.into());
491 }
492 Ok(())
493}
494
495pub(super) async fn handle_login() -> CliResult<()> {
496 if let Err(e) = run_login().await {
497 let _ = log_error("login", "Login failed", Some(&e)).await;
498 return Err(e.into());
499 }
500 Ok(())
501}
502
503pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
504 let commands::VersionCmd {
505 target,
506 git,
507 command,
508 } = cmd;
509
510 if command.is_some() && (target.is_some() || git) {
511 return Err(ErrorFactory::validation(
512 "version",
513 "`xbp version release` cannot be combined with `--git` or positional targets.",
514 Some("Run `xbp version release` as a standalone command."),
515 ));
516 }
517
518 if let Some(subcommand) = command {
519 match subcommand {
520 commands::VersionSubCommand::Release(release_cmd) => {
521 let options = VersionReleaseOptions {
522 explicit_version: release_cmd.version,
523 allow_dirty: release_cmd.allow_dirty,
524 title: release_cmd.title,
525 notes: release_cmd.notes,
526 notes_file: release_cmd.notes_file,
527 draft: release_cmd.draft,
528 prerelease: release_cmd.prerelease,
529 latest_policy: match release_cmd.make_latest {
530 commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
531 commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
532 commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
533 },
534 };
535 if let Err(e) = run_version_release_command(options).await {
536 return Err(ErrorFactory::operation(
537 "version",
538 "release version",
539 e,
540 Some("Use `--allow-dirty` if only generated files changed."),
541 ));
542 }
543 return Ok(());
544 }
545 }
546 }
547
548 if let Err(e) = run_version_command(target, git, debug).await {
549 return Err(ErrorFactory::operation(
550 "version",
551 "run version command",
552 e,
553 Some("Run `xbp version -h` to inspect supported usage."),
554 ));
555 }
556 Ok(())
557}
558
559fn should_print_help(cli: &Cli) -> bool {
560 cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
561}
562
563fn print_service_usage() {
564 println!(
565 "\n{} {}",
566 "Usage:".bright_blue().bold(),
567 "xbp service <command> <service-name>".bright_white()
568 );
569 println!("{} build, install, start, dev", "Commands:".bright_blue(),);
570 println!("{} xbp service build zeus", "Example:".bright_blue());
571 println!(
572 "{} xbp service --help <service-name>",
573 "Tip:".bright_yellow().bold(),
574 );
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
581
582 #[test]
583 fn plain_cli_invocation_prints_help() {
584 let cli = Cli::try_parse_from(["xbp"]).expect("parse");
585 assert!(should_print_help(&cli));
586 }
587
588 #[test]
589 fn list_flag_skips_help_short_circuit() {
590 let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
591 assert!(!should_print_help(&cli));
592 }
593
594 #[test]
595 fn install_without_package_parses_and_is_optional() {
596 let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
597 let Some(Commands::Install { package }) = cli.command else {
598 panic!("expected install command");
599 };
600 assert!(package.is_none());
601 }
602
603 #[test]
604 fn install_with_package_still_parses() {
605 let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
606 let Some(Commands::Install { package }) = cli.command else {
607 panic!("expected install command");
608 };
609 assert_eq!(package.as_deref(), Some("docker"));
610 }
611
612 #[test]
613 fn version_release_make_latest_parses_explicit_value() {
614 let cli = Cli::try_parse_from([
615 "xbp",
616 "version",
617 "release",
618 "--version",
619 "1.2.3",
620 "--make-latest",
621 "false",
622 ])
623 .expect("parse");
624 let Some(Commands::Version(version_cmd)) = cli.command else {
625 panic!("expected version command");
626 };
627 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
628 panic!("expected version release subcommand");
629 };
630 assert!(matches!(
631 release_cmd.make_latest,
632 VersionReleaseLatest::False
633 ));
634 }
635
636 #[test]
637 fn version_release_make_latest_defaults_to_legacy() {
638 let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
639 .expect("parse");
640 let Some(Commands::Version(version_cmd)) = cli.command else {
641 panic!("expected version command");
642 };
643 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
644 panic!("expected version release subcommand");
645 };
646 assert!(matches!(
647 release_cmd.make_latest,
648 VersionReleaseLatest::Legacy
649 ));
650 }
651}