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