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 mcp_tools_export;
9pub mod router;
10pub mod ui;
11
12pub use handlers::*;
13
14use crate::cli::app::AppContext;
15use crate::cli::error::{CliError, CliResult, ErrorFactory};
16use crate::commands::cli_session::{
17    post_cli_error_report, require_authenticated_cli_session, run_login_status, run_logout,
18    run_whoami, CliErrorReportPayload,
19};
20use crate::commands::curl;
21#[cfg(feature = "openapi-gen")]
22use crate::commands::generate_openapi::{run_generate_openapi, GenerateOpenApiArgs};
23use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
24use crate::commands::redeploy_v2::run_redeploy_v2;
25use crate::commands::{
26    install_package, list_services, open_global_config, pick_service_for_command, run_commit,
27    run_config, run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
28    run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
29    run_config_cloudflare_status, run_config_crates_login, run_config_crates_logout,
30    run_config_discord, run_config_openapi_setup, run_config_openapi_show,
31    run_config_publish_setup, run_config_release_setup, run_config_secret_delete,
32    run_config_secret_set, run_config_secret_show, run_cursor_ingest, run_generate_config,
33    run_github, run_init,
34    run_login, run_migrate_config_file, run_publish_command, run_redeploy, run_redeploy_service,
35    run_service_command, run_service_interactive, run_setup, run_todos, run_update_check,
36    run_version_bump_command, run_version_command, run_version_discover_services,
37    run_version_domain_command, run_version_release_command, run_version_workspace_command,
38    show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
39    PublishCommandOptions, ReleaseLatestPolicy, UpdateCheckOptions, VersionDomainCommand,
40    VersionDomainCommandOptions, VersionDomainDiagnoseOptions, VersionDomainDoctorOptions,
41    VersionDomainInitOptions, VersionDomainReleaseOptions, VersionDomainSyncOptions,
42    VersionReleaseOptions, WorkspacePublishHealOptions, WorkspacePublishPlanOptions,
43    WorkspacePublishRunOptions, WorkspaceVersionCheckOptions, WorkspaceVersionCommand,
44    WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
45};
46#[cfg(feature = "linear")]
47use crate::commands::{run_config_linear_select_initiative, run_linear};
48use crate::commands::{run_diag, run_nginx};
49use crate::config::{resolve_device_identity, sync_versioning_files_registry};
50use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
51use crate::utils::{
52    find_xbp_config_upwards, git_remote_url_from_metadata, parse_github_repo_from_remote_url,
53};
54use chrono::Utc;
55use clap::{error::ErrorKind as ClapErrorKind, Parser};
56use colored::Colorize;
57use commands::Cli;
58use std::env;
59
60pub async fn run() -> CliResult<()> {
61    let cli: Cli = match Cli::try_parse() {
62        Ok(cli) => cli,
63        Err(err) => {
64            let kind = err.kind();
65            let rendered = err.to_string();
66            if kind == ClapErrorKind::DisplayVersion {
67                help_render::emit_version_info(env!("CARGO_PKG_VERSION"));
68                return Ok(());
69            }
70            if kind == ClapErrorKind::DisplayHelp
71                || kind == ClapErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
72                || rendered.contains("Manage the XBP API server")
73            {
74                ui::configure_color_output();
75                if help_render::is_root_help_text(&rendered) {
76                    crate::commands::print_help().await;
77                } else {
78                    help_render::emit_styled_help(&rendered, help_render::HelpScope::Auto);
79                }
80                return Ok(());
81            }
82            let error = ErrorFactory::clap_parse(err);
83            report_cli_error("cli", &error).await;
84            return Err(error);
85        }
86    };
87
88    if cli.commands {
89        help_render::print_command_catalog();
90        return Ok(());
91    }
92
93    // Keep plain `xbp` aligned with `xbp --help` instead of entering
94    // interactive project selection flow.
95    if should_print_help(&cli) {
96        ui::configure_color_output();
97        crate::commands::print_help().await;
98        return Ok(());
99    }
100
101    let debug: bool = cli.debug;
102    ui::configure_color_output();
103    let command_name = cli
104        .command
105        .as_ref()
106        .map(commands::command_label)
107        .unwrap_or("interactive");
108    ui::print_cli_header(command_name, debug);
109
110    if let Err(e) = init_logger(debug).await {
111        let _ = log_error(
112            "system",
113            "Failed to initialize logger",
114            Some(&e.to_string()),
115        )
116        .await;
117    }
118    if let Err(e) = sync_versioning_files_registry() {
119        let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
120    }
121
122    let background_cursor_trigger =
123        background_cursor_ingest_trigger(cli.command.as_ref()).map(str::to_string);
124    let background_system_inventory_trigger =
125        background_system_inventory_refresh_trigger(cli.command.as_ref()).map(str::to_string);
126    let mut ctx = AppContext::new(debug);
127    let result = router::dispatch(cli, &mut ctx).await;
128
129    if result.is_ok() {
130        if let Some(trigger) = background_cursor_trigger {
131            if let Err(error) =
132                crate::commands::cursor_ingest::maybe_start_background_cursor_ingest(&trigger)
133            {
134                let _ = log_warn(
135                    "cursor",
136                    "Background Cursor ingest could not be started",
137                    Some(&error),
138                )
139                .await;
140            }
141        }
142        if let Some(trigger) = background_system_inventory_trigger {
143            if let Err(error) = crate::commands::system_inventory_refresh::
144                maybe_start_background_system_inventory_refresh(&trigger)
145            {
146                let _ = log_warn(
147                    "codetime",
148                    "Background system inventory refresh could not be started",
149                    Some(&error),
150                )
151                .await;
152            }
153        }
154    } else if let Err(ref error) = result {
155        report_cli_error(command_name, error).await;
156    }
157
158    result
159}
160
161/// Soft-report a CLI failure to xbp.app (D1). Never raises; missing login skips.
162async fn report_cli_error(command: &str, error: &CliError) {
163    let message = redact_sensitive_text(&strip_ansi(&error.to_string()));
164    if message.trim().is_empty() {
165        return;
166    }
167
168    let cwd = env::current_dir().ok();
169    let project_path = cwd
170        .as_ref()
171        .and_then(|dir| find_xbp_config_upwards(dir))
172        .map(|found| found.project_root.display().to_string());
173    let (repository_owner, repository_name) = cwd
174        .as_ref()
175        .and_then(|dir| {
176            let root = find_xbp_config_upwards(dir)
177                .map(|found| found.project_root)
178                .unwrap_or_else(|| dir.clone());
179            git_remote_url_from_metadata(&root, "origin")
180                .ok()
181                .flatten()
182                .and_then(|url| parse_github_repo_from_remote_url(&url))
183        })
184        .map(|(owner, repo)| (Some(owner), Some(repo)))
185        .unwrap_or((None, None));
186
187    let hardware_id = resolve_device_identity()
188        .ok()
189        .map(|device| device.hardware_id);
190    let hostname = env::var("COMPUTERNAME")
191        .or_else(|_| env::var("HOSTNAME"))
192        .ok()
193        .map(|value| value.trim().to_string())
194        .filter(|value| !value.is_empty());
195
196    let payload = CliErrorReportPayload {
197        command: command.to_string(),
198        action: extract_labeled_field(&message, "Action"),
199        kind: extract_error_kind(&message),
200        message: message.clone(),
201        details: extract_labeled_field(&message, "Details")
202            .or_else(|| extract_labeled_field(&message, "Reason")),
203        hint: extract_labeled_field(&message, "Hint"),
204        project_path,
205        cwd: cwd.map(|path| path.display().to_string()),
206        repository_owner,
207        repository_name,
208        cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
209        platform: Some(format!(
210            "{}-{}",
211            std::env::consts::OS,
212            std::env::consts::ARCH
213        )),
214        hostname,
215        hardware_id,
216        occurred_at: Some(Utc::now().to_rfc3339()),
217    };
218
219    let _ = post_cli_error_report(&payload).await;
220}
221
222fn strip_ansi(input: &str) -> String {
223    let bytes = input.as_bytes();
224    let mut out = String::with_capacity(input.len());
225    let mut i = 0;
226    while i < bytes.len() {
227        if bytes[i] == 0x1b {
228            i += 1;
229            if i < bytes.len() && bytes[i] == b'[' {
230                i += 1;
231                while i < bytes.len() {
232                    let b = bytes[i];
233                    i += 1;
234                    if (b'@'..=b'~').contains(&b) {
235                        break;
236                    }
237                }
238            }
239            continue;
240        }
241        out.push(bytes[i] as char);
242        i += 1;
243    }
244    out
245}
246
247fn redact_sensitive_text(input: &str) -> String {
248    // Best-effort: avoid shipping obvious tokens/keys if they appear in stderr.
249    let mut out = input.to_string();
250    for pattern in [
251        r"(?i)ghp_[A-Za-z0-9]{20,}",
252        r"(?i)gho_[A-Za-z0-9]{20,}",
253        r"(?i)github_pat_[A-Za-z0-9_]{20,}",
254        r"(?i)xbp_[A-Za-z0-9_-]{20,}",
255        r"(?i)sk-[A-Za-z0-9]{20,}",
256        r"(?i)Bearer\s+[A-Za-z0-9._\-+/=]{16,}",
257    ] {
258        if let Ok(re) = regex::Regex::new(pattern) {
259            out = re.replace_all(&out, "[redacted]").into_owned();
260        }
261    }
262    if out.len() > 8_000 {
263        out.truncate(8_000);
264        out.push_str("…");
265    }
266    out
267}
268
269fn extract_labeled_field(message: &str, label: &str) -> Option<String> {
270    let prefix = format!("{label}:");
271    for line in message.lines() {
272        let trimmed = line.trim();
273        if let Some(rest) = trimmed.strip_prefix(&prefix).or_else(|| {
274            // colored headers sometimes leave trailing spaces after labels
275            trimmed
276                .split_once(':')
277                .filter(|(head, _)| head.trim().eq_ignore_ascii_case(label))
278                .map(|(_, rest)| rest)
279        }) {
280            let value = rest.trim();
281            if !value.is_empty() {
282                return Some(value.to_string());
283            }
284        }
285    }
286    None
287}
288
289fn extract_error_kind(message: &str) -> Option<String> {
290    let trimmed = message.trim_start();
291    if let Some(rest) = trimmed.strip_prefix('[') {
292        if let Some(end) = rest.find(']') {
293            let kind = rest[..end].trim();
294            if !kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
295                return Some(kind.to_string());
296            }
297        }
298    }
299    // SECRETS / VERSION-style banners
300    message
301        .lines()
302        .next()
303        .and_then(|line| line.split_whitespace().next())
304        .map(|word| word.trim().to_ascii_uppercase())
305        .filter(|word| {
306            word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && word.len() <= 24
307        })
308}
309
310pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
311    if let Err(e) = run_init(debug).await {
312        let _ = log_error("init", "Init failed", Some(&e)).await;
313        return Err(e.into());
314    }
315    Ok(())
316}
317
318pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
319    if cmd.push {
320        crate::cli::auto_commit::set_explicit_push(true);
321    }
322
323    let args = CommitArgs {
324        dry_run: cmd.dry_run,
325        push: crate::cli::auto_commit::explicit_push_requested(),
326        no_ai: cmd.no_ai,
327        model: cmd.model,
328        scope: cmd.scope,
329    };
330
331    match run_commit(args).await {
332        Ok(CommitRunOutcome::Completed) | Ok(CommitRunOutcome::NothingToCommit) => Ok(()),
333        Err(error) => Err(map_commit_error(error)),
334    }
335}
336
337pub(super) async fn handle_push() -> CliResult<()> {
338    match crate::commands::push::run_push().await {
339        Ok(()) => Ok(()),
340        Err(error) => Err(ErrorFactory::operation(
341            "push",
342            "sync and push current branch",
343            error,
344            Some(
345                "If rebase conflicts remain, resolve them, run `git rebase --continue`, then `xbp push` again. Dirty WIP is stashed as `xbp auto-rebase: temporary stash`.",
346            ),
347        )),
348    }
349}
350
351pub(super) async fn handle_deploy(cmd: commands::DeployCmd, debug: bool) -> CliResult<()> {
352    use crate::commands::{run_deploy, DeployMode, DeployRequest};
353
354    let mode = if cmd.history {
355        DeployMode::History
356    } else if cmd.promote.is_some() {
357        DeployMode::Promote
358    } else if cmd.verify {
359        DeployMode::Verify
360    } else if cmd.status {
361        DeployMode::Status
362    } else if cmd.run {
363        DeployMode::Run
364    } else {
365        // Default: plan-only for safety
366        DeployMode::Plan
367    };
368
369    let request = DeployRequest {
370        target: cmd.target,
371        env: cmd.env,
372        mode,
373        promote_stage: cmd.promote,
374        yes: cmd.yes,
375        json: cmd.json,
376        debug: debug || cmd.plan && cmd.json,
377        limit: cmd.limit,
378    };
379
380    match run_deploy(request).await {
381        Ok(()) => Ok(()),
382        Err(error) => Err(ErrorFactory::operation(
383            "deploy",
384            "orchestrate service deploy",
385            error,
386            Some(
387                "Use `xbp deploy <target> --plan` first. Configure services[].deploy.envs.<env> and optional deploy.groups. Pass --yes for non-interactive k8s apply.",
388            ),
389        )),
390    }
391}
392
393fn map_commit_error(error: CommitError) -> CliError {
394    match error {
395        CommitError::PushFailed {
396            summary,
397            commit_sha,
398        } => {
399            let details = if let Some(commit_sha) = commit_sha {
400                format!("Created local commit {commit_sha} but push failed: {summary}")
401            } else {
402                format!("Push failed: {summary}")
403            };
404            ErrorFactory::operation(
405                "commit",
406                "push changes",
407                details,
408                commit_push_hint(&summary),
409            )
410        }
411        CommitError::TerminalSessionsBlocked(message) => ErrorFactory::validation(
412            "commit",
413            message,
414            Some("Add `/terminals` to `.gitignore` and run `git rm -r --cached terminals/`."),
415        ),
416        CommitError::Operation(message) => ErrorFactory::operation(
417            "commit",
418            "create conventional commit",
419            message,
420            Some("Run `xbp commit --dry-run` after saving changes."),
421        ),
422    }
423}
424
425fn commit_push_hint(summary: &str) -> Option<&'static str> {
426    let lowered = summary.to_ascii_lowercase();
427    if lowered.contains("unstaged changes")
428        || lowered.contains("uncommitted changes")
429        || lowered.contains("please commit or stash")
430        || lowered.contains("temporary stash")
431    {
432        Some(
433            "XBP stashes dirty WIP for auto-rebase. If push still failed: `git stash list`, restore the `xbp auto-rebase` stash if needed, then `git pull --rebase` and `git push`.",
434        )
435    } else if lowered.contains("auto-rebase failed") || lowered.contains("conflict") {
436        Some("Resolve rebase conflicts, run `git rebase --continue`, then `git push`.")
437    } else if lowered.contains("behind") || lowered.contains("non-fast-forward") {
438        Some("XBP should auto-rebase; if it did not, run `git pull --rebase` then `git push`.")
439    } else {
440        Some("Verify GitHub auth and branch permissions, then push again.")
441    }
442}
443
444pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
445    let options = PublishCommandOptions {
446        dry_run: cmd.dry_run,
447        allow_dirty: cmd.allow_dirty,
448        force: cmd.force,
449        include_prereqs: cmd.include_prereqs,
450        auto_fix: true,
451        target: cmd.target,
452        service: cmd.service,
453        manifest_path: cmd.manifest_path,
454        expected_version: None,
455    };
456
457    if let Err(e) = run_publish_command(options).await {
458        let _ = log_error("publish", "Publish command failed", Some(&e)).await;
459        return Err(ErrorFactory::operation(
460            "publish",
461            "run publish workflow",
462            e,
463            Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
464        ));
465    }
466
467    Ok(())
468}
469
470pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
471    if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
472        let _ = log_error("setup", "Setup failed", Some(&e)).await;
473        return Err(ErrorFactory::operation(
474            "setup",
475            "setup environment",
476            e,
477            Some("Run with `--debug` for command-level output."),
478        ));
479    }
480    Ok(())
481}
482
483pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
484    if let Some(name) = service_name {
485        if let Err(e) = ui::with_loader(
486            &format!("Redeploying service `{}`", name),
487            run_redeploy_service(&name, debug),
488        )
489        .await
490        {
491            let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
492            return Err(ErrorFactory::operation(
493                "redeploy",
494                &format!("redeploy service `{}`", name),
495                e,
496                Some("Verify service name with `xbp services`."),
497            ));
498        }
499    } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
500        let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
501        return Err(ErrorFactory::operation(
502            "redeploy",
503            "redeploy project",
504            e,
505            Some("Try `xbp redeploy <service>` for scoped retries."),
506        ));
507    }
508    Ok(())
509}
510
511pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
512    let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
513    match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
514        Ok(()) => Ok(()),
515        Err(e) => {
516            let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
517            Err(e.into())
518        }
519    }
520}
521
522pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
523    let commands::ConfigCmd {
524        project,
525        no_open,
526        provider,
527    } = cmd;
528
529    if provider.is_some() && (project || no_open) {
530        return Err(ErrorFactory::validation(
531            "config",
532            "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
533            Some("Run either provider key management OR project/global config actions."),
534        ));
535    }
536
537    if let Some(provider_cmd) = provider {
538        match provider_cmd {
539            commands::ConfigProviderCmd::MigrateConfigFile(subcmd) => {
540                run_migrate_config_file(
541                    &subcmd.format,
542                    subcmd.from.as_deref(),
543                    subcmd.output.as_deref(),
544                )
545                .map_err(|error| {
546                    ErrorFactory::operation("config", "migrate config file", error, None)
547                })?;
548            }
549            commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
550                commands::ConfigSecretAction::SetKey { key } => {
551                    if let Err(e) = run_config_secret_set("openrouter", key).await {
552                        let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
553                        return Err(ErrorFactory::operation(
554                            "config",
555                            "set OpenRouter key",
556                            e,
557                            Some("Run `xbp config openrouter show` to confirm key state."),
558                        ));
559                    }
560                }
561                commands::ConfigSecretAction::DeleteKey => {
562                    if let Err(e) = run_config_secret_delete("openrouter").await {
563                        let _ =
564                            log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
565                        return Err(ErrorFactory::operation(
566                            "config",
567                            "delete OpenRouter key",
568                            e,
569                            Some("Use `xbp config openrouter show` to verify removal."),
570                        ));
571                    }
572                }
573                commands::ConfigSecretAction::Show { raw } => {
574                    if let Err(e) = run_config_secret_show("openrouter", raw).await {
575                        let _ =
576                            log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
577                        return Err(ErrorFactory::operation(
578                            "config",
579                            "show OpenRouter key",
580                            e,
581                            None,
582                        ));
583                    }
584                }
585            },
586            commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
587                commands::ConfigSecretAction::SetKey { key } => {
588                    if let Err(e) = run_config_secret_set("github", key).await {
589                        let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
590                        return Err(ErrorFactory::operation(
591                            "config",
592                            "set GitHub token",
593                            e,
594                            Some("Use a token with repo scope for private repos."),
595                        ));
596                    }
597                }
598                commands::ConfigSecretAction::DeleteKey => {
599                    if let Err(e) = run_config_secret_delete("github").await {
600                        let _ =
601                            log_error("config", "Failed to delete GitHub token", Some(&e)).await;
602                        return Err(ErrorFactory::operation(
603                            "config",
604                            "delete GitHub token",
605                            e,
606                            None,
607                        ));
608                    }
609                }
610                commands::ConfigSecretAction::Show { raw } => {
611                    if let Err(e) = run_config_secret_show("github", raw).await {
612                        let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
613                        return Err(ErrorFactory::operation(
614                            "config",
615                            "show GitHub token",
616                            e,
617                            None,
618                        ));
619                    }
620                }
621            },
622            commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
623                None => {
624                    if let Err(e) = run_config_cloudflare_setup().await {
625                        let _ =
626                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
627                        return Err(ErrorFactory::operation(
628                            "config",
629                            "configure Cloudflare",
630                            e,
631                            Some(
632                                "Run `xbp config cloudflare status` to inspect credential sources.",
633                            ),
634                        ));
635                    }
636                }
637                Some(commands::CloudflareConfigAction::SetKey { key }) => {
638                    if let Err(e) = run_config_secret_set("cloudflare", key).await {
639                        let _ =
640                            log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
641                        return Err(ErrorFactory::operation(
642                            "config",
643                            "set Cloudflare token",
644                            e,
645                            Some("Use a Cloudflare API token with Secrets Store and DNS permissions."),
646                        ));
647                    }
648                }
649                Some(commands::CloudflareConfigAction::DeleteKey) => {
650                    if let Err(e) = run_config_secret_delete("cloudflare").await {
651                        let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
652                            .await;
653                        return Err(ErrorFactory::operation(
654                            "config",
655                            "delete Cloudflare token",
656                            e,
657                            None,
658                        ));
659                    }
660                }
661                Some(commands::CloudflareConfigAction::ShowKey { raw }) => {
662                    if let Err(e) = run_config_secret_show("cloudflare", raw).await {
663                        let _ =
664                            log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
665                        return Err(ErrorFactory::operation(
666                            "config",
667                            "show Cloudflare token",
668                            e,
669                            None,
670                        ));
671                    }
672                }
673                Some(commands::CloudflareConfigAction::SetAccountId { account_id }) => {
674                    if let Err(e) = run_config_cloudflare_account_set(account_id).await {
675                        let _ =
676                            log_error("config", "Failed to set Cloudflare account ID", Some(&e))
677                                .await;
678                        return Err(ErrorFactory::operation(
679                            "config",
680                            "set Cloudflare account ID",
681                            e,
682                            None,
683                        ));
684                    }
685                }
686                Some(commands::CloudflareConfigAction::DeleteAccountId) => {
687                    if let Err(e) = run_config_cloudflare_account_delete().await {
688                        let _ =
689                            log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
690                                .await;
691                        return Err(ErrorFactory::operation(
692                            "config",
693                            "delete Cloudflare account ID",
694                            e,
695                            None,
696                        ));
697                    }
698                }
699                Some(commands::CloudflareConfigAction::ShowAccountId { raw }) => {
700                    if let Err(e) = run_config_cloudflare_account_show(raw).await {
701                        let _ =
702                            log_error("config", "Failed to show Cloudflare account ID", Some(&e))
703                                .await;
704                        return Err(ErrorFactory::operation(
705                            "config",
706                            "show Cloudflare account ID",
707                            e,
708                            None,
709                        ));
710                    }
711                }
712                Some(commands::CloudflareConfigAction::Login) => {
713                    if let Err(e) = run_config_cloudflare_login().await {
714                        let _ = log_error("config", "Failed to link Cloudflare", Some(&e)).await;
715                        return Err(ErrorFactory::operation(
716                            "config",
717                            "link Cloudflare",
718                            e,
719                            None,
720                        ));
721                    }
722                }
723                Some(commands::CloudflareConfigAction::Status) => {
724                    if let Err(e) = run_config_cloudflare_status().await {
725                        let _ =
726                            log_error("config", "Failed to show Cloudflare status", Some(&e)).await;
727                        return Err(ErrorFactory::operation(
728                            "config",
729                            "show Cloudflare status",
730                            e,
731                            None,
732                        ));
733                    }
734                }
735                Some(commands::CloudflareConfigAction::Setup) => {
736                    if let Err(e) = run_config_cloudflare_setup().await {
737                        let _ =
738                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
739                        return Err(ErrorFactory::operation(
740                            "config",
741                            "configure Cloudflare",
742                            e,
743                            None,
744                        ));
745                    }
746                }
747            },
748            #[cfg(feature = "linear")]
749            commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
750                commands::LinearConfigAction::SetKey { key } => {
751                    if let Err(e) = run_config_secret_set("linear", key).await {
752                        let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
753                        return Err(ErrorFactory::operation(
754                            "config",
755                            "set Linear API key",
756                            e,
757                            Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
758                        ));
759                    }
760                }
761                commands::LinearConfigAction::DeleteKey => {
762                    if let Err(e) = run_config_secret_delete("linear").await {
763                        let _ =
764                            log_error("config", "Failed to delete Linear API key", Some(&e)).await;
765                        return Err(ErrorFactory::operation(
766                            "config",
767                            "delete Linear API key",
768                            e,
769                            None,
770                        ));
771                    }
772                }
773                commands::LinearConfigAction::Show { raw } => {
774                    if let Err(e) = run_config_secret_show("linear", raw).await {
775                        let _ =
776                            log_error("config", "Failed to show Linear API key", Some(&e)).await;
777                        return Err(ErrorFactory::operation(
778                            "config",
779                            "show Linear API key",
780                            e,
781                            None,
782                        ));
783                    }
784                }
785                commands::LinearConfigAction::SelectInitiative => {
786                    if let Err(e) = run_config_linear_select_initiative().await {
787                        let _ = log_error(
788                            "config",
789                            "Failed to select repo Linear initiative",
790                            Some(&e),
791                        )
792                        .await;
793                        return Err(ErrorFactory::operation(
794                            "config",
795                            "select repo Linear initiative",
796                            e,
797                            Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
798                        ));
799                    }
800                }
801            },
802            commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
803                commands::RegistryConfigAction::SetKey { key } => {
804                    if let Err(e) = run_config_secret_set("npm", key).await {
805                        let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
806                        return Err(ErrorFactory::operation(
807                            "config",
808                            "set npm token",
809                            e,
810                            Some("Use a valid npm automation or granular publish token."),
811                        ));
812                    }
813                }
814                commands::RegistryConfigAction::DeleteKey => {
815                    if let Err(e) = run_config_secret_delete("npm").await {
816                        let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
817                        return Err(ErrorFactory::operation(
818                            "config",
819                            "delete npm token",
820                            e,
821                            None,
822                        ));
823                    }
824                }
825                commands::RegistryConfigAction::Show { raw } => {
826                    if let Err(e) = run_config_secret_show("npm", raw).await {
827                        let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
828                        return Err(ErrorFactory::operation("config", "show npm token", e, None));
829                    }
830                }
831                commands::RegistryConfigAction::SetupRelease => {
832                    if let Err(e) = run_config_publish_setup("npm").await {
833                        let _ =
834                            log_error("config", "Failed to configure npm publish", Some(&e)).await;
835                        return Err(ErrorFactory::operation(
836                            "config",
837                            "configure npm publish",
838                            e,
839                            Some("Run this inside the target XBP project and ensure the package manifest exists."),
840                        ));
841                    }
842                }
843            },
844            commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
845                commands::CratesConfigAction::SetKey { key } => {
846                    if let Err(e) = run_config_secret_set("crates", key).await {
847                        let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
848                        return Err(ErrorFactory::operation(
849                            "config",
850                            "set crates token",
851                            e,
852                            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."),
853                        ));
854                    }
855                }
856                commands::CratesConfigAction::DeleteKey => {
857                    if let Err(e) = run_config_secret_delete("crates").await {
858                        let _ =
859                            log_error("config", "Failed to delete crates token", Some(&e)).await;
860                        return Err(ErrorFactory::operation(
861                            "config",
862                            "delete crates token",
863                            e,
864                            None,
865                        ));
866                    }
867                }
868                commands::CratesConfigAction::Show { raw } => {
869                    if let Err(e) = run_config_secret_show("crates", raw).await {
870                        let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
871                        return Err(ErrorFactory::operation(
872                            "config",
873                            "show crates token",
874                            e,
875                            None,
876                        ));
877                    }
878                }
879                commands::CratesConfigAction::SetupRelease => {
880                    if let Err(e) = run_config_publish_setup("crates").await {
881                        let _ = log_error("config", "Failed to configure crates publish", Some(&e))
882                            .await;
883                        return Err(ErrorFactory::operation(
884                            "config",
885                            "configure crates publish",
886                            e,
887                            Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
888                        ));
889                    }
890                }
891                commands::CratesConfigAction::Login { key } => {
892                    if let Err(e) = run_config_crates_login(key).await {
893                        let _ = log_error("config", "Failed to log Cargo into crates.io", Some(&e))
894                            .await;
895                        return Err(ErrorFactory::operation(
896                            "config",
897                            "log Cargo into crates.io",
898                            e,
899                            Some("Store a crates token with `xbp config crates set-key` first, or pass it directly to `xbp config crates login <token>`."),
900                        ));
901                    }
902                }
903                commands::CratesConfigAction::Logout => {
904                    if let Err(e) = run_config_crates_logout().await {
905                        let _ =
906                            log_error("config", "Failed to log Cargo out of crates.io", Some(&e))
907                                .await;
908                        return Err(ErrorFactory::operation(
909                            "config",
910                            "log Cargo out of crates.io",
911                            e,
912                            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."),
913                        ));
914                    }
915                }
916            },
917            commands::ConfigProviderCmd::Release(subcmd) => match subcmd.action {
918                commands::ReleaseConfigAction::Setup => {
919                    if let Err(e) = run_config_release_setup().await {
920                        let _ =
921                            log_error("config", "Failed to configure release settings", Some(&e))
922                                .await;
923                        return Err(ErrorFactory::operation(
924                            "config",
925                            "configure release settings",
926                            e,
927                            Some("Run this inside the target XBP project to update .xbp/xbp.yaml."),
928                        ));
929                    }
930                }
931            },
932            commands::ConfigProviderCmd::Discord(subcmd) => {
933                if let Err(e) = run_config_discord(subcmd.action).await {
934                    let _ = log_error("config", "Failed to configure Discord webhook", Some(&e))
935                        .await;
936                    return Err(ErrorFactory::operation(
937                        "config",
938                        "configure Discord webhook notifications",
939                        e,
940                        Some(
941                            "Run `xbp config discord setup` inside a project, or use `--global` for ~/.xbp/config.yaml. Set DISCORD_WEBHOOK_URL as an alternative.",
942                        ),
943                    ));
944                }
945            }
946            commands::ConfigProviderCmd::Openapi(subcmd) => match subcmd.action {
947                None | Some(commands::OpenapiConfigAction::Setup) => {
948                    if let Err(e) = run_config_openapi_setup().await {
949                        let _ =
950                            log_error("config", "Failed to configure OpenAPI generation", Some(&e))
951                                .await;
952                        return Err(ErrorFactory::operation(
953                            "config",
954                            "configure OpenAPI generation",
955                            e,
956                            Some("Run this inside the target XBP project to update .xbp/xbp.yaml."),
957                        ));
958                    }
959                }
960                Some(commands::OpenapiConfigAction::Show) => {
961                    if let Err(e) = run_config_openapi_show().await {
962                        let _ =
963                            log_error("config", "Failed to show OpenAPI config", Some(&e)).await;
964                        return Err(ErrorFactory::operation(
965                            "config",
966                            "show OpenAPI config",
967                            e,
968                            Some("Run this inside the target XBP project."),
969                        ));
970                    }
971                }
972            },
973        }
974    } else if project {
975        if let Err(e) = run_config(debug).await {
976            return Err(ErrorFactory::operation(
977                "config",
978                "read project config",
979                e,
980                Some("Ensure you're inside an XBP project root."),
981            ));
982        }
983    } else if let Err(e) = open_global_config(no_open).await {
984        let _ = log_error("config", "Failed to open global config", Some(&e)).await;
985        return Err(ErrorFactory::operation(
986            "config",
987            "open global config",
988            e,
989            None,
990        ));
991    }
992    Ok(())
993}
994
995pub(super) async fn handle_install(
996    package: Option<String>,
997    list: bool,
998    force: bool,
999    debug: bool,
1000) -> CliResult<()> {
1001    if list {
1002        crate::commands::print_install_targets_help();
1003        return Ok(());
1004    }
1005
1006    let Some(package) = package else {
1007        crate::commands::print_install_empty_state();
1008        return Ok(());
1009    };
1010
1011    if package.trim().is_empty() {
1012        crate::commands::print_install_empty_state();
1013        return Ok(());
1014    }
1015
1016    if crate::commands::is_install_listing_request(&package) {
1017        crate::commands::print_install_targets_help();
1018        return Ok(());
1019    }
1020
1021    let install_msg: String = format!("Installing package: {}", package);
1022    let _ = log_info("install", &install_msg, None).await;
1023    match ui::with_loader(
1024        &format!("Installing package `{}`", package),
1025        install_package(&package, force, debug),
1026    )
1027    .await
1028    {
1029        Ok(()) => {
1030            let success_msg = format!("Successfully installed: {}", package);
1031            let _ = log_success("install", &success_msg, None).await;
1032            Ok(())
1033        }
1034        Err(e) => Err(e.into()),
1035    }
1036}
1037
1038pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
1039    let url = cmd
1040        .url
1041        .unwrap_or_else(|| "https://example.com/api".to_string());
1042    if let Err(e) = ui::with_loader(
1043        &format!("Requesting {}", url),
1044        curl::run_curl(&url, cmd.no_timeout, debug),
1045    )
1046    .await
1047    {
1048        let _ = log_error("curl", "Curl command failed", Some(&e)).await;
1049        return Err(ErrorFactory::operation(
1050            "curl",
1051            "execute request",
1052            e,
1053            Some("Double-check the URL and network connectivity."),
1054        ));
1055    }
1056    Ok(())
1057}
1058
1059pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
1060    if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
1061        let _ = log_error("services", "Failed to list services", Some(&e)).await;
1062        return Err(ErrorFactory::operation(
1063            "services",
1064            "list services",
1065            e,
1066            Some("Ensure xbp config is present and valid."),
1067        ));
1068    }
1069    Ok(())
1070}
1071
1072pub(super) async fn handle_service(
1073    command: Option<String>,
1074    service_name: Option<String>,
1075    debug: bool,
1076) -> CliResult<()> {
1077    match (command.as_deref(), service_name.as_deref()) {
1078        // Bare `xbp service` → preview + interactive picker (service then command)
1079        (None, None) => {
1080            if let Err(e) = run_service_interactive(debug).await {
1081                let _ = log_error("service", "Interactive service run failed", Some(&e)).await;
1082                return Err(ErrorFactory::operation(
1083                    "service",
1084                    "interactive picker",
1085                    e,
1086                    None,
1087                ));
1088            }
1089        }
1090        // `xbp service <cmd>` (no name) → pick service that supports the cmd
1091        (Some(cmd), None) if cmd != "help" && cmd != "--help" => {
1092            if let Err(e) = pick_service_for_command(cmd, debug).await {
1093                let _ = log_error(
1094                    "service",
1095                    &format!("Service pick for '{}' failed", cmd),
1096                    Some(&e),
1097                )
1098                .await;
1099                return Err(ErrorFactory::operation(
1100                    "service",
1101                    &format!("pick service for {}", cmd),
1102                    e,
1103                    Some("Pass a service name explicitly, or ensure commands are defined in xbp config."),
1104                ));
1105            }
1106        }
1107        // `xbp service help <name>` or `xbp service --help <name>`
1108        (Some(cmd), Some(name)) if cmd == "help" || cmd == "--help" => {
1109            if let Err(e) = show_service_help(name).await {
1110                let _ = log_error("service", "Failed to show service help", Some(&e)).await;
1111                return Err(ErrorFactory::operation(
1112                    "service",
1113                    "show service help",
1114                    e,
1115                    None,
1116                ));
1117            }
1118        }
1119        // `xbp service <cmd> <name>` — classic direct execution
1120        (Some(cmd), Some(name)) => {
1121            if let Err(e) = run_service_command(cmd, name, debug).await {
1122                let _ = log_error(
1123                    "service",
1124                    &format!("Service command '{}' failed", cmd),
1125                    Some(&e),
1126                )
1127                .await;
1128                return Err(ErrorFactory::operation(
1129                    "service",
1130                    &format!("run `{}` for `{}`", cmd, name),
1131                    e,
1132                    Some("Check available services via `xbp services`."),
1133                ));
1134            }
1135        }
1136        // `xbp service <name>` (no explicit command) — treat as show service info / help
1137        (None, Some(name)) => {
1138            if let Err(e) = show_service_help(name).await {
1139                // fall back to usage if help fails (e.g. unknown service)
1140                let _ = log_warn(
1141                    "service",
1142                    &format!("Could not show help for service '{}': {}", name, e),
1143                    None,
1144                )
1145                .await;
1146                print_service_usage();
1147            }
1148        }
1149        // `xbp service help` (no name)
1150        (Some(_), None) => {
1151            print_service_usage();
1152        }
1153    }
1154    Ok(())
1155}
1156
1157pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
1158    if let Err(e) = run_nginx(cmd, debug).await {
1159        let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
1160        return Err(ErrorFactory::operation(
1161            "nginx",
1162            "execute nginx command",
1163            e.to_string(),
1164            Some("Try `xbp nginx --help` for command syntax."),
1165        ));
1166    }
1167    Ok(())
1168}
1169
1170pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
1171    if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
1172        let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
1173        return Err(ErrorFactory::operation(
1174            "diag",
1175            "run diagnostics",
1176            e.to_string(),
1177            Some("Re-run with `--debug` for more context."),
1178        ));
1179    }
1180    Ok(())
1181}
1182
1183pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
1184    match cmd.command {
1185        commands::GenerateSubCommand::Config(subcmd) => {
1186            let args = GenerateConfigArgs {
1187                force: subcmd.force,
1188                update: subcmd.update,
1189                from_json: subcmd.from_json,
1190            };
1191            if let Err(e) = run_generate_config(args, debug).await {
1192                let _ = log_error(
1193                    "generate-config",
1194                    "Failed to generate project config",
1195                    Some(&e),
1196                )
1197                .await;
1198                return Err(ErrorFactory::operation(
1199                    "generate-config",
1200                    "generate .xbp/xbp.yaml",
1201                    e,
1202                    Some("Use --update to refresh an existing config or --force to overwrite it."),
1203                ));
1204            }
1205        }
1206        #[cfg(feature = "openapi-gen")]
1207        commands::GenerateSubCommand::Openapi(subcmd) => {
1208            let args = GenerateOpenApiArgs {
1209                services: subcmd.service,
1210                all: subcmd.all,
1211                aggregate: false,
1212                format: subcmd.format,
1213                output: subcmd.output,
1214                check: subcmd.check,
1215                dry_run: subcmd.dry_run,
1216                strict: subcmd.strict,
1217                no_cache: subcmd.no_cache,
1218                version: None,
1219            };
1220            if let Err(e) = run_generate_openapi(args, debug).await {
1221                let _ = log_error("generate-openapi", "Failed to generate OpenAPI", Some(&e)).await;
1222                return Err(ErrorFactory::operation(
1223                    "generate-openapi",
1224                    "generate OpenAPI documents",
1225                    e,
1226                    Some("Check openapi settings in .xbp/xbp.yaml or use --strict for unresolved type details."),
1227                ));
1228            }
1229        }
1230        commands::GenerateSubCommand::Systemd(subcmd) => {
1231            let args = GenerateSystemdArgs {
1232                output_dir: subcmd.output_dir,
1233                service: subcmd.service,
1234                api: subcmd.api,
1235            };
1236            if let Err(e) = run_generate_systemd(args, debug).await {
1237                let _ = log_error(
1238                    "generate-systemd",
1239                    "Failed to generate systemd units",
1240                    Some(&e),
1241                )
1242                .await;
1243                return Err(ErrorFactory::operation(
1244                    "generate-systemd",
1245                    "generate unit files",
1246                    e,
1247                    Some("Use a writable `--output-dir` or run with elevated permissions."),
1248                ));
1249            }
1250        }
1251    }
1252    Ok(())
1253}
1254
1255pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
1256    if let Err(e) = crate::commands::run_done(
1257        cmd.root,
1258        cmd.since,
1259        cmd.output,
1260        cmd.no_ai,
1261        cmd.recursive,
1262        cmd.exclude,
1263    )
1264    .await
1265    {
1266        let _ = log_error("done", "Done command failed", Some(&e)).await;
1267        return Err(e.into());
1268    }
1269    Ok(())
1270}
1271
1272#[cfg(feature = "linear")]
1273pub(super) async fn handle_linear(cmd: commands::LinearIssuesCmd, _debug: bool) -> CliResult<()> {
1274    if let Err(e) = run_linear(cmd).await {
1275        let _ = log_error("linear", "Linear command failed", Some(&e)).await;
1276        return Err(ErrorFactory::operation(
1277            "linear",
1278            "run Linear issues command",
1279            e,
1280            Some("Configure a key with `xbp config linear set-key`."),
1281        ));
1282    }
1283    Ok(())
1284}
1285
1286pub(super) async fn handle_github(cmd: commands::GithubIssuesCmd, _debug: bool) -> CliResult<()> {
1287    if let Err(e) = run_github(cmd).await {
1288        let _ = log_error("github", "GitHub issues command failed", Some(&e)).await;
1289        return Err(ErrorFactory::operation(
1290            "github",
1291            "run GitHub issues command",
1292            e,
1293            Some("Configure a token with `xbp config github set-key` and ensure git origin is a GitHub repo."),
1294        ));
1295    }
1296    Ok(())
1297}
1298
1299pub(super) async fn handle_todos(cmd: commands::TodosCmd, _debug: bool) -> CliResult<()> {
1300    if let Err(e) = run_todos(cmd).await {
1301        let _ = log_error("todos", "Todos command failed", Some(&e)).await;
1302        return Err(ErrorFactory::operation(
1303            "todos",
1304            "run todos scan/sync",
1305            e,
1306            Some("Try `xbp todos scan` first, then `xbp todos sync --to both --dry-run`."),
1307        ));
1308    }
1309    Ok(())
1310}
1311
1312pub(super) async fn handle_issues(cmd: commands::TodosCmd, _debug: bool) -> CliResult<()> {
1313    if let Err(e) = crate::commands::todos::run_issues(cmd).await {
1314        let _ = log_error("issues", "Issues command failed", Some(&e)).await;
1315        return Err(ErrorFactory::operation(
1316            "issues",
1317            "run issues scan/sync",
1318            e,
1319            Some("Try `xbp issues scan` first, then `xbp issues sync --to both --dry-run`."),
1320        ));
1321    }
1322    Ok(())
1323}
1324
1325pub(super) async fn handle_fix_process_monitor_json(
1326    cmd: commands::FixProcessMonitorJsonCmd,
1327    _debug: bool,
1328) -> CliResult<()> {
1329    if let Err(error) =
1330        crate::commands::run_fix_process_monitor_json(cmd.path, cmd.check, cmd.stdout)
1331    {
1332        let _ = log_error(
1333            "fix-process-monitor-json",
1334            "Failed to repair process monitor JSON",
1335            Some(&error),
1336        )
1337        .await;
1338        return Err(error.into());
1339    }
1340    Ok(())
1341}
1342
1343pub(super) async fn handle_cursor(cmd: commands::CursorCmd) -> CliResult<()> {
1344    let result = match cmd.command {
1345        commands::CursorSubCommand::Ingest { dry_run } => run_cursor_ingest(dry_run).await,
1346    };
1347
1348    if let Err(error) = result {
1349        let _ = log_error("cursor", "Cursor command failed", Some(&error)).await;
1350        return Err(error.into());
1351    }
1352    Ok(())
1353}
1354
1355pub(super) async fn handle_login(cmd: commands::LoginCmd) -> CliResult<()> {
1356    let result = match cmd.action {
1357        Some(commands::LoginSubCommand::Status) => run_login_status().await,
1358        Some(commands::LoginSubCommand::Logout) => run_logout().await,
1359        None => run_login().await,
1360    };
1361
1362    if let Err(e) = result {
1363        let _ = log_error("login", "Login command failed", Some(&e)).await;
1364        return Err(e.into());
1365    }
1366
1367    Ok(())
1368}
1369
1370pub(super) async fn handle_whoami() -> CliResult<()> {
1371    if let Err(e) = run_whoami().await {
1372        let _ = log_error("whoami", "Whoami command failed", Some(&e)).await;
1373        return Err(e.into());
1374    }
1375
1376    Ok(())
1377}
1378
1379pub(super) async fn handle_update(cmd: commands::UpdateCmd) -> CliResult<()> {
1380    let options = UpdateCheckOptions {
1381        crate_name: cmd.crate_name,
1382        json: cmd.json,
1383        install: cmd.install,
1384        features: cmd.features,
1385        no_default_features: cmd.no_default_features,
1386        all_features: cmd.all_features,
1387        fail_if_outdated: cmd.fail_if_outdated,
1388    };
1389    if let Err(e) = run_update_check(options).await {
1390        let _ = log_error("update", "Update check failed", Some(&e)).await;
1391        return Err(ErrorFactory::operation(
1392            "update",
1393            "check crates.io for a newer XBP release",
1394            e,
1395            Some(
1396                "Confirm network access to https://crates.io/crates/xbp, or install with `cargo install xbp --locked --features all`.",
1397            ),
1398        ));
1399    }
1400    Ok(())
1401}
1402
1403pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
1404    if let Err(e) = require_authenticated_cli_session().await {
1405        return Err(ErrorFactory::operation(
1406            "version",
1407            "verify CLI login",
1408            e,
1409            Some("Run `xbp login` before using version workflows."),
1410        ));
1411    }
1412
1413    if cmd.push {
1414        crate::cli::auto_commit::set_explicit_push(true);
1415    }
1416
1417    let commands::VersionCmd {
1418        target,
1419        explicit_version,
1420        git,
1421        command,
1422        ..
1423    } = cmd;
1424    let resolved_target = explicit_version.or(target);
1425
1426    if command.is_some() && (resolved_target.is_some() || git) {
1427        return Err(ErrorFactory::validation(
1428            "version",
1429            "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
1430            Some(
1431                "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
1432            ),
1433        ));
1434    }
1435
1436    if let Some(subcommand) = command {
1437        match subcommand {
1438            commands::VersionSubCommand::Release(release_cmd) => {
1439                let options = VersionReleaseOptions {
1440                    explicit_version: release_cmd.version,
1441                    release_flag: release_cmd.flag.map(|flag| flag.as_str().to_string()),
1442                    allow_dirty: release_cmd.allow_dirty,
1443                    title: release_cmd.title,
1444                    notes: release_cmd.notes,
1445                    notes_file: release_cmd.notes_file,
1446                    draft: release_cmd.draft,
1447                    prerelease: release_cmd.prerelease,
1448                    publish: release_cmd.publish,
1449                    force: release_cmd.force,
1450                    dry_run: release_cmd.dry_run,
1451                    latest_policy: match release_cmd.make_latest {
1452                        commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
1453                        commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
1454                        commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
1455                    },
1456                };
1457                if let Err(e) = run_version_release_command(options).await {
1458                    let hint = version_release_error_hint(&e);
1459                    return Err(ErrorFactory::operation(
1460                        "version",
1461                        "release version",
1462                        e,
1463                        hint,
1464                    ));
1465                }
1466                return Ok(());
1467            }
1468            commands::VersionSubCommand::Discover(discover_cmd) => {
1469                if let Err(e) = run_version_discover_services(&discover_cmd).await {
1470                    return Err(ErrorFactory::operation(
1471                        "version",
1472                        "discover and register project services",
1473                        e,
1474                        Some(
1475                            "Run `xbp version discover --dry-run` to preview package roots, or `--no-register` to opt out of writing.",
1476                        ),
1477                    ));
1478                }
1479                return Ok(());
1480            }
1481            commands::VersionSubCommand::Bump(bump_cmd) => {
1482                if bump_cmd.push {
1483                    crate::cli::auto_commit::set_explicit_push(true);
1484                }
1485                if let Err(e) = run_version_bump_command(&bump_cmd).await {
1486                    return Err(ErrorFactory::operation(
1487                        "version",
1488                        "bump mutated package versions",
1489                        e,
1490                        Some(
1491                            "Run `xbp version bump --dry-run` to preview candidates. On a clean tree every registered package is offered; use `--all --patch` (or `--minor`/`--major`) in non-interactive shells.",
1492                        ),
1493                    ));
1494                }
1495                return Ok(());
1496            }
1497            commands::VersionSubCommand::Domain(domain_cmd) => {
1498                let command = match domain_cmd.command {
1499                    commands::VersionDomainSubCommand::Init(init_cmd) => {
1500                        VersionDomainCommand::Init(VersionDomainInitOptions {
1501                            name: init_cmd.name,
1502                            root: init_cmd.root,
1503                            write: init_cmd.write,
1504                        })
1505                    }
1506                    commands::VersionDomainSubCommand::Doctor(doctor_cmd) => {
1507                        VersionDomainCommand::Doctor(VersionDomainDoctorOptions {
1508                            domain: doctor_cmd.domain,
1509                        })
1510                    }
1511                    commands::VersionDomainSubCommand::Sync(sync_cmd) => {
1512                        VersionDomainCommand::Sync(VersionDomainSyncOptions {
1513                            domain: sync_cmd.domain,
1514                            write: sync_cmd.write,
1515                        })
1516                    }
1517                    commands::VersionDomainSubCommand::Diagnose(diagnose_cmd) => {
1518                        VersionDomainCommand::Diagnose(VersionDomainDiagnoseOptions {
1519                            domain: diagnose_cmd.domain,
1520                            log: diagnose_cmd.log,
1521                        })
1522                    }
1523                    commands::VersionDomainSubCommand::Release(release_cmd) => {
1524                        VersionDomainCommand::Release(VersionDomainReleaseOptions {
1525                            domain: release_cmd.domain,
1526                            patch: release_cmd.patch,
1527                            minor: release_cmd.minor,
1528                            major: release_cmd.major,
1529                            version: release_cmd.version,
1530                            deploy: release_cmd.deploy,
1531                            allow_major_jump: release_cmd.allow_major_jump,
1532                            allow_cross_domain_version: release_cmd.allow_cross_domain_version,
1533                        })
1534                    }
1535                };
1536                if let Err(e) =
1537                    run_version_domain_command(VersionDomainCommandOptions { command }).await
1538                {
1539                    return Err(ErrorFactory::operation(
1540                        "version",
1541                        "run version-domain command",
1542                        e,
1543                        Some("Run `xbp version domain -h` to inspect supported usage."),
1544                    ));
1545                }
1546                return Ok(());
1547            }
1548            commands::VersionSubCommand::Workspace(workspace_cmd) => {
1549                let (repo, json, workspace_command) = match workspace_cmd.command {
1550                    commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
1551                        check_cmd.target.repo,
1552                        check_cmd.target.json,
1553                        WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
1554                            version: check_cmd.version,
1555                        }),
1556                    ),
1557                    commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
1558                        sync_cmd.target.repo,
1559                        sync_cmd.target.json,
1560                        WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
1561                            version: sync_cmd.version,
1562                            write: sync_cmd.write,
1563                        }),
1564                    ),
1565                    commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
1566                        validate_cmd.target.repo,
1567                        validate_cmd.target.json,
1568                        WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
1569                            package: validate_cmd.package,
1570                            cargo_check: validate_cmd.cargo_check,
1571                            package_dry_run: validate_cmd.package_dry_run,
1572                        }),
1573                    ),
1574                    commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
1575                        match publish_cmd.command {
1576                            commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
1577                                plan_cmd.target.repo,
1578                                plan_cmd.target.json,
1579                                WorkspaceVersionCommand::PublishPlan(WorkspacePublishPlanOptions {
1580                                    only: plan_cmd.only,
1581                                    include_prereqs: plan_cmd.include_prereqs,
1582                                }),
1583                            ),
1584                            commands::VersionWorkspacePublishSubCommand::Heal(heal_cmd) => (
1585                                heal_cmd.target.repo,
1586                                heal_cmd.target.json,
1587                                WorkspaceVersionCommand::PublishHeal(WorkspacePublishHealOptions {
1588                                    only: heal_cmd.only,
1589                                    include_prereqs: heal_cmd.include_prereqs,
1590                                    write: heal_cmd.write,
1591                                    version: heal_cmd.version,
1592                                }),
1593                            ),
1594                            commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
1595                                run_cmd.target.repo,
1596                                run_cmd.target.json,
1597                                WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
1598                                    dry_run: run_cmd.dry_run,
1599                                    from: run_cmd.from,
1600                                    only: run_cmd.only,
1601                                    include_prereqs: run_cmd.include_prereqs,
1602                                    continue_on_error: run_cmd.continue_on_error,
1603                                    allow_dirty: run_cmd.allow_dirty,
1604                                    auto_fix: run_cmd.auto_fix,
1605                                    timeout_seconds: run_cmd.timeout_seconds,
1606                                    poll_interval_seconds: run_cmd.poll_interval_seconds,
1607                                }),
1608                            ),
1609                        }
1610                    }
1611                };
1612
1613                if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
1614                    repo,
1615                    json,
1616                    command: workspace_command,
1617                })
1618                .await
1619                {
1620                    return Err(ErrorFactory::operation(
1621                        "version",
1622                        "run workspace version command",
1623                        e,
1624                        Some("Run `xbp version workspace -h` to inspect supported usage."),
1625                    ));
1626                }
1627                return Ok(());
1628            }
1629        }
1630    }
1631
1632    if let Err(e) = run_version_command(resolved_target, git, debug).await {
1633        return Err(ErrorFactory::operation(
1634            "version",
1635            "run version command",
1636            e,
1637            Some("Run `xbp version -h` to inspect supported usage."),
1638        ));
1639    }
1640    Ok(())
1641}
1642
1643fn version_release_error_hint(error: &str) -> Option<&'static str> {
1644    if error.contains("Working tree is dirty") || error.contains("uncommitted changes") {
1645        return Some(
1646            "Use `--allow-dirty` if only generated files changed. `--force` also allows a dirty tree when `--publish` is enabled.",
1647        );
1648    }
1649    if error.contains("CARGO_REGISTRY_TOKEN") || error.contains("no token found") {
1650        return Some(
1651            "Configure a crates.io token with `xbp config crates set-key` or export `CARGO_REGISTRY_TOKEN`.",
1652        );
1653    }
1654    if error.contains("Workspace version drift detected") {
1655        return Some(
1656            "Run `xbp version workspace sync --write` to align crate versions, or pass `--force` to auto-sync during release.",
1657        );
1658    }
1659    if error.contains("Workspace closure detected")
1660        || error.contains("workspace publish command failed")
1661    {
1662        return Some(
1663            "For multi-crate workspaces, inspect the publish output and run `xbp version workspace publish plan --include-prereqs` first.",
1664        );
1665    }
1666    if error.contains("Duplicate port found") || error.contains("Duplicate service name") {
1667        return Some(
1668            "Duplicate service ports/names are deploy-time checks. Re-run `xbp version release` (release no longer blocks on them). Fix ports in `.xbp/xbp.yaml` before `xbp deploy`/`xbp start`.",
1669        );
1670    }
1671    if error.contains("Release cancelled") {
1672        return Some(
1673            "Re-run with `--allow-dirty` to skip dirty-tree prompts, or `--force` to auto-heal workspace versions and allow dirty publishes.",
1674        );
1675    }
1676    None
1677}
1678
1679fn should_print_help(cli: &Cli) -> bool {
1680    cli.command.is_none() && !cli.list && !cli.logs && !cli.commands && cli.port.is_none()
1681}
1682
1683fn background_cursor_ingest_trigger(command: Option<&commands::Commands>) -> Option<&'static str> {
1684    match command {
1685        None => None,
1686        Some(commands::Commands::Cursor(_)) => None,
1687        Some(commands::Commands::Diag(_)) => None,
1688        Some(commands::Commands::Whoami) => None,
1689        Some(commands::Commands::Update(_)) => None,
1690        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1691            None => Some("login"),
1692            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1693        },
1694        Some(other) => Some(commands::command_label(other)),
1695    }
1696}
1697
1698fn background_system_inventory_refresh_trigger(
1699    command: Option<&commands::Commands>,
1700) -> Option<&'static str> {
1701    match command {
1702        None => None,
1703        Some(commands::Commands::Cursor(_)) => None,
1704        Some(commands::Commands::Diag(_)) => None,
1705        Some(commands::Commands::Whoami) => None,
1706        Some(commands::Commands::Update(_)) => None,
1707        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1708            None => Some("login"),
1709            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1710        },
1711        Some(other) => Some(commands::command_label(other)),
1712    }
1713}
1714
1715fn print_service_usage() {
1716    ui::configure_color_output();
1717    println!();
1718    println!("{}", "xbp service".bright_magenta().bold());
1719    ui::divider(28);
1720    println!(
1721        "{} {}",
1722        "Usage:".bright_cyan().bold(),
1723        "xbp service [command] [service-name]".bright_white()
1724    );
1725    println!();
1726    println!("Running with no arguments opens an interactive preview + picker.");
1727    ui::section("Commands");
1728    for command in ["build", "install", "start", "dev", "pre"] {
1729        println!("  {}", command.bright_cyan());
1730    }
1731    ui::section("Examples");
1732    println!(
1733        "  {}",
1734        "xbp service                      # interactive: pick service → command".dimmed()
1735    );
1736    println!(
1737        "  {}",
1738        "xbp service start                # pick from services that support start".dimmed()
1739    );
1740    println!("  {}", "xbp service build zeus".dimmed());
1741    ui::tip("Run `xbp service --help <service-name>` for service-specific guidance. All runs are recorded under $HOME/.xbp/logs/service/");
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746    use super::*;
1747    use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
1748
1749    #[test]
1750    fn plain_cli_invocation_prints_help() {
1751        let cli = Cli::try_parse_from(["xbp"]).expect("parse");
1752        assert!(should_print_help(&cli));
1753    }
1754
1755    #[test]
1756    fn root_version_short_v_displays_version() {
1757        let err = Cli::try_parse_from(["xbp", "-v"]).expect_err("version");
1758        assert_eq!(err.kind(), ClapErrorKind::DisplayVersion);
1759    }
1760
1761    #[test]
1762    fn root_version_capital_v_and_long_still_display_version() {
1763        let err_v = Cli::try_parse_from(["xbp", "-V"]).expect_err("version -V");
1764        assert_eq!(err_v.kind(), ClapErrorKind::DisplayVersion);
1765        let err_long = Cli::try_parse_from(["xbp", "--version"]).expect_err("version long");
1766        assert_eq!(err_long.kind(), ClapErrorKind::DisplayVersion);
1767    }
1768
1769    #[test]
1770    fn list_flag_skips_help_short_circuit() {
1771        let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
1772        assert!(!should_print_help(&cli));
1773    }
1774
1775    #[test]
1776    fn commands_flag_skips_help_short_circuit() {
1777        let cli = Cli::try_parse_from(["xbp", "--commands"]).expect("parse");
1778        assert!(!should_print_help(&cli));
1779        assert!(cli.commands);
1780    }
1781
1782    #[test]
1783    fn install_without_package_parses_and_is_optional() {
1784        let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
1785        let Some(Commands::Install {
1786            list,
1787            force,
1788            package,
1789        }) = cli.command
1790        else {
1791            panic!("expected install command");
1792        };
1793        assert!(!list);
1794        assert!(!force);
1795        assert!(package.is_none());
1796    }
1797
1798    #[test]
1799    fn install_with_package_still_parses() {
1800        let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
1801        let Some(Commands::Install {
1802            list,
1803            force,
1804            package,
1805        }) = cli.command
1806        else {
1807            panic!("expected install command");
1808        };
1809        assert!(!list);
1810        assert!(!force);
1811        assert_eq!(package.as_deref(), Some("docker"));
1812    }
1813
1814    #[test]
1815    fn install_force_flag_parses() {
1816        let cli = Cli::try_parse_from(["xbp", "install", "--force", "nx-brew"]).expect("parse");
1817        let Some(Commands::Install {
1818            list,
1819            force,
1820            package,
1821        }) = cli.command
1822        else {
1823            panic!("expected install command");
1824        };
1825        assert!(!list);
1826        assert!(force);
1827        assert_eq!(package.as_deref(), Some("nx-brew"));
1828    }
1829
1830    #[test]
1831    fn install_list_flag_parses() {
1832        let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
1833        let Some(Commands::Install {
1834            list,
1835            force: _,
1836            package,
1837        }) = cli.command
1838        else {
1839            panic!("expected install command");
1840        };
1841        assert!(list);
1842        assert!(package.is_none());
1843    }
1844
1845    #[test]
1846    fn install_short_list_flag_parses() {
1847        let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
1848        let Some(Commands::Install {
1849            list,
1850            force: _,
1851            package,
1852        }) = cli.command
1853        else {
1854            panic!("expected install command");
1855        };
1856        assert!(list);
1857        assert!(package.is_none());
1858    }
1859
1860    #[test]
1861    fn install_ls_alias_parses_as_package() {
1862        let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
1863        let Some(Commands::Install {
1864            list,
1865            force: _,
1866            package,
1867        }) = cli.command
1868        else {
1869            panic!("expected install command");
1870        };
1871        assert!(!list);
1872        assert_eq!(package.as_deref(), Some("ls"));
1873    }
1874
1875    #[test]
1876    fn version_release_make_latest_parses_explicit_value() {
1877        let cli = Cli::try_parse_from([
1878            "xbp",
1879            "version",
1880            "release",
1881            "--version",
1882            "1.2.3",
1883            "--make-latest",
1884            "false",
1885        ])
1886        .expect("parse");
1887        let Some(Commands::Version(version_cmd)) = cli.command else {
1888            panic!("expected version command");
1889        };
1890        assert!(version_cmd.explicit_version.is_none());
1891        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1892            panic!("expected version release subcommand");
1893        };
1894        assert!(matches!(
1895            release_cmd.make_latest,
1896            VersionReleaseLatest::False
1897        ));
1898    }
1899
1900    #[test]
1901    fn version_release_make_latest_defaults_to_legacy() {
1902        let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
1903            .expect("parse");
1904        let Some(Commands::Version(version_cmd)) = cli.command else {
1905            panic!("expected version command");
1906        };
1907        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1908            panic!("expected version release subcommand");
1909        };
1910        assert!(matches!(
1911            release_cmd.make_latest,
1912            VersionReleaseLatest::Legacy
1913        ));
1914        assert!(!release_cmd.dry_run);
1915        assert!(!release_cmd.publish);
1916    }
1917
1918    #[test]
1919    fn version_release_parses_dry_run_and_publish() {
1920        let cli = Cli::try_parse_from([
1921            "xbp",
1922            "version",
1923            "release",
1924            "--version",
1925            "1.2.3",
1926            "--publish",
1927            "--dry-run",
1928        ])
1929        .expect("parse");
1930        let Some(Commands::Version(version_cmd)) = cli.command else {
1931            panic!("expected version command");
1932        };
1933        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1934            panic!("expected version release subcommand");
1935        };
1936        assert!(release_cmd.dry_run);
1937        assert!(release_cmd.publish);
1938    }
1939
1940    #[test]
1941    fn version_explicit_flag_parses() {
1942        let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
1943        let Some(Commands::Version(version_cmd)) = cli.command else {
1944            panic!("expected version command");
1945        };
1946        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1947        assert!(version_cmd.target.is_none());
1948        assert!(version_cmd.command.is_none());
1949    }
1950
1951    #[test]
1952    fn version_short_explicit_flag_parses_with_alias() {
1953        let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
1954        let Some(Commands::Version(version_cmd)) = cli.command else {
1955            panic!("expected version command");
1956        };
1957        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1958        assert!(version_cmd.target.is_none());
1959        assert!(version_cmd.command.is_none());
1960    }
1961}