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