1pub mod app;
2pub mod auto_commit;
3pub mod commands;
4pub mod error;
5pub mod features;
6pub mod handlers;
7pub mod router;
8pub mod ui;
9
10pub use handlers::*;
11
12use crate::cli::app::AppContext;
13use crate::cli::error::{CliResult, ErrorFactory};
14use crate::commands::curl;
15use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
16use crate::commands::redeploy_v2::run_redeploy_v2;
17use crate::commands::{
18 install_package, list_services, open_global_config, run_commit, run_config,
19 run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
20 run_config_cloudflare_account_show, run_config_linear_select_initiative,
21 run_config_publish_setup, run_config_secret_delete, run_config_secret_set,
22 run_config_secret_show, run_generate_config, run_init, run_login, run_publish_command,
23 run_redeploy, run_redeploy_service, run_service_command, run_setup, run_version_command,
24 run_version_release_command, run_version_workspace_command, show_service_help, CommitArgs,
25 GenerateConfigArgs, PublishCommandOptions, ReleaseLatestPolicy, VersionReleaseOptions,
26 WorkspacePublishRunOptions, WorkspaceVersionCheckOptions, WorkspaceVersionCommand,
27 WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
28};
29use crate::commands::{run_diag, run_nginx};
30use crate::config::sync_versioning_files_registry;
31use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
32use clap::{error::ErrorKind as ClapErrorKind, CommandFactory, Parser};
33use colored::Colorize;
34use commands::Cli;
35
36pub async fn run() -> CliResult<()> {
37 let cli: Cli = match Cli::try_parse() {
38 Ok(cli) => cli,
39 Err(err) => {
40 let kind = err.kind();
41 let rendered = err.to_string();
42 if matches!(
43 kind,
44 ClapErrorKind::DisplayHelp
45 | ClapErrorKind::DisplayVersion
46 | ClapErrorKind::MissingSubcommand
47 ) || rendered.contains("Manage the XBP API server")
48 {
49 print!("{rendered}");
50 return Ok(());
51 }
52 return Err(ErrorFactory::clap_parse(err));
53 }
54 };
55
56 if should_print_help(&cli) {
59 let mut cmd = Cli::command();
60 let _ = cmd.print_help();
61 println!();
62 return Ok(());
63 }
64
65 let debug: bool = cli.debug;
66 ui::configure_color_output();
67 let command_name = cli
68 .command
69 .as_ref()
70 .map(commands::command_label)
71 .unwrap_or("interactive");
72 ui::print_cli_header(command_name, debug);
73
74 if let Err(e) = init_logger(debug).await {
75 let _ = log_error(
76 "system",
77 "Failed to initialize logger",
78 Some(&e.to_string()),
79 )
80 .await;
81 }
82 if let Err(e) = sync_versioning_files_registry() {
83 let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
84 }
85
86 let mut ctx = AppContext::new(debug);
87 router::dispatch(cli, &mut ctx).await
88}
89
90pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
91 if let Err(e) = run_init(debug).await {
92 let _ = log_error("init", "Init failed", Some(&e)).await;
93 return Err(e.into());
94 }
95 Ok(())
96}
97
98pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
99 let args = CommitArgs {
100 dry_run: cmd.dry_run,
101 push: cmd.push,
102 no_ai: cmd.no_ai,
103 model: cmd.model,
104 scope: cmd.scope,
105 };
106
107 if let Err(e) = run_commit(args).await {
108 let _ = log_error("commit", "Commit command failed", Some(&e)).await;
109 return Err(ErrorFactory::operation(
110 "commit",
111 "create conventional commit",
112 e,
113 Some("Run `xbp commit --dry-run` to inspect the generated message first."),
114 ));
115 }
116
117 Ok(())
118}
119
120pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
121 let options = PublishCommandOptions {
122 dry_run: cmd.dry_run,
123 allow_dirty: cmd.allow_dirty,
124 target: cmd.target,
125 expected_version: None,
126 };
127
128 if let Err(e) = run_publish_command(options).await {
129 let _ = log_error("publish", "Publish command failed", Some(&e)).await;
130 return Err(ErrorFactory::operation(
131 "publish",
132 "run publish workflow",
133 e,
134 Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
135 ));
136 }
137
138 Ok(())
139}
140
141pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
142 if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
143 let _ = log_error("setup", "Setup failed", Some(&e)).await;
144 return Err(ErrorFactory::operation(
145 "setup",
146 "setup environment",
147 e,
148 Some("Run with `--debug` for command-level output."),
149 ));
150 }
151 Ok(())
152}
153
154pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
155 if let Some(name) = service_name {
156 if let Err(e) = ui::with_loader(
157 &format!("Redeploying service `{}`", name),
158 run_redeploy_service(&name, debug),
159 )
160 .await
161 {
162 let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
163 return Err(ErrorFactory::operation(
164 "redeploy",
165 &format!("redeploy service `{}`", name),
166 e,
167 Some("Verify service name with `xbp services`."),
168 ));
169 }
170 } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
171 let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
172 return Err(ErrorFactory::operation(
173 "redeploy",
174 "redeploy project",
175 e,
176 Some("Try `xbp redeploy <service>` for scoped retries."),
177 ));
178 }
179 Ok(())
180}
181
182pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
183 let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
184 match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
185 Ok(()) => Ok(()),
186 Err(e) => {
187 let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
188 Err(e.into())
189 }
190 }
191}
192
193pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
194 let commands::ConfigCmd {
195 project,
196 no_open,
197 provider,
198 } = cmd;
199
200 if provider.is_some() && (project || no_open) {
201 return Err(ErrorFactory::validation(
202 "config",
203 "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
204 Some("Run either provider key management OR project/global config actions."),
205 ));
206 }
207
208 if let Some(provider_cmd) = provider {
209 match provider_cmd {
210 commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
211 commands::ConfigSecretAction::SetKey { key } => {
212 if let Err(e) = run_config_secret_set("openrouter", key).await {
213 let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
214 return Err(ErrorFactory::operation(
215 "config",
216 "set OpenRouter key",
217 e,
218 Some("Run `xbp config openrouter show` to confirm key state."),
219 ));
220 }
221 }
222 commands::ConfigSecretAction::DeleteKey => {
223 if let Err(e) = run_config_secret_delete("openrouter").await {
224 let _ =
225 log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
226 return Err(ErrorFactory::operation(
227 "config",
228 "delete OpenRouter key",
229 e,
230 Some("Use `xbp config openrouter show` to verify removal."),
231 ));
232 }
233 }
234 commands::ConfigSecretAction::Show { raw } => {
235 if let Err(e) = run_config_secret_show("openrouter", raw).await {
236 let _ =
237 log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
238 return Err(ErrorFactory::operation(
239 "config",
240 "show OpenRouter key",
241 e,
242 None,
243 ));
244 }
245 }
246 },
247 commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
248 commands::ConfigSecretAction::SetKey { key } => {
249 if let Err(e) = run_config_secret_set("github", key).await {
250 let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
251 return Err(ErrorFactory::operation(
252 "config",
253 "set GitHub token",
254 e,
255 Some("Use a token with repo scope for private repos."),
256 ));
257 }
258 }
259 commands::ConfigSecretAction::DeleteKey => {
260 if let Err(e) = run_config_secret_delete("github").await {
261 let _ =
262 log_error("config", "Failed to delete GitHub token", Some(&e)).await;
263 return Err(ErrorFactory::operation(
264 "config",
265 "delete GitHub token",
266 e,
267 None,
268 ));
269 }
270 }
271 commands::ConfigSecretAction::Show { raw } => {
272 if let Err(e) = run_config_secret_show("github", raw).await {
273 let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
274 return Err(ErrorFactory::operation(
275 "config",
276 "show GitHub token",
277 e,
278 None,
279 ));
280 }
281 }
282 },
283 commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
284 commands::CloudflareConfigAction::SetKey { key } => {
285 if let Err(e) = run_config_secret_set("cloudflare", key).await {
286 let _ =
287 log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
288 return Err(ErrorFactory::operation(
289 "config",
290 "set Cloudflare token",
291 e,
292 Some("Use a Cloudflare API token with Secrets Store and DNS permissions."),
293 ));
294 }
295 }
296 commands::CloudflareConfigAction::DeleteKey => {
297 if let Err(e) = run_config_secret_delete("cloudflare").await {
298 let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
299 .await;
300 return Err(ErrorFactory::operation(
301 "config",
302 "delete Cloudflare token",
303 e,
304 None,
305 ));
306 }
307 }
308 commands::CloudflareConfigAction::ShowKey { raw } => {
309 if let Err(e) = run_config_secret_show("cloudflare", raw).await {
310 let _ =
311 log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
312 return Err(ErrorFactory::operation(
313 "config",
314 "show Cloudflare token",
315 e,
316 None,
317 ));
318 }
319 }
320 commands::CloudflareConfigAction::SetAccountId { account_id } => {
321 if let Err(e) = run_config_cloudflare_account_set(account_id).await {
322 let _ =
323 log_error("config", "Failed to set Cloudflare account ID", Some(&e))
324 .await;
325 return Err(ErrorFactory::operation(
326 "config",
327 "set Cloudflare account ID",
328 e,
329 None,
330 ));
331 }
332 }
333 commands::CloudflareConfigAction::DeleteAccountId => {
334 if let Err(e) = run_config_cloudflare_account_delete().await {
335 let _ =
336 log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
337 .await;
338 return Err(ErrorFactory::operation(
339 "config",
340 "delete Cloudflare account ID",
341 e,
342 None,
343 ));
344 }
345 }
346 commands::CloudflareConfigAction::ShowAccountId { raw } => {
347 if let Err(e) = run_config_cloudflare_account_show(raw).await {
348 let _ =
349 log_error("config", "Failed to show Cloudflare account ID", Some(&e))
350 .await;
351 return Err(ErrorFactory::operation(
352 "config",
353 "show Cloudflare account ID",
354 e,
355 None,
356 ));
357 }
358 }
359 },
360 commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
361 commands::LinearConfigAction::SetKey { key } => {
362 if let Err(e) = run_config_secret_set("linear", key).await {
363 let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
364 return Err(ErrorFactory::operation(
365 "config",
366 "set Linear API key",
367 e,
368 Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
369 ));
370 }
371 }
372 commands::LinearConfigAction::DeleteKey => {
373 if let Err(e) = run_config_secret_delete("linear").await {
374 let _ =
375 log_error("config", "Failed to delete Linear API key", Some(&e)).await;
376 return Err(ErrorFactory::operation(
377 "config",
378 "delete Linear API key",
379 e,
380 None,
381 ));
382 }
383 }
384 commands::LinearConfigAction::Show { raw } => {
385 if let Err(e) = run_config_secret_show("linear", raw).await {
386 let _ =
387 log_error("config", "Failed to show Linear API key", Some(&e)).await;
388 return Err(ErrorFactory::operation(
389 "config",
390 "show Linear API key",
391 e,
392 None,
393 ));
394 }
395 }
396 commands::LinearConfigAction::SelectInitiative => {
397 if let Err(e) = run_config_linear_select_initiative().await {
398 let _ = log_error(
399 "config",
400 "Failed to select repo Linear initiative",
401 Some(&e),
402 )
403 .await;
404 return Err(ErrorFactory::operation(
405 "config",
406 "select repo Linear initiative",
407 e,
408 Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
409 ));
410 }
411 }
412 },
413 commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
414 commands::RegistryConfigAction::SetKey { key } => {
415 if let Err(e) = run_config_secret_set("npm", key).await {
416 let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
417 return Err(ErrorFactory::operation(
418 "config",
419 "set npm token",
420 e,
421 Some("Use a valid npm automation or granular publish token."),
422 ));
423 }
424 }
425 commands::RegistryConfigAction::DeleteKey => {
426 if let Err(e) = run_config_secret_delete("npm").await {
427 let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
428 return Err(ErrorFactory::operation(
429 "config",
430 "delete npm token",
431 e,
432 None,
433 ));
434 }
435 }
436 commands::RegistryConfigAction::Show { raw } => {
437 if let Err(e) = run_config_secret_show("npm", raw).await {
438 let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
439 return Err(ErrorFactory::operation("config", "show npm token", e, None));
440 }
441 }
442 commands::RegistryConfigAction::SetupRelease => {
443 if let Err(e) = run_config_publish_setup("npm").await {
444 let _ =
445 log_error("config", "Failed to configure npm publish", Some(&e)).await;
446 return Err(ErrorFactory::operation(
447 "config",
448 "configure npm publish",
449 e,
450 Some("Run this inside the target XBP project and ensure the package manifest exists."),
451 ));
452 }
453 }
454 },
455 commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
456 commands::RegistryConfigAction::SetKey { key } => {
457 if let Err(e) = run_config_secret_set("crates", key).await {
458 let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
459 return Err(ErrorFactory::operation(
460 "config",
461 "set crates token",
462 e,
463 Some("Use a crates.io API token or rely on CARGO_REGISTRY_TOKEN."),
464 ));
465 }
466 }
467 commands::RegistryConfigAction::DeleteKey => {
468 if let Err(e) = run_config_secret_delete("crates").await {
469 let _ =
470 log_error("config", "Failed to delete crates token", Some(&e)).await;
471 return Err(ErrorFactory::operation(
472 "config",
473 "delete crates token",
474 e,
475 None,
476 ));
477 }
478 }
479 commands::RegistryConfigAction::Show { raw } => {
480 if let Err(e) = run_config_secret_show("crates", raw).await {
481 let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
482 return Err(ErrorFactory::operation(
483 "config",
484 "show crates token",
485 e,
486 None,
487 ));
488 }
489 }
490 commands::RegistryConfigAction::SetupRelease => {
491 if let Err(e) = run_config_publish_setup("crates").await {
492 let _ = log_error("config", "Failed to configure crates publish", Some(&e))
493 .await;
494 return Err(ErrorFactory::operation(
495 "config",
496 "configure crates publish",
497 e,
498 Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
499 ));
500 }
501 }
502 },
503 }
504 } else if project {
505 if let Err(e) = run_config(debug).await {
506 return Err(ErrorFactory::operation(
507 "config",
508 "read project config",
509 e,
510 Some("Ensure you're inside an XBP project root."),
511 ));
512 }
513 } else if let Err(e) = open_global_config(no_open).await {
514 let _ = log_error("config", "Failed to open global config", Some(&e)).await;
515 return Err(ErrorFactory::operation(
516 "config",
517 "open global config",
518 e,
519 None,
520 ));
521 }
522 Ok(())
523}
524
525pub(super) async fn handle_install(
526 package: Option<String>,
527 list: bool,
528 debug: bool,
529) -> CliResult<()> {
530 if list {
531 crate::commands::print_install_targets_help();
532 return Ok(());
533 }
534
535 let Some(package) = package else {
536 crate::commands::print_install_empty_state();
537 return Ok(());
538 };
539
540 if package.trim().is_empty() {
541 crate::commands::print_install_empty_state();
542 return Ok(());
543 }
544
545 if crate::commands::is_install_listing_request(&package) {
546 crate::commands::print_install_targets_help();
547 return Ok(());
548 }
549
550 let install_msg: String = format!("Installing package: {}", package);
551 let _ = log_info("install", &install_msg, None).await;
552 match ui::with_loader(
553 &format!("Installing package `{}`", package),
554 install_package(&package, debug),
555 )
556 .await
557 {
558 Ok(()) => {
559 let success_msg = format!("Successfully installed: {}", package);
560 let _ = log_success("install", &success_msg, None).await;
561 Ok(())
562 }
563 Err(e) => Err(e.into()),
564 }
565}
566
567pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
568 let url = cmd
569 .url
570 .unwrap_or_else(|| "https://example.com/api".to_string());
571 if let Err(e) = ui::with_loader(
572 &format!("Requesting {}", url),
573 curl::run_curl(&url, cmd.no_timeout, debug),
574 )
575 .await
576 {
577 let _ = log_error("curl", "Curl command failed", Some(&e)).await;
578 return Err(ErrorFactory::operation(
579 "curl",
580 "execute request",
581 e,
582 Some("Double-check the URL and network connectivity."),
583 ));
584 }
585 Ok(())
586}
587
588pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
589 if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
590 let _ = log_error("services", "Failed to list services", Some(&e)).await;
591 return Err(ErrorFactory::operation(
592 "services",
593 "list services",
594 e,
595 Some("Ensure xbp config is present and valid."),
596 ));
597 }
598 Ok(())
599}
600
601pub(super) async fn handle_service(
602 command: Option<String>,
603 service_name: Option<String>,
604 debug: bool,
605) -> CliResult<()> {
606 if let Some(cmd) = command {
607 if cmd == "--help" || cmd == "help" {
608 if let Some(name) = service_name {
609 if let Err(e) = show_service_help(&name).await {
610 let _ = log_error("service", "Failed to show service help", Some(&e)).await;
611 return Err(ErrorFactory::operation(
612 "service",
613 "show service help",
614 e,
615 None,
616 ));
617 }
618 } else {
619 print_service_usage();
620 }
621 } else if let Some(name) = service_name {
622 if let Err(e) = run_service_command(&cmd, &name, debug).await {
623 let _ = log_error(
624 "service",
625 &format!("Service command '{}' failed", cmd),
626 Some(&e),
627 )
628 .await;
629 return Err(ErrorFactory::operation(
630 "service",
631 &format!("run `{}` for `{}`", cmd, name),
632 e,
633 Some("Check available services via `xbp services`."),
634 ));
635 }
636 } else {
637 let _ = log_error("service", "Service name required", None).await;
638 return Err(ErrorFactory::validation(
639 "service",
640 "Service name required.",
641 Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
642 ));
643 }
644 } else {
645 print_service_usage();
646 }
647 Ok(())
648}
649
650pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
651 if let Err(e) = run_nginx(cmd, debug).await {
652 let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
653 return Err(ErrorFactory::operation(
654 "nginx",
655 "execute nginx command",
656 e.to_string(),
657 Some("Try `xbp nginx --help` for command syntax."),
658 ));
659 }
660 Ok(())
661}
662
663pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
664 if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
665 let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
666 return Err(ErrorFactory::operation(
667 "diag",
668 "run diagnostics",
669 e.to_string(),
670 Some("Re-run with `--debug` for more context."),
671 ));
672 }
673 Ok(())
674}
675
676pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
677 match cmd.command {
678 commands::GenerateSubCommand::Config(subcmd) => {
679 let args = GenerateConfigArgs {
680 force: subcmd.force,
681 update: subcmd.update,
682 from_json: subcmd.from_json,
683 };
684 if let Err(e) = run_generate_config(args, debug).await {
685 let _ = log_error(
686 "generate-config",
687 "Failed to generate project config",
688 Some(&e),
689 )
690 .await;
691 return Err(ErrorFactory::operation(
692 "generate-config",
693 "generate .xbp/xbp.yaml",
694 e,
695 Some("Use --update to refresh an existing config or --force to overwrite it."),
696 ));
697 }
698 }
699 commands::GenerateSubCommand::Systemd(subcmd) => {
700 let args = GenerateSystemdArgs {
701 output_dir: subcmd.output_dir,
702 service: subcmd.service,
703 api: subcmd.api,
704 };
705 if let Err(e) = run_generate_systemd(args, debug).await {
706 let _ = log_error(
707 "generate-systemd",
708 "Failed to generate systemd units",
709 Some(&e),
710 )
711 .await;
712 return Err(ErrorFactory::operation(
713 "generate-systemd",
714 "generate unit files",
715 e,
716 Some("Use a writable `--output-dir` or run with elevated permissions."),
717 ));
718 }
719 }
720 }
721 Ok(())
722}
723
724pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
725 if let Err(e) = crate::commands::run_done(
726 cmd.root,
727 cmd.since,
728 cmd.output,
729 cmd.no_ai,
730 cmd.recursive,
731 cmd.exclude,
732 )
733 .await
734 {
735 let _ = log_error("done", "Done command failed", Some(&e)).await;
736 return Err(e.into());
737 }
738 Ok(())
739}
740
741pub(super) async fn handle_login() -> CliResult<()> {
742 if let Err(e) = run_login().await {
743 let _ = log_error("login", "Login failed", Some(&e)).await;
744 return Err(e.into());
745 }
746 Ok(())
747}
748
749pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
750 let commands::VersionCmd {
751 target,
752 explicit_version,
753 git,
754 command,
755 } = cmd;
756 let resolved_target = explicit_version.or(target);
757
758 if command.is_some() && (resolved_target.is_some() || git) {
759 return Err(ErrorFactory::validation(
760 "version",
761 "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
762 Some(
763 "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
764 ),
765 ));
766 }
767
768 if let Some(subcommand) = command {
769 match subcommand {
770 commands::VersionSubCommand::Release(release_cmd) => {
771 let options = VersionReleaseOptions {
772 explicit_version: release_cmd.version,
773 allow_dirty: release_cmd.allow_dirty,
774 title: release_cmd.title,
775 notes: release_cmd.notes,
776 notes_file: release_cmd.notes_file,
777 draft: release_cmd.draft,
778 prerelease: release_cmd.prerelease,
779 publish: release_cmd.publish,
780 latest_policy: match release_cmd.make_latest {
781 commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
782 commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
783 commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
784 },
785 };
786 if let Err(e) = run_version_release_command(options).await {
787 return Err(ErrorFactory::operation(
788 "version",
789 "release version",
790 e,
791 Some("Use `--allow-dirty` if only generated files changed."),
792 ));
793 }
794 return Ok(());
795 }
796 commands::VersionSubCommand::Workspace(workspace_cmd) => {
797 let (repo, json, workspace_command) = match workspace_cmd.command {
798 commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
799 check_cmd.target.repo,
800 check_cmd.target.json,
801 WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
802 version: check_cmd.version,
803 }),
804 ),
805 commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
806 sync_cmd.target.repo,
807 sync_cmd.target.json,
808 WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
809 version: sync_cmd.version,
810 write: sync_cmd.write,
811 }),
812 ),
813 commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
814 validate_cmd.target.repo,
815 validate_cmd.target.json,
816 WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
817 package: validate_cmd.package,
818 cargo_check: validate_cmd.cargo_check,
819 package_dry_run: validate_cmd.package_dry_run,
820 }),
821 ),
822 commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
823 match publish_cmd.command {
824 commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
825 plan_cmd.target.repo,
826 plan_cmd.target.json,
827 WorkspaceVersionCommand::PublishPlan,
828 ),
829 commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
830 run_cmd.target.repo,
831 run_cmd.target.json,
832 WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
833 dry_run: run_cmd.dry_run,
834 from: run_cmd.from,
835 only: run_cmd.only,
836 continue_on_error: run_cmd.continue_on_error,
837 allow_dirty: run_cmd.allow_dirty,
838 timeout_seconds: run_cmd.timeout_seconds,
839 poll_interval_seconds: run_cmd.poll_interval_seconds,
840 }),
841 ),
842 }
843 }
844 };
845
846 if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
847 repo,
848 json,
849 command: workspace_command,
850 })
851 .await
852 {
853 return Err(ErrorFactory::operation(
854 "version",
855 "run workspace version command",
856 e,
857 Some("Run `xbp version workspace -h` to inspect supported usage."),
858 ));
859 }
860 return Ok(());
861 }
862 }
863 }
864
865 if let Err(e) = run_version_command(resolved_target, git, debug).await {
866 return Err(ErrorFactory::operation(
867 "version",
868 "run version command",
869 e,
870 Some("Run `xbp version -h` to inspect supported usage."),
871 ));
872 }
873 Ok(())
874}
875
876fn should_print_help(cli: &Cli) -> bool {
877 cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
878}
879
880fn print_service_usage() {
881 println!(
882 "\n{} {}",
883 "Usage:".bright_blue().bold(),
884 "xbp service <command> <service-name>".bright_white()
885 );
886 println!("{} build, install, start, dev", "Commands:".bright_blue(),);
887 println!("{} xbp service build zeus", "Example:".bright_blue());
888 println!(
889 "{} xbp service --help <service-name>",
890 "Tip:".bright_yellow().bold(),
891 );
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
898
899 #[test]
900 fn plain_cli_invocation_prints_help() {
901 let cli = Cli::try_parse_from(["xbp"]).expect("parse");
902 assert!(should_print_help(&cli));
903 }
904
905 #[test]
906 fn list_flag_skips_help_short_circuit() {
907 let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
908 assert!(!should_print_help(&cli));
909 }
910
911 #[test]
912 fn install_without_package_parses_and_is_optional() {
913 let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
914 let Some(Commands::Install { list, package }) = cli.command else {
915 panic!("expected install command");
916 };
917 assert!(!list);
918 assert!(package.is_none());
919 }
920
921 #[test]
922 fn install_with_package_still_parses() {
923 let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
924 let Some(Commands::Install { list, package }) = cli.command else {
925 panic!("expected install command");
926 };
927 assert!(!list);
928 assert_eq!(package.as_deref(), Some("docker"));
929 }
930
931 #[test]
932 fn install_list_flag_parses() {
933 let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
934 let Some(Commands::Install { list, package }) = cli.command else {
935 panic!("expected install command");
936 };
937 assert!(list);
938 assert!(package.is_none());
939 }
940
941 #[test]
942 fn install_short_list_flag_parses() {
943 let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
944 let Some(Commands::Install { list, package }) = cli.command else {
945 panic!("expected install command");
946 };
947 assert!(list);
948 assert!(package.is_none());
949 }
950
951 #[test]
952 fn install_ls_alias_parses_as_package() {
953 let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
954 let Some(Commands::Install { list, package }) = cli.command else {
955 panic!("expected install command");
956 };
957 assert!(!list);
958 assert_eq!(package.as_deref(), Some("ls"));
959 }
960
961 #[test]
962 fn version_release_make_latest_parses_explicit_value() {
963 let cli = Cli::try_parse_from([
964 "xbp",
965 "version",
966 "release",
967 "--version",
968 "1.2.3",
969 "--make-latest",
970 "false",
971 ])
972 .expect("parse");
973 let Some(Commands::Version(version_cmd)) = cli.command else {
974 panic!("expected version command");
975 };
976 assert!(version_cmd.explicit_version.is_none());
977 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
978 panic!("expected version release subcommand");
979 };
980 assert!(matches!(
981 release_cmd.make_latest,
982 VersionReleaseLatest::False
983 ));
984 }
985
986 #[test]
987 fn version_release_make_latest_defaults_to_legacy() {
988 let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
989 .expect("parse");
990 let Some(Commands::Version(version_cmd)) = cli.command else {
991 panic!("expected version command");
992 };
993 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
994 panic!("expected version release subcommand");
995 };
996 assert!(matches!(
997 release_cmd.make_latest,
998 VersionReleaseLatest::Legacy
999 ));
1000 }
1001
1002 #[test]
1003 fn version_explicit_flag_parses() {
1004 let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
1005 let Some(Commands::Version(version_cmd)) = cli.command else {
1006 panic!("expected version command");
1007 };
1008 assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1009 assert!(version_cmd.target.is_none());
1010 assert!(version_cmd.command.is_none());
1011 }
1012
1013 #[test]
1014 fn version_short_explicit_flag_parses_with_alias() {
1015 let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
1016 let Some(Commands::Version(version_cmd)) = cli.command else {
1017 panic!("expected version command");
1018 };
1019 assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1020 assert!(version_cmd.target.is_none());
1021 assert!(version_cmd.command.is_none());
1022 }
1023}