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