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