Skip to main content

xbp_cli/cli/
mod.rs

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    // Keep plain `xbp` aligned with `xbp --help` instead of entering
57    // interactive project selection flow.
58    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}