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