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 interactive;
9pub mod mcp_tools_export;
10pub mod router;
11pub mod ui;
12
13pub use handlers::*;
14
15use crate::cli::app::AppContext;
16use crate::cli::error::{CliError, CliResult, ErrorFactory};
17use crate::commands::cli_session::{
18    post_cli_command_activity, post_cli_error_report, require_authenticated_cli_session,
19    run_login_status, run_logout, run_whoami, CliCommandActivityPayload, CliErrorReportPayload,
20};
21use crate::commands::curl;
22#[cfg(feature = "openapi-gen")]
23use crate::commands::generate_openapi::{run_generate_openapi, GenerateOpenApiArgs};
24use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
25use crate::commands::redeploy_v2::run_redeploy_v2;
26use crate::commands::{
27    install_package, list_services, open_global_config, pick_service_for_command, run_commit,
28    run_config, run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
29    run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
30    run_config_cloudflare_status, run_config_cloudflare_token_delete,
31    run_config_cloudflare_token_set, run_config_cloudflare_token_show, run_config_crates_login,
32    run_config_crates_logout, run_config_discord, run_config_openapi_setup, run_config_openapi_show,
33    run_config_publish_setup, run_config_release_setup, run_config_secret_delete,
34    run_config_secret_set, run_config_secret_show, run_cursor_ingest, run_generate_config,
35    run_github, run_init,
36    run_login, run_config_heal, run_migrate_config_file, run_publish_command, run_redeploy,
37    run_redeploy_service,
38    run_service_command, run_service_interactive, run_setup, run_todos, run_update_check, run_audit,
39    run_version_bump_command, run_version_command, run_version_discover_services,
40    run_version_domain_command, run_version_release_command, run_version_workspace_command,
41    show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
42    PublishCommandOptions, ReleaseLatestPolicy, UpdateCheckOptions, VersionDomainCommand,
43    VersionDomainCommandOptions, VersionDomainDiagnoseOptions, VersionDomainDoctorOptions,
44    VersionDomainInitOptions, VersionDomainReleaseOptions, VersionDomainSyncOptions,
45    VersionReleaseOptions, WorkspacePublishHealOptions, WorkspacePublishPlanOptions,
46    WorkspacePublishRunOptions, WorkspaceVersionCheckOptions, WorkspaceVersionCommand,
47    WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
48};
49#[cfg(feature = "linear")]
50use crate::commands::{run_config_linear_select_initiative, run_linear};
51use crate::commands::{run_diag, run_nginx, run_resource_monitor, ResourceMonitorOptions};
52use crate::config::{resolve_device_identity, sync_versioning_files_registry};
53use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
54use crate::utils::{
55    find_xbp_config_upwards, git_remote_url_from_metadata, parse_github_repo_from_remote_url,
56};
57use chrono::Utc;
58use clap::{error::ErrorKind as ClapErrorKind, Parser};
59use colored::Colorize;
60use commands::Cli;
61use std::env;
62
63pub async fn run() -> CliResult<()> {
64    // Allow `xbp -h -q deploy`: clap treats -h as exclusive help, so strip root
65    // help flags when a search query is present (subcommand help is unchanged).
66    let argv = preprocess_argv_for_help_query(std::env::args().collect());
67    let cli: Cli = match Cli::try_parse_from(argv) {
68        Ok(cli) => cli,
69        Err(err) => {
70            let kind = err.kind();
71            let rendered = err.to_string();
72            if kind == ClapErrorKind::DisplayVersion {
73                help_render::emit_version_info(env!("CARGO_PKG_VERSION"));
74                return Ok(());
75            }
76            if kind == ClapErrorKind::DisplayHelp
77                || kind == ClapErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
78                || rendered.contains("Manage the XBP API server")
79            {
80                ui::configure_color_output();
81                if help_render::is_root_help_text(&rendered) {
82                    crate::commands::print_help().await;
83                } else {
84                    help_render::emit_styled_help(&rendered, help_render::HelpScope::Auto);
85                }
86                return Ok(());
87            }
88            let error = ErrorFactory::clap_parse(err);
89            report_cli_error("cli", &error).await;
90            return Err(error);
91        }
92    };
93
94    if cli.commands {
95        help_render::print_command_catalog();
96        return Ok(());
97    }
98
99    // `xbp -q deploy` / `xbp -h -q deploy` / `xbp --query webhook`
100    if let Some(query) = cli.query.as_ref().map(|q| q.trim()).filter(|q| !q.is_empty()) {
101        crate::commands::help::print_help_query(query);
102        return Ok(());
103    }
104
105    // Keep plain `xbp` aligned with `xbp --help` instead of entering
106    // interactive project selection flow.
107    if should_print_help(&cli) {
108        ui::configure_color_output();
109        crate::commands::print_help().await;
110        return Ok(());
111    }
112
113    let debug: bool = cli.debug;
114    ui::configure_color_output();
115    let command_name = cli
116        .command
117        .as_ref()
118        .map(commands::command_label)
119        .unwrap_or("interactive");
120    ui::print_cli_header(command_name, debug);
121
122    if let Err(e) = init_logger(debug).await {
123        let _ = log_error(
124            "system",
125            "Failed to initialize logger",
126            Some(&e.to_string()),
127        )
128        .await;
129    }
130    if let Err(e) = sync_versioning_files_registry() {
131        let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
132    }
133
134    let background_cursor_trigger =
135        background_cursor_ingest_trigger(cli.command.as_ref()).map(str::to_string);
136    let background_system_inventory_trigger =
137        background_system_inventory_refresh_trigger(cli.command.as_ref()).map(str::to_string);
138    let mut ctx = AppContext::new(debug);
139    let result = router::dispatch(cli, &mut ctx).await;
140
141    // Soft-ingest ops command results (success + failure) to xbp.app D1.
142    report_cli_command_activity(command_name, &result).await;
143
144    if result.is_ok() {
145        if let Some(trigger) = background_cursor_trigger {
146            if let Err(error) =
147                crate::commands::cursor_ingest::maybe_start_background_cursor_ingest(&trigger)
148            {
149                let _ = log_warn(
150                    "cursor",
151                    "Background Cursor ingest could not be started",
152                    Some(&error),
153                )
154                .await;
155            }
156        }
157        if let Some(trigger) = background_system_inventory_trigger {
158            if let Err(error) = crate::commands::system_inventory_refresh::
159                maybe_start_background_system_inventory_refresh(&trigger)
160            {
161                let _ = log_warn(
162                    "codetime",
163                    "Background system inventory refresh could not be started",
164                    Some(&error),
165                )
166                .await;
167            }
168        }
169    } else if let Err(ref error) = result {
170        report_cli_error(command_name, error).await;
171    }
172
173    result
174}
175
176/// Commands whose results are always mirrored to xbp.app (when logged in).
177fn should_log_command_activity(command: &str) -> bool {
178    matches!(
179        command,
180        "kubernetes"
181            | "deploy"
182            | "service"
183            | "services"
184            | "redeploy"
185            | "redeploy-v2"
186            | "oci"
187            | "cloudflare"
188            | "workers"
189    )
190}
191
192/// Soft-report success/failure for ops commands. Never raises.
193async fn report_cli_command_activity(command: &str, result: &Result<(), CliError>) {
194    if !should_log_command_activity(command) {
195        return;
196    }
197
198    let cwd = env::current_dir().ok();
199    let project_path = cwd
200        .as_ref()
201        .and_then(|dir| find_xbp_config_upwards(dir))
202        .map(|found| found.project_root.display().to_string());
203    let (repository_owner, repository_name) = cwd
204        .as_ref()
205        .and_then(|dir| {
206            let root = find_xbp_config_upwards(dir)
207                .map(|found| found.project_root)
208                .unwrap_or_else(|| dir.clone());
209            git_remote_url_from_metadata(&root, "origin")
210                .ok()
211                .flatten()
212                .and_then(|url| parse_github_repo_from_remote_url(&url))
213        })
214        .map(|(owner, repo)| (Some(owner), Some(repo)))
215        .unwrap_or((None, None));
216
217    let hardware_id = resolve_device_identity()
218        .ok()
219        .map(|device| device.hardware_id);
220    let hostname = env::var("COMPUTERNAME")
221        .or_else(|_| env::var("HOSTNAME"))
222        .ok()
223        .map(|value| value.trim().to_string())
224        .filter(|value| !value.is_empty());
225
226    let (status, message, details, hint, action) = match result {
227        Ok(()) => (
228            "success".to_string(),
229            format!("xbp {command} completed"),
230            None,
231            None,
232            None,
233        ),
234        Err(error) => {
235            let message = redact_sensitive_text(&strip_ansi(&error.to_string()));
236            (
237                "failed".to_string(),
238                message.clone(),
239                extract_labeled_field(&message, "Details")
240                    .or_else(|| extract_labeled_field(&message, "Reason")),
241                extract_labeled_field(&message, "Hint"),
242                extract_labeled_field(&message, "Action"),
243            )
244        }
245    };
246
247    let payload = CliCommandActivityPayload {
248        command: command.to_string(),
249        action,
250        status,
251        message,
252        details,
253        hint,
254        project_path,
255        cwd: cwd.map(|path| path.display().to_string()),
256        repository_owner,
257        repository_name,
258        cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
259        platform: Some(format!(
260            "{}-{}",
261            std::env::consts::OS,
262            std::env::consts::ARCH
263        )),
264        hostname,
265        hardware_id,
266        xbp_version: Some(env!("CARGO_PKG_VERSION").to_string()),
267        occurred_at: Some(Utc::now().to_rfc3339()),
268    };
269
270    let _ = post_cli_command_activity(&payload).await;
271}
272
273/// Soft-report a CLI failure to xbp.app (D1). Never raises; missing login skips.
274async fn report_cli_error(command: &str, error: &CliError) {
275    let message = redact_sensitive_text(&strip_ansi(&error.to_string()));
276    if message.trim().is_empty() {
277        return;
278    }
279
280    let cwd = env::current_dir().ok();
281    let project_path = cwd
282        .as_ref()
283        .and_then(|dir| find_xbp_config_upwards(dir))
284        .map(|found| found.project_root.display().to_string());
285    let (repository_owner, repository_name) = cwd
286        .as_ref()
287        .and_then(|dir| {
288            let root = find_xbp_config_upwards(dir)
289                .map(|found| found.project_root)
290                .unwrap_or_else(|| dir.clone());
291            git_remote_url_from_metadata(&root, "origin")
292                .ok()
293                .flatten()
294                .and_then(|url| parse_github_repo_from_remote_url(&url))
295        })
296        .map(|(owner, repo)| (Some(owner), Some(repo)))
297        .unwrap_or((None, None));
298
299    let hardware_id = resolve_device_identity()
300        .ok()
301        .map(|device| device.hardware_id);
302    let hostname = env::var("COMPUTERNAME")
303        .or_else(|_| env::var("HOSTNAME"))
304        .ok()
305        .map(|value| value.trim().to_string())
306        .filter(|value| !value.is_empty());
307
308    let payload = CliErrorReportPayload {
309        command: command.to_string(),
310        action: extract_labeled_field(&message, "Action"),
311        kind: extract_error_kind(&message),
312        message: message.clone(),
313        details: extract_labeled_field(&message, "Details")
314            .or_else(|| extract_labeled_field(&message, "Reason")),
315        hint: extract_labeled_field(&message, "Hint"),
316        project_path,
317        cwd: cwd.map(|path| path.display().to_string()),
318        repository_owner,
319        repository_name,
320        cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
321        platform: Some(format!(
322            "{}-{}",
323            std::env::consts::OS,
324            std::env::consts::ARCH
325        )),
326        hostname,
327        hardware_id,
328        occurred_at: Some(Utc::now().to_rfc3339()),
329    };
330
331    let _ = post_cli_error_report(&payload).await;
332}
333
334fn strip_ansi(input: &str) -> String {
335    let bytes = input.as_bytes();
336    let mut out = String::with_capacity(input.len());
337    let mut i = 0;
338    while i < bytes.len() {
339        if bytes[i] == 0x1b {
340            i += 1;
341            if i < bytes.len() && bytes[i] == b'[' {
342                i += 1;
343                while i < bytes.len() {
344                    let b = bytes[i];
345                    i += 1;
346                    if (b'@'..=b'~').contains(&b) {
347                        break;
348                    }
349                }
350            }
351            continue;
352        }
353        out.push(bytes[i] as char);
354        i += 1;
355    }
356    out
357}
358
359fn redact_sensitive_text(input: &str) -> String {
360    // Best-effort: avoid shipping obvious tokens/keys if they appear in stderr.
361    let mut out = input.to_string();
362    for pattern in [
363        r"(?i)ghp_[A-Za-z0-9]{20,}",
364        r"(?i)gho_[A-Za-z0-9]{20,}",
365        r"(?i)github_pat_[A-Za-z0-9_]{20,}",
366        r"(?i)xbp_[A-Za-z0-9_-]{20,}",
367        r"(?i)sk-[A-Za-z0-9]{20,}",
368        r"(?i)Bearer\s+[A-Za-z0-9._\-+/=]{16,}",
369    ] {
370        if let Ok(re) = regex::Regex::new(pattern) {
371            out = re.replace_all(&out, "[redacted]").into_owned();
372        }
373    }
374    if out.len() > 8_000 {
375        out.truncate(8_000);
376        out.push_str("…");
377    }
378    out
379}
380
381fn extract_labeled_field(message: &str, label: &str) -> Option<String> {
382    let prefix = format!("{label}:");
383    for line in message.lines() {
384        let trimmed = line.trim();
385        if let Some(rest) = trimmed.strip_prefix(&prefix).or_else(|| {
386            // colored headers sometimes leave trailing spaces after labels
387            trimmed
388                .split_once(':')
389                .filter(|(head, _)| head.trim().eq_ignore_ascii_case(label))
390                .map(|(_, rest)| rest)
391        }) {
392            let value = rest.trim();
393            if !value.is_empty() {
394                return Some(value.to_string());
395            }
396        }
397    }
398    None
399}
400
401fn extract_error_kind(message: &str) -> Option<String> {
402    let trimmed = message.trim_start();
403    if let Some(rest) = trimmed.strip_prefix('[') {
404        if let Some(end) = rest.find(']') {
405            let kind = rest[..end].trim();
406            if !kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
407                return Some(kind.to_string());
408            }
409        }
410    }
411    // SECRETS / VERSION-style banners
412    message
413        .lines()
414        .next()
415        .and_then(|line| line.split_whitespace().next())
416        .map(|word| word.trim().to_ascii_uppercase())
417        .filter(|word| {
418            word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && word.len() <= 24
419        })
420}
421
422pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
423    if let Err(e) = run_init(debug).await {
424        let _ = log_error("init", "Init failed", Some(&e)).await;
425        return Err(e.into());
426    }
427    Ok(())
428}
429
430pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
431    if cmd.push {
432        crate::cli::auto_commit::set_explicit_push(true);
433    }
434
435    let args = CommitArgs {
436        dry_run: cmd.dry_run,
437        push: crate::cli::auto_commit::explicit_push_requested(),
438        no_ai: cmd.no_ai,
439        model: cmd.model,
440        scope: cmd.scope,
441    };
442
443    match run_commit(args).await {
444        Ok(CommitRunOutcome::Completed) | Ok(CommitRunOutcome::NothingToCommit) => Ok(()),
445        Err(error) => Err(map_commit_error(error)),
446    }
447}
448
449pub(super) async fn handle_push() -> CliResult<()> {
450    match crate::commands::push::run_push().await {
451        Ok(()) => Ok(()),
452        Err(error) => Err(ErrorFactory::operation(
453            "push",
454            "sync and push current branch",
455            error,
456            Some(
457                "If rebase conflicts remain, resolve them, run `git rebase --continue`, then `xbp push` again. Dirty WIP is stashed as `xbp auto-rebase: temporary stash`.",
458            ),
459        )),
460    }
461}
462
463pub(super) async fn handle_deploy(cmd: commands::DeployCmd, debug: bool) -> CliResult<()> {
464    use crate::commands::deploy_engine::{
465        peel_deploy_mode_positional, resolve_deploy_invocation, run_deploy, run_deploy_reset,
466        DeployInvocationOptions, DeployRequest, DeployResetRequest,
467    };
468    use xbp_deploy::{DeployFlags, DeployMode};
469
470    // --reset short-circuits normal plan/run (config + local history only).
471    if cmd.reset {
472        let (project_root, config) =
473            match crate::commands::service::load_xbp_config_with_root().await {
474                Ok(v) => v,
475                Err(e) => {
476                    return Err(ErrorFactory::operation(
477                        "deploy",
478                        "load project config",
479                        e,
480                        Some("Run `xbp init` or `xbp version discover` inside a project."),
481                    ));
482                }
483            };
484        let mut reset = DeployResetRequest {
485            kubernetes: cmd.reset_kubernetes,
486            deploy_config: cmd.reset_deploy_config,
487            service_deploy: cmd.reset_service_deploy,
488            history: cmd.reset_history,
489            services: cmd.reset_services.clone(),
490            env: cmd.reset_env.clone(),
491            full_service_deploy: cmd.full_service_deploy,
492            dry_run: cmd.dry_run,
493            yes: cmd.yes,
494        };
495        if cmd.all {
496            reset.enable_all();
497        }
498        return match run_deploy_reset(&project_root, config, reset) {
499            Ok(_) => Ok(()),
500            Err(error) => Err(ErrorFactory::operation(
501                "deploy",
502                "reset deploy settings",
503                error,
504                Some(
505                    "Examples:\n  xbp deploy --reset --all --yes\n  xbp deploy --reset --reset-kubernetes --reset-service-deploy\n  xbp deploy --reset --reset-env production --yes",
506                ),
507            )),
508        };
509    }
510
511    // History / engine maintenance does not need a service target.
512    // Short-circuit before interactive target resolution:
513    //   xbp deploy --engine-check [--scan]
514    //   xbp deploy --repair-history
515    //   xbp deploy --history
516    //   xbp deploy --redact-history
517    //   xbp deploy --revitalize <id>
518    if cmd.engine_check
519        || cmd.repair_history
520        || cmd.redact_history
521        || cmd.revitalize.is_some()
522        || cmd.history
523    {
524        let request = DeployRequest {
525            target: cmd
526                .target
527                .clone()
528                .filter(|s| !s.trim().is_empty())
529                .unwrap_or_else(|| "all".into()),
530            env: cmd.env.clone(),
531            mode: if cmd.history {
532                DeployMode::History
533            } else {
534                DeployMode::Plan
535            },
536            flags: DeployFlags {
537                yes: true,
538                json: cmd.json,
539                history_limit: cmd.limit.max(1),
540                output: cmd.output.clone(),
541                dry_run: cmd.dry_run,
542                ..DeployFlags::default()
543            },
544            debug,
545            revitalize: cmd.revitalize.clone(),
546            revitalize_out: cmd.revitalize_out.clone(),
547            revitalize_in_place: cmd.revitalize_in_place,
548            redact_history: cmd.redact_history,
549            repair_history: cmd.repair_history,
550            engine_check: cmd.engine_check,
551            scan: cmd.scan,
552        };
553        return match run_deploy(request).await {
554            Ok(()) => Ok(()),
555            Err(error) => Err(ErrorFactory::operation(
556                "deploy",
557                "deploy history",
558                error.to_string(),
559                Some(
560                    "Examples:\n  xbp deploy --engine-check\n  xbp deploy --engine-check --scan\n  xbp deploy --repair-history\n  xbp deploy --history\n  xbp deploy --redact-history",
561                ),
562            )),
563        };
564    }
565
566    // Exactly one mode: clap ArgGroup enforces mutual exclusion when multiple set.
567    let mode_count = [
568        cmd.plan,
569        cmd.run,
570        cmd.verify,
571        cmd.status,
572        cmd.history,
573        cmd.promote.is_some(),
574    ]
575    .into_iter()
576    .filter(|v| *v)
577    .count();
578    if mode_count > 1 {
579        return Err(ErrorFactory::validation(
580            "deploy",
581            "exactly one of --plan, --run, --verify, --status, --history, --promote is allowed",
582            Some(
583                "Plan only (no mutations): `xbp deploy --plan`\n\
584                 Apply after review: `xbp deploy --run`\n\
585                 With a service: `xbp deploy athena --env production --plan`",
586            ),
587        ));
588    }
589
590    let mode_explicit = mode_count > 0;
591    let mode = if cmd.history {
592        DeployMode::History
593    } else if cmd.promote.is_some() {
594        DeployMode::Promote
595    } else if cmd.verify {
596        DeployMode::Verify
597    } else if cmd.status {
598        DeployMode::Status
599    } else if cmd.run {
600        DeployMode::Run
601    } else {
602        DeployMode::Plan
603    };
604
605    // `xbp deploy plan` / `xbp deploy run` — mode words are not service names.
606    let peeled = match peel_deploy_mode_positional(cmd.target.clone(), mode, mode_explicit) {
607        Ok(v) => v,
608        Err(e) => {
609            return Err(ErrorFactory::validation(
610                "deploy",
611                e,
612                Some(
613                    "Plan only (no mutations): `xbp deploy --plan`\n\
614                     Apply: `xbp deploy --run` (or `xbp deploy <service> --run`)",
615                ),
616            ));
617        }
618    };
619
620    // Load project config early so interactive target/mode/env pickers can list services.
621    let (_project_root, project_config) =
622        match crate::commands::service::load_xbp_config_with_root().await {
623            Ok(v) => v,
624            Err(e) => {
625                return Err(ErrorFactory::operation(
626                    "deploy",
627                    "load project config",
628                    e,
629                    Some("Run `xbp init` or `xbp version discover` inside a project."),
630                ));
631            }
632        };
633
634    let resolved = match resolve_deploy_invocation(
635        &project_config,
636        peeled.target,
637        cmd.env,
638        peeled.mode,
639        peeled.mode_explicit,
640        cmd.yes,
641        cmd.json || cmd.output.is_some(),
642        DeployInvocationOptions {
643            promote_tag: cmd.promote.clone(),
644            local_image: cmd.local_image,
645            push_image: cmd.push_image,
646            skip_build: cmd.skip_build,
647            dry_run: cmd.dry_run,
648            skip_doctor: cmd.skip_doctor,
649            skip_health: cmd.skip_health,
650            skip_rollout: cmd.skip_rollout,
651            skip_apply: cmd.skip_apply,
652            skip_oci: cmd.skip_oci,
653            skip_expose: cmd.skip_expose,
654            kube_context: cmd.context.clone(),
655        },
656    ) {
657        Ok(v) => v,
658        Err(e) => {
659            let hint = if e.to_ascii_lowercase().contains("promote") {
660                "Example: `xbp deploy athena --promote stable --dry-run`, or run `xbp deploy` and pick promote + tag interactively."
661            } else if e.to_ascii_lowercase().contains("mode") {
662                "Plan only (no mutations): `xbp deploy --plan`. Apply: `xbp deploy --run`."
663            } else if e.to_ascii_lowercase().contains("image") || e.to_ascii_lowercase().contains("cancelled") {
664                "In a TTY, pick image strategy (local load / registry push / skip) and apply toggles. Or pass --local-image / --push-image / --skip-build."
665            } else {
666                "Pass a service name / group / `all`, or run in a TTY for the interactive picker. Modes: --plan (default, no mutations) or --run (apply)."
667            };
668            return Err(ErrorFactory::operation(
669                "deploy",
670                "resolve deploy target",
671                e,
672                Some(hint),
673            ));
674        }
675    };
676
677    if matches!(resolved.mode, DeployMode::Promote) && resolved.promote_tag.is_none() {
678        return Err(ErrorFactory::validation(
679            "deploy",
680            "promote mode requires a tag",
681            Some("Example: `xbp deploy athena --promote stable --dry-run`, or run interactively and choose a tag."),
682        ));
683    }
684
685    let destinations = if cmd.to.is_empty() {
686        Vec::new()
687    } else {
688        match xbp_deploy::parse_destinations(&cmd.to) {
689            Ok(d) => d,
690            Err(e) => {
691                return Err(ErrorFactory::validation(
692                    "deploy",
693                    e,
694                    Some(&format!(
695                        "Known destinations: {}",
696                        xbp_deploy::known_destinations().join(", ")
697                    )),
698                ));
699            }
700        }
701    };
702
703    let request = DeployRequest {
704        target: resolved.target,
705        env: resolved.env,
706        mode: resolved.mode,
707        flags: DeployFlags {
708            dry_run: resolved.dry_run,
709            skip_oci: resolved.skip_oci,
710            skip_build: resolved.skip_build,
711            local_image: resolved.local_image,
712            require_new_container_image: cmd.require_new_container_image,
713            bootstrap_workload: !cmd.no_bootstrap_workload,
714            doctor: cmd.doctor,
715            skip_doctor: resolved.skip_doctor,
716            skip_apply: resolved.skip_apply,
717            skip_rollout: resolved.skip_rollout,
718            skip_expose: resolved.skip_expose,
719            skip_health: resolved.skip_health,
720            yes: cmd.yes,
721            json: cmd.json,
722            namespace: cmd.namespace,
723            context: cmd.context,
724            promote_tag: resolved.promote_tag.clone().or(cmd.promote),
725            history_limit: cmd.limit,
726            output: cmd.output,
727            destinations,
728            only_services: cmd.only,
729            exclude_services: cmd.exclude,
730        },
731        debug,
732        revitalize: cmd.revitalize,
733        revitalize_out: cmd.revitalize_out,
734        revitalize_in_place: cmd.revitalize_in_place,
735        redact_history: cmd.redact_history,
736        repair_history: cmd.repair_history,
737        engine_check: cmd.engine_check,
738        scan: cmd.scan,
739    };
740
741    // Discord for --run/--promote is sent inside `run_deploy` (with k8s/CF-aware events).
742    // Do not double-notify here; plan/verify stay quiet unless those paths emit.
743    match run_deploy(request).await {
744        Ok(()) => Ok(()),
745        Err(error) => {
746            Err(ErrorFactory::operation(
747                "deploy",
748                "orchestrate service deploy",
749                error.to_string(),
750                Some(deploy_failure_hint(&error.to_string())),
751            ))
752        }
753    }
754}
755
756/// Pick an OPERATION hint from the deploy error body (avoid always blaming deploy.envs).
757fn deploy_failure_hint(error: &str) -> &'static str {
758    let lower = error.to_ascii_lowercase();
759    // Docker *build* failures often contain `docker-desktop://dashboard/build/...` which
760    // must not be misread as Docker Desktop Kubernetes connectivity.
761    let looks_like_docker_build = lower.contains("build/push")
762        || lower.contains("build/load")
763        || lower.contains("docker build")
764        || lower.contains("buildx")
765        || lower.contains("failed to solve")
766        || lower.contains("load build context")
767        || lower.contains("docker-desktop://dashboard/build");
768
769    if looks_like_docker_build {
770        return "Dockerfile / build-context failed (not a Kubernetes API issue). Fix Dockerfile, shrink context (`.dockerignore` for `.next` / `node_modules`), or for Cloudflare-only apps re-run so the OpenNext/Wrangler path is used. For k8s local clusters: `--local-image` skips GHCR push.";
771    }
772
773    if let Some(hint) = xbp_k8s::kubectl_failure_hint(error) {
774        return hint;
775    }
776    if lower.contains("cancelled") || lower.contains("deploy cancelled") {
777        "Re-run and confirm the apply prompt, or pass --yes for non-interactive apply."
778    } else if lower.contains("unknown deploy target") || lower.contains("unknown target") {
779        "Check service/group names in .xbp/xbp.yaml, or run `xbp deploy --help`."
780    } else if lower.contains("oci")
781        || lower.contains("manifest unknown")
782        || lower.contains("unauthorized")
783        || lower.contains("denied")
784    {
785        "Check image reference and registry auth (GH_TOKEN / `xbp config oci`), or re-run with --local-image for local clusters (no registry push)."
786    } else if lower.contains("health") {
787        "Inspect service readiness and health URLs in deploy.envs; use --skip-health if probing is not needed."
788    } else if lower.contains("notfound")
789        || lower.contains("not found")
790        || lower.contains("deployments.apps")
791        || lower.contains("rollout failed")
792    {
793        "Workload missing in the namespace used for rollout. Confirm services[].deploy.envs.<env>.namespace (e.g. athena), workload name, and that apply ran against that namespace. Use `xbp kubernetes namespaces` and `xbp deploy <target> --plan`."
794    } else if lower.contains("missing") && lower.contains("deploy.envs") {
795        "Missing deploy.envs is configured interactively when you re-run in a TTY (or pass --yes to accept derived defaults). Use `xbp deploy <target> --env <env> --plan` first."
796    } else if lower.contains("docker desktop")
797        || lower.contains("docker-desktop")
798        || lower.contains("kindest")
799        || lower.contains("desktop-containerd")
800    {
801        "Run `xbp kubernetes doctor` (interactive) or `xbp kubernetes doctor --yes` (auto kubeadm). Deploy also offers recovery when Docker Desktop Kubernetes is down."
802    } else {
803        "Use `xbp deploy <target> --env <env> --plan` to inspect the plan. Pass --yes for non-interactive apply. For cluster issues, check `kubectl config current-context` and kubernetes.context / kubernetes.kubeconfig, or `xbp kubernetes doctor`."
804    }
805}
806
807pub(super) async fn handle_oci(cmd: commands::OciCmd, debug: bool) -> CliResult<()> {
808    match crate::oci::run_oci(cmd, debug).await {
809        Ok(()) => Ok(()),
810        Err(error) => Err(ErrorFactory::operation(
811            "oci",
812            "run OCI registry command",
813            error,
814            Some(
815                "Check image reference, registry auth (GH_TOKEN / xbp config oci), or use --anonymous for public pulls.",
816            ),
817        )),
818    }
819}
820
821fn map_commit_error(error: CommitError) -> CliError {
822    match error {
823        CommitError::PushFailed {
824            summary,
825            commit_sha,
826        } => {
827            let details = if let Some(commit_sha) = commit_sha {
828                format!("Created local commit {commit_sha} but push failed: {summary}")
829            } else {
830                format!("Push failed: {summary}")
831            };
832            ErrorFactory::operation(
833                "commit",
834                "push changes",
835                details,
836                commit_push_hint(&summary),
837            )
838        }
839        CommitError::TerminalSessionsBlocked(message) => ErrorFactory::validation(
840            "commit",
841            message,
842            Some("Add `/terminals` to `.gitignore` and run `git rm -r --cached terminals/`."),
843        ),
844        CommitError::Operation(message) => ErrorFactory::operation(
845            "commit",
846            "create conventional commit",
847            message,
848            Some("Run `xbp commit --dry-run` after saving changes."),
849        ),
850    }
851}
852
853fn commit_push_hint(summary: &str) -> Option<&'static str> {
854    let lowered = summary.to_ascii_lowercase();
855    if lowered.contains("100 mib")
856        || lowered.contains("large file")
857        || lowered.contains("gh001")
858        || lowered.contains("git-lfs")
859        || lowered.contains("file size limit")
860    {
861        Some(
862            "XBP auto-excludes ≥100 MiB files from new commits and rewrites unpushed history when possible. Keep local DBs unstaged or use Git LFS; do not force-push rewritten history that others already pulled.",
863        )
864    } else if lowered.contains("unstaged changes")
865        || lowered.contains("uncommitted changes")
866        || lowered.contains("please commit or stash")
867        || lowered.contains("temporary stash")
868    {
869        Some(
870            "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`.",
871        )
872    } else if lowered.contains("auto-rebase failed") || lowered.contains("conflict") {
873        Some("Resolve rebase conflicts, run `git rebase --continue`, then `git push`.")
874    } else if lowered.contains("behind") || lowered.contains("non-fast-forward") {
875        Some("XBP should auto-rebase; if it did not, run `git pull --rebase` then `git push`.")
876    } else {
877        Some("Verify GitHub auth and branch permissions, then push again.")
878    }
879}
880
881pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
882    let options = PublishCommandOptions {
883        dry_run: cmd.dry_run,
884        allow_dirty: cmd.allow_dirty,
885        force: cmd.force,
886        include_prereqs: cmd.include_prereqs,
887        auto_fix: true,
888        target: cmd.target,
889        service: cmd.service,
890        manifest_path: cmd.manifest_path,
891        expected_version: None,
892    };
893
894    if let Err(e) = run_publish_command(options).await {
895        let _ = log_error("publish", "Publish command failed", Some(&e)).await;
896        return Err(ErrorFactory::operation(
897            "publish",
898            "run publish workflow",
899            e,
900            Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
901        ));
902    }
903
904    Ok(())
905}
906
907pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
908    if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
909        let _ = log_error("setup", "Setup failed", Some(&e)).await;
910        return Err(ErrorFactory::operation(
911            "setup",
912            "setup environment",
913            e,
914            Some("Run with `--debug` for command-level output."),
915        ));
916    }
917    Ok(())
918}
919
920pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
921    if let Some(name) = service_name {
922        if let Err(e) = ui::with_loader(
923            &format!("Redeploying service `{}`", name),
924            run_redeploy_service(&name, debug),
925        )
926        .await
927        {
928            let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
929            return Err(ErrorFactory::operation(
930                "redeploy",
931                &format!("redeploy service `{}`", name),
932                e,
933                Some("Verify service name with `xbp services`."),
934            ));
935        }
936    } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
937        let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
938        return Err(ErrorFactory::operation(
939            "redeploy",
940            "redeploy project",
941            e,
942            Some("Try `xbp redeploy <service>` for scoped retries."),
943        ));
944    }
945    Ok(())
946}
947
948pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
949    let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
950    match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
951        Ok(()) => Ok(()),
952        Err(e) => {
953            let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
954            Err(e.into())
955        }
956    }
957}
958
959pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
960    let commands::ConfigCmd {
961        project,
962        no_open,
963        provider,
964    } = cmd;
965
966    if provider.is_some() && (project || no_open) {
967        return Err(ErrorFactory::validation(
968            "config",
969            "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
970            Some("Run either provider key management OR project/global config actions."),
971        ));
972    }
973
974    if let Some(provider_cmd) = provider {
975        match provider_cmd {
976            commands::ConfigProviderCmd::MigrateConfigFile(subcmd) => {
977                run_migrate_config_file(
978                    &subcmd.format,
979                    subcmd.from.as_deref(),
980                    subcmd.output.as_deref(),
981                )
982                .map_err(|error| {
983                    ErrorFactory::operation("config", "migrate config file", error, None)
984                })?;
985            }
986            commands::ConfigProviderCmd::Heal(subcmd) => {
987                run_config_heal(subcmd.from.as_deref(), subcmd.check, subcmd.all).map_err(
988                    |error| {
989                        ErrorFactory::operation(
990                            "config",
991                            "heal project config",
992                            error,
993                            Some("Run `xbp config heal --check` to inspect without writing."),
994                        )
995                    },
996                )?;
997            }
998            commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
999                commands::ConfigSecretAction::SetKey { key } => {
1000                    if let Err(e) = run_config_secret_set("openrouter", key).await {
1001                        let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
1002                        return Err(ErrorFactory::operation(
1003                            "config",
1004                            "set OpenRouter key",
1005                            e,
1006                            Some("Run `xbp config openrouter show` to confirm key state."),
1007                        ));
1008                    }
1009                }
1010                commands::ConfigSecretAction::DeleteKey => {
1011                    if let Err(e) = run_config_secret_delete("openrouter").await {
1012                        let _ =
1013                            log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
1014                        return Err(ErrorFactory::operation(
1015                            "config",
1016                            "delete OpenRouter key",
1017                            e,
1018                            Some("Use `xbp config openrouter show` to verify removal."),
1019                        ));
1020                    }
1021                }
1022                commands::ConfigSecretAction::Show { raw } => {
1023                    if let Err(e) = run_config_secret_show("openrouter", raw).await {
1024                        let _ =
1025                            log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
1026                        return Err(ErrorFactory::operation(
1027                            "config",
1028                            "show OpenRouter key",
1029                            e,
1030                            None,
1031                        ));
1032                    }
1033                }
1034            },
1035            commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
1036                commands::ConfigSecretAction::SetKey { key } => {
1037                    if let Err(e) = run_config_secret_set("github", key).await {
1038                        let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
1039                        return Err(ErrorFactory::operation(
1040                            "config",
1041                            "set GitHub token",
1042                            e,
1043                            Some("Use a token with repo scope for private repos."),
1044                        ));
1045                    }
1046                }
1047                commands::ConfigSecretAction::DeleteKey => {
1048                    if let Err(e) = run_config_secret_delete("github").await {
1049                        let _ =
1050                            log_error("config", "Failed to delete GitHub token", Some(&e)).await;
1051                        return Err(ErrorFactory::operation(
1052                            "config",
1053                            "delete GitHub token",
1054                            e,
1055                            None,
1056                        ));
1057                    }
1058                }
1059                commands::ConfigSecretAction::Show { raw } => {
1060                    if let Err(e) = run_config_secret_show("github", raw).await {
1061                        let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
1062                        return Err(ErrorFactory::operation(
1063                            "config",
1064                            "show GitHub token",
1065                            e,
1066                            None,
1067                        ));
1068                    }
1069                }
1070            },
1071            commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
1072                None => {
1073                    if let Err(e) = run_config_cloudflare_setup().await {
1074                        let _ =
1075                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
1076                        return Err(ErrorFactory::operation(
1077                            "config",
1078                            "configure Cloudflare",
1079                            e,
1080                            Some(
1081                                "Run `xbp config cloudflare status` to inspect credential sources.",
1082                            ),
1083                        ));
1084                    }
1085                }
1086                Some(commands::CloudflareConfigAction::SetKey { key, kind }) => {
1087                    if let Err(e) = run_config_cloudflare_token_set(key, &kind).await {
1088                        let _ =
1089                            log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
1090                        return Err(ErrorFactory::operation(
1091                            "config",
1092                            "set Cloudflare token",
1093                            e,
1094                            Some(
1095                                "Use a user token (`cfut_`) for Workers Builds and/or an account \
1096                                 token (`cfat_`) for Wrangler. `xbp config cloudflare set-key --kind auto`.",
1097                            ),
1098                        ));
1099                    }
1100                }
1101                Some(commands::CloudflareConfigAction::DeleteKey { kind }) => {
1102                    if let Err(e) = run_config_cloudflare_token_delete(&kind).await {
1103                        let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
1104                            .await;
1105                        return Err(ErrorFactory::operation(
1106                            "config",
1107                            "delete Cloudflare token",
1108                            e,
1109                            None,
1110                        ));
1111                    }
1112                }
1113                Some(commands::CloudflareConfigAction::ShowKey { raw }) => {
1114                    if let Err(e) = run_config_cloudflare_token_show(raw).await {
1115                        let _ =
1116                            log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
1117                        return Err(ErrorFactory::operation(
1118                            "config",
1119                            "show Cloudflare token",
1120                            e,
1121                            None,
1122                        ));
1123                    }
1124                }
1125                Some(commands::CloudflareConfigAction::SetAccountId { account_id }) => {
1126                    if let Err(e) = run_config_cloudflare_account_set(account_id).await {
1127                        let _ =
1128                            log_error("config", "Failed to set Cloudflare account ID", Some(&e))
1129                                .await;
1130                        return Err(ErrorFactory::operation(
1131                            "config",
1132                            "set Cloudflare account ID",
1133                            e,
1134                            None,
1135                        ));
1136                    }
1137                }
1138                Some(commands::CloudflareConfigAction::DeleteAccountId) => {
1139                    if let Err(e) = run_config_cloudflare_account_delete().await {
1140                        let _ =
1141                            log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
1142                                .await;
1143                        return Err(ErrorFactory::operation(
1144                            "config",
1145                            "delete Cloudflare account ID",
1146                            e,
1147                            None,
1148                        ));
1149                    }
1150                }
1151                Some(commands::CloudflareConfigAction::ShowAccountId { raw }) => {
1152                    if let Err(e) = run_config_cloudflare_account_show(raw).await {
1153                        let _ =
1154                            log_error("config", "Failed to show Cloudflare account ID", Some(&e))
1155                                .await;
1156                        return Err(ErrorFactory::operation(
1157                            "config",
1158                            "show Cloudflare account ID",
1159                            e,
1160                            None,
1161                        ));
1162                    }
1163                }
1164                Some(commands::CloudflareConfigAction::Login) => {
1165                    if let Err(e) = run_config_cloudflare_login().await {
1166                        let _ = log_error("config", "Failed to link Cloudflare", Some(&e)).await;
1167                        return Err(ErrorFactory::operation(
1168                            "config",
1169                            "link Cloudflare",
1170                            e,
1171                            None,
1172                        ));
1173                    }
1174                }
1175                Some(commands::CloudflareConfigAction::Status) => {
1176                    if let Err(e) = run_config_cloudflare_status().await {
1177                        let _ =
1178                            log_error("config", "Failed to show Cloudflare status", Some(&e)).await;
1179                        return Err(ErrorFactory::operation(
1180                            "config",
1181                            "show Cloudflare status",
1182                            e,
1183                            None,
1184                        ));
1185                    }
1186                }
1187                Some(commands::CloudflareConfigAction::Setup) => {
1188                    if let Err(e) = run_config_cloudflare_setup().await {
1189                        let _ =
1190                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
1191                        return Err(ErrorFactory::operation(
1192                            "config",
1193                            "configure Cloudflare",
1194                            e,
1195                            None,
1196                        ));
1197                    }
1198                }
1199            },
1200            #[cfg(feature = "linear")]
1201            commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
1202                commands::LinearConfigAction::SetKey { key } => {
1203                    if let Err(e) = run_config_secret_set("linear", key).await {
1204                        let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
1205                        return Err(ErrorFactory::operation(
1206                            "config",
1207                            "set Linear API key",
1208                            e,
1209                            Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
1210                        ));
1211                    }
1212                }
1213                commands::LinearConfigAction::DeleteKey => {
1214                    if let Err(e) = run_config_secret_delete("linear").await {
1215                        let _ =
1216                            log_error("config", "Failed to delete Linear API key", Some(&e)).await;
1217                        return Err(ErrorFactory::operation(
1218                            "config",
1219                            "delete Linear API key",
1220                            e,
1221                            None,
1222                        ));
1223                    }
1224                }
1225                commands::LinearConfigAction::Show { raw } => {
1226                    if let Err(e) = run_config_secret_show("linear", raw).await {
1227                        let _ =
1228                            log_error("config", "Failed to show Linear API key", Some(&e)).await;
1229                        return Err(ErrorFactory::operation(
1230                            "config",
1231                            "show Linear API key",
1232                            e,
1233                            None,
1234                        ));
1235                    }
1236                }
1237                commands::LinearConfigAction::SelectInitiative => {
1238                    if let Err(e) = run_config_linear_select_initiative().await {
1239                        let _ = log_error(
1240                            "config",
1241                            "Failed to select repo Linear initiative",
1242                            Some(&e),
1243                        )
1244                        .await;
1245                        return Err(ErrorFactory::operation(
1246                            "config",
1247                            "select repo Linear initiative",
1248                            e,
1249                            Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
1250                        ));
1251                    }
1252                }
1253            },
1254            commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
1255                commands::RegistryConfigAction::SetKey { key } => {
1256                    if let Err(e) = run_config_secret_set("npm", key).await {
1257                        let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
1258                        return Err(ErrorFactory::operation(
1259                            "config",
1260                            "set npm token",
1261                            e,
1262                            Some("Use a valid npm automation or granular publish token."),
1263                        ));
1264                    }
1265                }
1266                commands::RegistryConfigAction::DeleteKey => {
1267                    if let Err(e) = run_config_secret_delete("npm").await {
1268                        let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
1269                        return Err(ErrorFactory::operation(
1270                            "config",
1271                            "delete npm token",
1272                            e,
1273                            None,
1274                        ));
1275                    }
1276                }
1277                commands::RegistryConfigAction::Show { raw } => {
1278                    if let Err(e) = run_config_secret_show("npm", raw).await {
1279                        let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
1280                        return Err(ErrorFactory::operation("config", "show npm token", e, None));
1281                    }
1282                }
1283                commands::RegistryConfigAction::SetupRelease => {
1284                    if let Err(e) = run_config_publish_setup("npm").await {
1285                        let _ =
1286                            log_error("config", "Failed to configure npm publish", Some(&e)).await;
1287                        return Err(ErrorFactory::operation(
1288                            "config",
1289                            "configure npm publish",
1290                            e,
1291                            Some("Run this inside the target XBP project and ensure the package manifest exists."),
1292                        ));
1293                    }
1294                }
1295            },
1296            commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
1297                commands::CratesConfigAction::SetKey { key } => {
1298                    if let Err(e) = run_config_secret_set("crates", key).await {
1299                        let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
1300                        return Err(ErrorFactory::operation(
1301                            "config",
1302                            "set crates token",
1303                            e,
1304                            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."),
1305                        ));
1306                    }
1307                }
1308                commands::CratesConfigAction::DeleteKey => {
1309                    if let Err(e) = run_config_secret_delete("crates").await {
1310                        let _ =
1311                            log_error("config", "Failed to delete crates token", Some(&e)).await;
1312                        return Err(ErrorFactory::operation(
1313                            "config",
1314                            "delete crates token",
1315                            e,
1316                            None,
1317                        ));
1318                    }
1319                }
1320                commands::CratesConfigAction::Show { raw } => {
1321                    if let Err(e) = run_config_secret_show("crates", raw).await {
1322                        let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
1323                        return Err(ErrorFactory::operation(
1324                            "config",
1325                            "show crates token",
1326                            e,
1327                            None,
1328                        ));
1329                    }
1330                }
1331                commands::CratesConfigAction::SetupRelease => {
1332                    if let Err(e) = run_config_publish_setup("crates").await {
1333                        let _ = log_error("config", "Failed to configure crates publish", Some(&e))
1334                            .await;
1335                        return Err(ErrorFactory::operation(
1336                            "config",
1337                            "configure crates publish",
1338                            e,
1339                            Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
1340                        ));
1341                    }
1342                }
1343                commands::CratesConfigAction::Login { key } => {
1344                    if let Err(e) = run_config_crates_login(key).await {
1345                        let _ = log_error("config", "Failed to log Cargo into crates.io", Some(&e))
1346                            .await;
1347                        return Err(ErrorFactory::operation(
1348                            "config",
1349                            "log Cargo into crates.io",
1350                            e,
1351                            Some("Store a crates token with `xbp config crates set-key` first, or pass it directly to `xbp config crates login <token>`."),
1352                        ));
1353                    }
1354                }
1355                commands::CratesConfigAction::Logout => {
1356                    if let Err(e) = run_config_crates_logout().await {
1357                        let _ =
1358                            log_error("config", "Failed to log Cargo out of crates.io", Some(&e))
1359                                .await;
1360                        return Err(ErrorFactory::operation(
1361                            "config",
1362                            "log Cargo out of crates.io",
1363                            e,
1364                            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."),
1365                        ));
1366                    }
1367                }
1368            },
1369            commands::ConfigProviderCmd::Release(subcmd) => match subcmd.action {
1370                commands::ReleaseConfigAction::Setup => {
1371                    if let Err(e) = run_config_release_setup().await {
1372                        let _ =
1373                            log_error("config", "Failed to configure release settings", Some(&e))
1374                                .await;
1375                        return Err(ErrorFactory::operation(
1376                            "config",
1377                            "configure release settings",
1378                            e,
1379                            Some("Run this inside the target XBP project to update .xbp/xbp.toml (or existing project config)."),
1380                        ));
1381                    }
1382                }
1383            },
1384            commands::ConfigProviderCmd::Discord(subcmd) => {
1385                if let Err(e) = run_config_discord(subcmd.action).await {
1386                    let _ = log_error("config", "Failed to configure Discord webhook", Some(&e))
1387                        .await;
1388                    return Err(ErrorFactory::operation(
1389                        "config",
1390                        "configure Discord webhook notifications",
1391                        e,
1392                        Some(
1393                            "Run `xbp config discord setup` inside a project (writes .xbp/xbp.toml by default), or use `--global` for ~/.xbp/config.yaml. Set DISCORD_WEBHOOK_URL as an alternative.",
1394                        ),
1395                    ));
1396                }
1397            }
1398            commands::ConfigProviderCmd::Openapi(subcmd) => match subcmd.action {
1399                None | Some(commands::OpenapiConfigAction::Setup) => {
1400                    if let Err(e) = run_config_openapi_setup().await {
1401                        let _ =
1402                            log_error("config", "Failed to configure OpenAPI generation", Some(&e))
1403                                .await;
1404                        return Err(ErrorFactory::operation(
1405                            "config",
1406                            "configure OpenAPI generation",
1407                            e,
1408                            Some("Run this inside the target XBP project to update .xbp/xbp.toml (or existing project config)."),
1409                        ));
1410                    }
1411                }
1412                Some(commands::OpenapiConfigAction::Show) => {
1413                    if let Err(e) = run_config_openapi_show().await {
1414                        let _ =
1415                            log_error("config", "Failed to show OpenAPI config", Some(&e)).await;
1416                        return Err(ErrorFactory::operation(
1417                            "config",
1418                            "show OpenAPI config",
1419                            e,
1420                            Some("Run this inside the target XBP project."),
1421                        ));
1422                    }
1423                }
1424            },
1425            commands::ConfigProviderCmd::Oci(subcmd) => {
1426                if let Err(e) = crate::oci::run_config_oci(subcmd.action).await {
1427                    let _ = log_error("config", "Failed to configure OCI registry credentials", Some(&e))
1428                        .await;
1429                    return Err(ErrorFactory::operation(
1430                        "config",
1431                        "configure OCI registry credentials",
1432                        e,
1433                        Some(
1434                            "Use `xbp config oci set-registry-token --registry ghcr.io --username USER` (token masked in show).",
1435                        ),
1436                    ));
1437                }
1438            }
1439        }
1440    } else if project {
1441        if let Err(e) = run_config(debug).await {
1442            return Err(ErrorFactory::operation(
1443                "config",
1444                "read project config",
1445                e,
1446                Some("Ensure you're inside an XBP project root."),
1447            ));
1448        }
1449    } else if let Err(e) = open_global_config(no_open).await {
1450        let _ = log_error("config", "Failed to open global config", Some(&e)).await;
1451        return Err(ErrorFactory::operation(
1452            "config",
1453            "open global config",
1454            e,
1455            None,
1456        ));
1457    }
1458    Ok(())
1459}
1460
1461pub(super) async fn handle_install(
1462    package: Option<String>,
1463    list: bool,
1464    force: bool,
1465    debug: bool,
1466) -> CliResult<()> {
1467    if list {
1468        crate::commands::print_install_targets_help();
1469        return Ok(());
1470    }
1471
1472    let Some(package) = package else {
1473        crate::commands::print_install_empty_state();
1474        return Ok(());
1475    };
1476
1477    if package.trim().is_empty() {
1478        crate::commands::print_install_empty_state();
1479        return Ok(());
1480    }
1481
1482    if crate::commands::is_install_listing_request(&package) {
1483        crate::commands::print_install_targets_help();
1484        return Ok(());
1485    }
1486
1487    let install_msg: String = format!("Installing package: {}", package);
1488    let _ = log_info("install", &install_msg, None).await;
1489    match ui::with_loader(
1490        &format!("Installing package `{}`", package),
1491        install_package(&package, force, debug),
1492    )
1493    .await
1494    {
1495        Ok(()) => {
1496            let success_msg = format!("Successfully installed: {}", package);
1497            let _ = log_success("install", &success_msg, None).await;
1498            Ok(())
1499        }
1500        Err(e) => Err(e.into()),
1501    }
1502}
1503
1504pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
1505    let url = cmd
1506        .url
1507        .unwrap_or_else(|| "https://example.com/api".to_string());
1508    if let Err(e) = ui::with_loader(
1509        &format!("Requesting {}", url),
1510        curl::run_curl(&url, cmd.no_timeout, debug),
1511    )
1512    .await
1513    {
1514        let _ = log_error("curl", "Curl command failed", Some(&e)).await;
1515        return Err(ErrorFactory::operation(
1516            "curl",
1517            "execute request",
1518            e,
1519            Some("Double-check the URL and network connectivity."),
1520        ));
1521    }
1522    Ok(())
1523}
1524
1525pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
1526    if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
1527        let _ = log_error("services", "Failed to list services", Some(&e)).await;
1528        return Err(ErrorFactory::operation(
1529            "services",
1530            "list services",
1531            e,
1532            Some("Ensure xbp config is present and valid."),
1533        ));
1534    }
1535    Ok(())
1536}
1537
1538pub(super) async fn handle_service(
1539    command: Option<String>,
1540    service_name: Option<String>,
1541    debug: bool,
1542) -> CliResult<()> {
1543    match (command.as_deref(), service_name.as_deref()) {
1544        // Bare `xbp service` → preview + interactive picker (service then command)
1545        (None, None) => {
1546            if let Err(e) = run_service_interactive(debug).await {
1547                let _ = log_error("service", "Interactive service run failed", Some(&e)).await;
1548                return Err(ErrorFactory::operation(
1549                    "service",
1550                    "interactive picker",
1551                    e,
1552                    None,
1553                ));
1554            }
1555        }
1556        // `xbp service <cmd>` (no name) → pick service that supports the cmd
1557        (Some(cmd), None) if cmd != "help" && cmd != "--help" => {
1558            if let Err(e) = pick_service_for_command(cmd, debug).await {
1559                let _ = log_error(
1560                    "service",
1561                    &format!("Service pick for '{}' failed", cmd),
1562                    Some(&e),
1563                )
1564                .await;
1565                return Err(ErrorFactory::operation(
1566                    "service",
1567                    &format!("pick service for {}", cmd),
1568                    e,
1569                    Some("Pass a service name explicitly, or ensure commands are defined in xbp config."),
1570                ));
1571            }
1572        }
1573        // `xbp service help <name>` or `xbp service --help <name>`
1574        (Some(cmd), Some(name)) if cmd == "help" || cmd == "--help" => {
1575            if let Err(e) = show_service_help(name).await {
1576                let _ = log_error("service", "Failed to show service help", Some(&e)).await;
1577                return Err(ErrorFactory::operation(
1578                    "service",
1579                    "show service help",
1580                    e,
1581                    None,
1582                ));
1583            }
1584        }
1585        // `xbp service <cmd> <name>` — classic direct execution
1586        (Some(cmd), Some(name)) => {
1587            if let Err(e) = run_service_command(cmd, name, debug).await {
1588                let _ = log_error(
1589                    "service",
1590                    &format!("Service command '{}' failed", cmd),
1591                    Some(&e),
1592                )
1593                .await;
1594                return Err(ErrorFactory::operation(
1595                    "service",
1596                    &format!("run `{}` for `{}`", cmd, name),
1597                    e,
1598                    Some("Check available services via `xbp services`."),
1599                ));
1600            }
1601        }
1602        // `xbp service <name>` (no explicit command) — treat as show service info / help
1603        (None, Some(name)) => {
1604            if let Err(e) = show_service_help(name).await {
1605                // fall back to usage if help fails (e.g. unknown service)
1606                let _ = log_warn(
1607                    "service",
1608                    &format!("Could not show help for service '{}': {}", name, e),
1609                    None,
1610                )
1611                .await;
1612                print_service_usage();
1613            }
1614        }
1615        // `xbp service help` (no name)
1616        (Some(_), None) => {
1617            print_service_usage();
1618        }
1619    }
1620    Ok(())
1621}
1622
1623pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
1624    if let Err(e) = run_nginx(cmd, debug).await {
1625        let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
1626        return Err(ErrorFactory::operation(
1627            "nginx",
1628            "execute nginx command",
1629            e.to_string(),
1630            Some("Try `xbp nginx --help` for command syntax."),
1631        ));
1632    }
1633    Ok(())
1634}
1635
1636pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
1637    if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
1638        let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
1639        return Err(ErrorFactory::operation(
1640            "diag",
1641            "run diagnostics",
1642            e.to_string(),
1643            Some("Re-run with `--debug` for more context."),
1644        ));
1645    }
1646    Ok(())
1647}
1648
1649pub(super) async fn handle_resource(cmd: commands::ResourceCmd, _debug: bool) -> CliResult<()> {
1650    let opts = ResourceMonitorOptions {
1651        json: cmd.json,
1652        watch_seconds: cmd.watch_seconds,
1653        samples: cmd.samples,
1654    };
1655    if let Err(e) = run_resource_monitor(opts).await {
1656        return Err(ErrorFactory::operation(
1657            "resource",
1658            "sample XBP process / cache usage",
1659            e,
1660            Some("Try `xbp resource --json` or ensure process listing is allowed."),
1661        ));
1662    }
1663    Ok(())
1664}
1665
1666pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
1667    match cmd.command {
1668        commands::GenerateSubCommand::Config(subcmd) => {
1669            let args = GenerateConfigArgs {
1670                force: subcmd.force,
1671                update: subcmd.update,
1672                from_json: subcmd.from_json,
1673            };
1674            if let Err(e) = run_generate_config(args, debug).await {
1675                let _ = log_error(
1676                    "generate-config",
1677                    "Failed to generate project config",
1678                    Some(&e),
1679                )
1680                .await;
1681                return Err(ErrorFactory::operation(
1682                    "generate-config",
1683                    "generate .xbp/xbp.toml",
1684                    e,
1685                    Some("Use --update to refresh an existing config or --force to overwrite it."),
1686                ));
1687            }
1688        }
1689        #[cfg(feature = "openapi-gen")]
1690        commands::GenerateSubCommand::Openapi(subcmd) => {
1691            let args = GenerateOpenApiArgs {
1692                services: subcmd.service,
1693                all: subcmd.all,
1694                aggregate: false,
1695                format: subcmd.format,
1696                output: subcmd.output,
1697                check: subcmd.check,
1698                dry_run: subcmd.dry_run,
1699                strict: subcmd.strict,
1700                no_cache: subcmd.no_cache,
1701                version: None,
1702            };
1703            if let Err(e) = run_generate_openapi(args, debug).await {
1704                let _ = log_error("generate-openapi", "Failed to generate OpenAPI", Some(&e)).await;
1705                return Err(ErrorFactory::operation(
1706                    "generate-openapi",
1707                    "generate OpenAPI documents",
1708                    e,
1709                    Some("Check openapi settings in project config (.xbp/xbp.toml by default) or use --strict for unresolved type details."),
1710                ));
1711            }
1712        }
1713        commands::GenerateSubCommand::Systemd(subcmd) => {
1714            let args = GenerateSystemdArgs {
1715                output_dir: subcmd.output_dir,
1716                service: subcmd.service,
1717                api: subcmd.api,
1718            };
1719            if let Err(e) = run_generate_systemd(args, debug).await {
1720                let _ = log_error(
1721                    "generate-systemd",
1722                    "Failed to generate systemd units",
1723                    Some(&e),
1724                )
1725                .await;
1726                return Err(ErrorFactory::operation(
1727                    "generate-systemd",
1728                    "generate unit files",
1729                    e,
1730                    Some("Use a writable `--output-dir` or run with elevated permissions."),
1731                ));
1732            }
1733        }
1734    }
1735    Ok(())
1736}
1737
1738pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
1739    if let Err(e) = crate::commands::run_done(
1740        cmd.root,
1741        cmd.since,
1742        cmd.output,
1743        cmd.no_ai,
1744        cmd.recursive,
1745        cmd.exclude,
1746    )
1747    .await
1748    {
1749        let _ = log_error("done", "Done command failed", Some(&e)).await;
1750        return Err(e.into());
1751    }
1752    Ok(())
1753}
1754
1755#[cfg(feature = "linear")]
1756pub(super) async fn handle_linear(cmd: commands::LinearIssuesCmd, _debug: bool) -> CliResult<()> {
1757    if let Err(e) = run_linear(cmd).await {
1758        let _ = log_error("linear", "Linear command failed", Some(&e)).await;
1759        return Err(ErrorFactory::operation(
1760            "linear",
1761            "run Linear issues command",
1762            e,
1763            Some("Configure a key with `xbp config linear set-key`."),
1764        ));
1765    }
1766    Ok(())
1767}
1768
1769pub(super) async fn handle_github(cmd: commands::GithubIssuesCmd, _debug: bool) -> CliResult<()> {
1770    if let Err(e) = run_github(cmd).await {
1771        let _ = log_error("github", "GitHub issues command failed", Some(&e)).await;
1772        return Err(ErrorFactory::operation(
1773            "github",
1774            "run GitHub issues command",
1775            e,
1776            Some("Configure a token with `xbp config github set-key` and ensure git origin is a GitHub repo."),
1777        ));
1778    }
1779    Ok(())
1780}
1781
1782pub(super) async fn handle_todos(cmd: commands::TodosCmd, _debug: bool) -> CliResult<()> {
1783    if let Err(e) = run_todos(cmd).await {
1784        let _ = log_error("todos", "Todos command failed", Some(&e)).await;
1785        return Err(ErrorFactory::operation(
1786            "todos",
1787            "run todos scan/sync",
1788            e,
1789            Some("Try `xbp todos scan` first, then `xbp todos sync --to both --dry-run`."),
1790        ));
1791    }
1792    Ok(())
1793}
1794
1795pub(super) async fn handle_issues(cmd: commands::TodosCmd, _debug: bool) -> CliResult<()> {
1796    if let Err(e) = crate::commands::todos::run_issues(cmd).await {
1797        let _ = log_error("issues", "Issues command failed", Some(&e)).await;
1798        return Err(ErrorFactory::operation(
1799            "issues",
1800            "run issues scan/sync",
1801            e,
1802            Some("Try `xbp issues scan` first, then `xbp issues sync --to both --dry-run`."),
1803        ));
1804    }
1805    Ok(())
1806}
1807
1808pub(super) async fn handle_audit(cmd: commands::AuditCmd, _debug: bool) -> CliResult<()> {
1809    if let Err(e) = run_audit(cmd).await {
1810        let _ = log_error("audit", "Audit command failed", Some(&e)).await;
1811        return Err(ErrorFactory::operation(
1812            "audit",
1813            "run static analysis audit",
1814            e,
1815            Some("Try `xbp audit errors --language rust --format terminal` from a project root."),
1816        ));
1817    }
1818    Ok(())
1819}
1820
1821pub(super) async fn handle_fix_process_monitor_json(
1822    cmd: commands::FixProcessMonitorJsonCmd,
1823    _debug: bool,
1824) -> CliResult<()> {
1825    if let Err(error) =
1826        crate::commands::run_fix_process_monitor_json(cmd.path, cmd.check, cmd.stdout)
1827    {
1828        let _ = log_error(
1829            "fix-process-monitor-json",
1830            "Failed to repair process monitor JSON",
1831            Some(&error),
1832        )
1833        .await;
1834        return Err(error.into());
1835    }
1836    Ok(())
1837}
1838
1839pub(super) async fn handle_cursor(cmd: commands::CursorCmd) -> CliResult<()> {
1840    let result = match cmd.command {
1841        commands::CursorSubCommand::Ingest { dry_run } => run_cursor_ingest(dry_run).await,
1842    };
1843
1844    if let Err(error) = result {
1845        let _ = log_error("cursor", "Cursor command failed", Some(&error)).await;
1846        return Err(error.into());
1847    }
1848    Ok(())
1849}
1850
1851pub(super) async fn handle_login(cmd: commands::LoginCmd) -> CliResult<()> {
1852    let result = match cmd.action {
1853        Some(commands::LoginSubCommand::Status) => run_login_status().await,
1854        Some(commands::LoginSubCommand::Logout) => run_logout().await,
1855        None => run_login().await,
1856    };
1857
1858    if let Err(e) = result {
1859        let _ = log_error("login", "Login command failed", Some(&e)).await;
1860        return Err(e.into());
1861    }
1862
1863    Ok(())
1864}
1865
1866pub(super) async fn handle_whoami() -> CliResult<()> {
1867    if let Err(e) = run_whoami().await {
1868        let _ = log_error("whoami", "Whoami command failed", Some(&e)).await;
1869        return Err(e.into());
1870    }
1871
1872    Ok(())
1873}
1874
1875pub(super) async fn handle_update(cmd: commands::UpdateCmd) -> CliResult<()> {
1876    let options = UpdateCheckOptions {
1877        crate_name: cmd.crate_name,
1878        json: cmd.json,
1879        install: cmd.install,
1880        features: cmd.features,
1881        no_default_features: cmd.no_default_features,
1882        all_features: cmd.all_features,
1883        fail_if_outdated: cmd.fail_if_outdated,
1884    };
1885    if let Err(e) = run_update_check(options).await {
1886        let _ = log_error("update", "Update check failed", Some(&e)).await;
1887        return Err(ErrorFactory::operation(
1888            "update",
1889            "check crates.io for a newer XBP release",
1890            e,
1891            Some(
1892                "Confirm network access to https://crates.io/crates/xbp, or install with `cargo install xbp --locked --features all`.",
1893            ),
1894        ));
1895    }
1896    Ok(())
1897}
1898
1899pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
1900    if let Err(e) = require_authenticated_cli_session().await {
1901        return Err(ErrorFactory::operation(
1902            "version",
1903            "verify CLI login",
1904            e,
1905            Some("Run `xbp login` before using version workflows."),
1906        ));
1907    }
1908
1909    if cmd.push {
1910        crate::cli::auto_commit::set_explicit_push(true);
1911    }
1912
1913    let commands::VersionCmd {
1914        target,
1915        explicit_version,
1916        git,
1917        force,
1918        command,
1919        ..
1920    } = cmd;
1921    let resolved_target = explicit_version.or(target);
1922
1923    if command.is_some() && (resolved_target.is_some() || git) {
1924        return Err(ErrorFactory::validation(
1925            "version",
1926            "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
1927            Some(
1928                "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
1929            ),
1930        ));
1931    }
1932
1933    if let Some(subcommand) = command {
1934        match subcommand {
1935            commands::VersionSubCommand::Release(release_cmd) => {
1936                let options = VersionReleaseOptions {
1937                    explicit_version: release_cmd.version,
1938                    release_flag: release_cmd.flag.map(|flag| flag.as_str().to_string()),
1939                    allow_dirty: release_cmd.allow_dirty,
1940                    title: release_cmd.title,
1941                    notes: release_cmd.notes,
1942                    notes_file: release_cmd.notes_file,
1943                    draft: release_cmd.draft,
1944                    prerelease: release_cmd.prerelease,
1945                    publish: release_cmd.publish,
1946                    force: release_cmd.force,
1947                    dry_run: release_cmd.dry_run,
1948                    force_scope: None,
1949                    latest_policy: match release_cmd.make_latest {
1950                        commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
1951                        commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
1952                        commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
1953                    },
1954                };
1955                if let Err(e) = run_version_release_command(options).await {
1956                    let hint = version_release_error_hint(&e);
1957                    return Err(ErrorFactory::operation(
1958                        "version",
1959                        "release version",
1960                        e,
1961                        hint,
1962                    ));
1963                }
1964                return Ok(());
1965            }
1966            commands::VersionSubCommand::Discover(discover_cmd) => {
1967                if let Err(e) = run_version_discover_services(&discover_cmd).await {
1968                    return Err(ErrorFactory::operation(
1969                        "version",
1970                        "discover and register project services",
1971                        e,
1972                        Some(
1973                            "Run `xbp version discover --dry-run` to preview package roots, or `--no-register` to opt out of writing.",
1974                        ),
1975                    ));
1976                }
1977                return Ok(());
1978            }
1979            commands::VersionSubCommand::Bump(bump_cmd) => {
1980                if bump_cmd.push {
1981                    crate::cli::auto_commit::set_explicit_push(true);
1982                }
1983                if let Err(e) = run_version_bump_command(&bump_cmd).await {
1984                    return Err(ErrorFactory::operation(
1985                        "version",
1986                        "bump mutated package versions",
1987                        e,
1988                        Some(
1989                            "Preview with `xbp v bump --plan` or `--dry-run`. On a clean tree, packages with commits since the last release are candidates. Use `--auto` or `--all --patch` in non-interactive shells.",
1990                        ),
1991                    ));
1992                }
1993                return Ok(());
1994            }
1995            commands::VersionSubCommand::Domain(domain_cmd) => {
1996                let command = match domain_cmd.command {
1997                    commands::VersionDomainSubCommand::Init(init_cmd) => {
1998                        VersionDomainCommand::Init(VersionDomainInitOptions {
1999                            name: init_cmd.name,
2000                            root: init_cmd.root,
2001                            write: init_cmd.write,
2002                        })
2003                    }
2004                    commands::VersionDomainSubCommand::Doctor(doctor_cmd) => {
2005                        VersionDomainCommand::Doctor(VersionDomainDoctorOptions {
2006                            domain: doctor_cmd.domain,
2007                        })
2008                    }
2009                    commands::VersionDomainSubCommand::Sync(sync_cmd) => {
2010                        VersionDomainCommand::Sync(VersionDomainSyncOptions {
2011                            domain: sync_cmd.domain,
2012                            write: sync_cmd.write,
2013                        })
2014                    }
2015                    commands::VersionDomainSubCommand::Diagnose(diagnose_cmd) => {
2016                        VersionDomainCommand::Diagnose(VersionDomainDiagnoseOptions {
2017                            domain: diagnose_cmd.domain,
2018                            log: diagnose_cmd.log,
2019                        })
2020                    }
2021                    commands::VersionDomainSubCommand::Release(release_cmd) => {
2022                        VersionDomainCommand::Release(VersionDomainReleaseOptions {
2023                            domain: release_cmd.domain,
2024                            patch: release_cmd.patch,
2025                            minor: release_cmd.minor,
2026                            major: release_cmd.major,
2027                            version: release_cmd.version,
2028                            deploy: release_cmd.deploy,
2029                            allow_major_jump: release_cmd.allow_major_jump,
2030                            allow_cross_domain_version: release_cmd.allow_cross_domain_version,
2031                        })
2032                    }
2033                };
2034                if let Err(e) =
2035                    run_version_domain_command(VersionDomainCommandOptions { command }).await
2036                {
2037                    return Err(ErrorFactory::operation(
2038                        "version",
2039                        "run version-domain command",
2040                        e,
2041                        Some("Run `xbp version domain -h` to inspect supported usage."),
2042                    ));
2043                }
2044                return Ok(());
2045            }
2046            commands::VersionSubCommand::Workspace(workspace_cmd) => {
2047                let (repo, json, workspace_command) = match workspace_cmd.command {
2048                    commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
2049                        check_cmd.target.repo,
2050                        check_cmd.target.json,
2051                        WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
2052                            version: check_cmd.version,
2053                        }),
2054                    ),
2055                    commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
2056                        sync_cmd.target.repo,
2057                        sync_cmd.target.json,
2058                        WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
2059                            version: sync_cmd.version,
2060                            write: sync_cmd.write,
2061                        }),
2062                    ),
2063                    commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
2064                        validate_cmd.target.repo,
2065                        validate_cmd.target.json,
2066                        WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
2067                            package: validate_cmd.package,
2068                            cargo_check: validate_cmd.cargo_check,
2069                            package_dry_run: validate_cmd.package_dry_run,
2070                        }),
2071                    ),
2072                    commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
2073                        match publish_cmd.command {
2074                            commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
2075                                plan_cmd.target.repo,
2076                                plan_cmd.target.json,
2077                                WorkspaceVersionCommand::PublishPlan(WorkspacePublishPlanOptions {
2078                                    only: plan_cmd.only,
2079                                    include_prereqs: plan_cmd.include_prereqs,
2080                                }),
2081                            ),
2082                            commands::VersionWorkspacePublishSubCommand::Heal(heal_cmd) => (
2083                                heal_cmd.target.repo,
2084                                heal_cmd.target.json,
2085                                WorkspaceVersionCommand::PublishHeal(WorkspacePublishHealOptions {
2086                                    only: heal_cmd.only,
2087                                    include_prereqs: heal_cmd.include_prereqs,
2088                                    write: heal_cmd.write,
2089                                    version: heal_cmd.version,
2090                                }),
2091                            ),
2092                            commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
2093                                run_cmd.target.repo,
2094                                run_cmd.target.json,
2095                                WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
2096                                    dry_run: run_cmd.dry_run,
2097                                    from: run_cmd.from,
2098                                    only: run_cmd.only,
2099                                    include_prereqs: run_cmd.include_prereqs,
2100                                    continue_on_error: run_cmd.continue_on_error,
2101                                    allow_dirty: run_cmd.allow_dirty,
2102                                    auto_fix: run_cmd.auto_fix,
2103                                    timeout_seconds: run_cmd.timeout_seconds,
2104                                    poll_interval_seconds: run_cmd.poll_interval_seconds,
2105                                }),
2106                            ),
2107                        }
2108                    }
2109                };
2110
2111                if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
2112                    repo,
2113                    json,
2114                    command: workspace_command,
2115                })
2116                .await
2117                {
2118                    return Err(ErrorFactory::operation(
2119                        "version",
2120                        "run workspace version command",
2121                        e,
2122                        Some("Run `xbp version workspace -h` to inspect supported usage."),
2123                    ));
2124                }
2125                return Ok(());
2126            }
2127        }
2128    }
2129
2130    if let Err(e) = run_version_command(resolved_target, git, force, debug).await {
2131        return Err(ErrorFactory::operation(
2132            "version",
2133            "run version command",
2134            e,
2135            Some(
2136                "Run `xbp version -h` to inspect supported usage. \
2137                 Version downgrades require `xbp version <x.y.z> --force`.",
2138            ),
2139        ));
2140    }
2141    Ok(())
2142}
2143
2144fn version_release_error_hint(error: &str) -> Option<&'static str> {
2145    if error.contains("Working tree is dirty") || error.contains("uncommitted changes") {
2146        return Some(
2147            "Use `--allow-dirty` if only generated files changed. `--force` also allows a dirty tree when `--publish` is enabled.",
2148        );
2149    }
2150    if error.contains("CARGO_REGISTRY_TOKEN") || error.contains("no token found") {
2151        return Some(
2152            "Configure a crates.io token with `xbp config crates set-key` or export `CARGO_REGISTRY_TOKEN`.",
2153        );
2154    }
2155    if error.contains("Workspace version drift detected") {
2156        return Some(
2157            "Run `xbp version workspace sync --write` to align crate versions, or pass `--force` to auto-sync during release.",
2158        );
2159    }
2160    if error.contains("Workspace closure detected")
2161        || error.contains("workspace publish command failed")
2162    {
2163        return Some(
2164            "For multi-crate workspaces, inspect the publish output and run `xbp version workspace publish plan --include-prereqs` first.",
2165        );
2166    }
2167    if error.contains("Duplicate port found") || error.contains("Duplicate service name") {
2168        return Some(
2169            "Duplicate service ports/names are deploy-time checks. Re-run `xbp version release` (release no longer blocks on them). Fix ports in project config (`.xbp/xbp.toml` by default) before `xbp deploy`/`xbp start`.",
2170        );
2171    }
2172    if error.contains("Release cancelled") {
2173        return Some(
2174            "Re-run with `--allow-dirty` to skip dirty-tree prompts, or `--force` to auto-heal workspace versions and allow dirty publishes.",
2175        );
2176    }
2177    if error.contains("No version targets")
2178        || error.contains("matched commits since the last release baseline")
2179    {
2180        return Some(
2181            "Pick a scope that actually changed (the picker annotates matching changes), or re-run with `--force` to release the selected scope's full version-target set anyway.",
2182        );
2183    }
2184    None
2185}
2186
2187fn should_print_help(cli: &Cli) -> bool {
2188    cli.command.is_none()
2189        && !cli.list
2190        && !cli.logs
2191        && !cli.commands
2192        && cli.query.is_none()
2193        && cli.port.is_none()
2194}
2195
2196/// When both root help (`-h`/`--help`) and a query (`-q`/`--query`) appear,
2197/// drop the help flags so clap does not short-circuit before parsing the query.
2198/// Subcommand help (`xbp workers -h`) is unaffected when no root `-q` is present.
2199fn preprocess_argv_for_help_query(args: Vec<String>) -> Vec<String> {
2200    let has_query = args.iter().skip(1).any(|a| {
2201        a == "-q" || a == "--query" || a.starts_with("--query=") || a.starts_with("-q=")
2202    });
2203    if !has_query {
2204        return args;
2205    }
2206    args.into_iter()
2207        .filter(|a| a != "-h" && a != "--help")
2208        .collect()
2209}
2210
2211#[cfg(test)]
2212mod help_query_argv_tests {
2213    use super::preprocess_argv_for_help_query;
2214
2215    #[test]
2216    fn strips_root_help_when_query_present() {
2217        let out = preprocess_argv_for_help_query(vec![
2218            "xbp".into(),
2219            "-h".into(),
2220            "-q".into(),
2221            "deploy".into(),
2222        ]);
2223        assert_eq!(out, vec!["xbp", "-q", "deploy"]);
2224    }
2225
2226    #[test]
2227    fn leaves_argv_when_no_query() {
2228        let out = preprocess_argv_for_help_query(vec!["xbp".into(), "diag".into(), "-h".into()]);
2229        assert_eq!(out, vec!["xbp", "diag", "-h"]);
2230    }
2231
2232    #[test]
2233    fn strips_long_help_with_long_query() {
2234        let out = preprocess_argv_for_help_query(vec![
2235            "xbp".into(),
2236            "--help".into(),
2237            "--query".into(),
2238            "webhook".into(),
2239        ]);
2240        assert_eq!(out, vec!["xbp", "--query", "webhook"]);
2241    }
2242
2243    #[test]
2244    fn strips_both_help_forms() {
2245        let out = preprocess_argv_for_help_query(vec![
2246            "xbp".into(),
2247            "-h".into(),
2248            "--help".into(),
2249            "-q".into(),
2250            "ports".into(),
2251        ]);
2252        assert_eq!(out, vec!["xbp", "-q", "ports"]);
2253    }
2254
2255    #[test]
2256    fn keeps_query_equals_form() {
2257        let out = preprocess_argv_for_help_query(vec![
2258            "xbp".into(),
2259            "-h".into(),
2260            "--query=deploy".into(),
2261        ]);
2262        assert_eq!(out, vec!["xbp", "--query=deploy"]);
2263    }
2264
2265    #[test]
2266    fn keeps_short_query_equals_form() {
2267        let out =
2268            preprocess_argv_for_help_query(vec!["xbp".into(), "--help".into(), "-q=nginx".into()]);
2269        assert_eq!(out, vec!["xbp", "-q=nginx"]);
2270    }
2271
2272    #[test]
2273    fn leaves_plain_help_alone() {
2274        let out = preprocess_argv_for_help_query(vec!["xbp".into(), "--help".into()]);
2275        assert_eq!(out, vec!["xbp", "--help"]);
2276    }
2277
2278    #[test]
2279    fn leaves_subcommand_help_without_root_query() {
2280        let out = preprocess_argv_for_help_query(vec![
2281            "xbp".into(),
2282            "workers".into(),
2283            "list".into(),
2284            "-h".into(),
2285        ]);
2286        assert_eq!(out, vec!["xbp", "workers", "list", "-h"]);
2287    }
2288
2289    #[test]
2290    fn strips_help_even_if_query_comes_first() {
2291        let out = preprocess_argv_for_help_query(vec![
2292            "xbp".into(),
2293            "-q".into(),
2294            "linear".into(),
2295            "-h".into(),
2296        ]);
2297        assert_eq!(out, vec!["xbp", "-q", "linear"]);
2298    }
2299
2300    #[test]
2301    fn empty_args_passthrough() {
2302        let out = preprocess_argv_for_help_query(vec!["xbp".into()]);
2303        assert_eq!(out, vec!["xbp"]);
2304    }
2305}
2306
2307fn background_cursor_ingest_trigger(command: Option<&commands::Commands>) -> Option<&'static str> {
2308    match command {
2309        None => None,
2310        Some(commands::Commands::Cursor(_)) => None,
2311        Some(commands::Commands::Diag(_)) => None,
2312        Some(commands::Commands::Whoami) => None,
2313        Some(commands::Commands::Update(_)) => None,
2314        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
2315            None => Some("login"),
2316            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
2317        },
2318        Some(other) => Some(commands::command_label(other)),
2319    }
2320}
2321
2322fn background_system_inventory_refresh_trigger(
2323    command: Option<&commands::Commands>,
2324) -> Option<&'static str> {
2325    match command {
2326        None => None,
2327        Some(commands::Commands::Cursor(_)) => None,
2328        Some(commands::Commands::Diag(_)) => None,
2329        Some(commands::Commands::Whoami) => None,
2330        Some(commands::Commands::Update(_)) => None,
2331        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
2332            None => Some("login"),
2333            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
2334        },
2335        Some(other) => Some(commands::command_label(other)),
2336    }
2337}
2338
2339fn print_service_usage() {
2340    ui::configure_color_output();
2341    println!();
2342    println!("{}", "xbp service".bright_magenta().bold());
2343    ui::divider(28);
2344    println!(
2345        "{} {}",
2346        "Usage:".bright_cyan().bold(),
2347        "xbp service [command] [service-name]".bright_white()
2348    );
2349    println!();
2350    println!("Running with no arguments opens an interactive preview + picker.");
2351    ui::section("Commands");
2352    for command in ["build", "install", "start", "dev", "pre"] {
2353        println!("  {}", command.bright_cyan());
2354    }
2355    ui::section("Examples");
2356    println!(
2357        "  {}",
2358        "xbp service                      # interactive: pick service → command".dimmed()
2359    );
2360    println!(
2361        "  {}",
2362        "xbp service start                # pick from services that support start".dimmed()
2363    );
2364    println!("  {}", "xbp service build zeus".dimmed());
2365    ui::tip("Run `xbp service --help <service-name>` for service-specific guidance. All runs are recorded under $HOME/.xbp/logs/service/");
2366}
2367
2368#[cfg(test)]
2369mod tests {
2370    use super::*;
2371    use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
2372
2373    #[test]
2374    fn plain_cli_invocation_prints_help() {
2375        let cli = Cli::try_parse_from(["xbp"]).expect("parse");
2376        assert!(should_print_help(&cli));
2377    }
2378
2379    #[test]
2380    fn root_version_short_v_displays_version() {
2381        let err = Cli::try_parse_from(["xbp", "-v"]).expect_err("version");
2382        assert_eq!(err.kind(), ClapErrorKind::DisplayVersion);
2383    }
2384
2385    #[test]
2386    fn root_version_capital_v_and_long_still_display_version() {
2387        let err_v = Cli::try_parse_from(["xbp", "-V"]).expect_err("version -V");
2388        assert_eq!(err_v.kind(), ClapErrorKind::DisplayVersion);
2389        let err_long = Cli::try_parse_from(["xbp", "--version"]).expect_err("version long");
2390        assert_eq!(err_long.kind(), ClapErrorKind::DisplayVersion);
2391    }
2392
2393    #[test]
2394    fn list_flag_skips_help_short_circuit() {
2395        let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
2396        assert!(!should_print_help(&cli));
2397    }
2398
2399    #[test]
2400    fn commands_flag_skips_help_short_circuit() {
2401        let cli = Cli::try_parse_from(["xbp", "--commands"]).expect("parse");
2402        assert!(!should_print_help(&cli));
2403        assert!(cli.commands);
2404    }
2405
2406    #[test]
2407    fn install_without_package_parses_and_is_optional() {
2408        let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
2409        let Some(Commands::Install {
2410            list,
2411            force,
2412            package,
2413        }) = cli.command
2414        else {
2415            panic!("expected install command");
2416        };
2417        assert!(!list);
2418        assert!(!force);
2419        assert!(package.is_none());
2420    }
2421
2422    #[test]
2423    fn install_with_package_still_parses() {
2424        let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
2425        let Some(Commands::Install {
2426            list,
2427            force,
2428            package,
2429        }) = cli.command
2430        else {
2431            panic!("expected install command");
2432        };
2433        assert!(!list);
2434        assert!(!force);
2435        assert_eq!(package.as_deref(), Some("docker"));
2436    }
2437
2438    #[test]
2439    fn install_force_flag_parses() {
2440        let cli = Cli::try_parse_from(["xbp", "install", "--force", "nx-brew"]).expect("parse");
2441        let Some(Commands::Install {
2442            list,
2443            force,
2444            package,
2445        }) = cli.command
2446        else {
2447            panic!("expected install command");
2448        };
2449        assert!(!list);
2450        assert!(force);
2451        assert_eq!(package.as_deref(), Some("nx-brew"));
2452    }
2453
2454    #[test]
2455    fn install_list_flag_parses() {
2456        let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
2457        let Some(Commands::Install {
2458            list,
2459            force: _,
2460            package,
2461        }) = cli.command
2462        else {
2463            panic!("expected install command");
2464        };
2465        assert!(list);
2466        assert!(package.is_none());
2467    }
2468
2469    #[test]
2470    fn install_short_list_flag_parses() {
2471        let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
2472        let Some(Commands::Install {
2473            list,
2474            force: _,
2475            package,
2476        }) = cli.command
2477        else {
2478            panic!("expected install command");
2479        };
2480        assert!(list);
2481        assert!(package.is_none());
2482    }
2483
2484    #[test]
2485    fn install_ls_alias_parses_as_package() {
2486        let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
2487        let Some(Commands::Install {
2488            list,
2489            force: _,
2490            package,
2491        }) = cli.command
2492        else {
2493            panic!("expected install command");
2494        };
2495        assert!(!list);
2496        assert_eq!(package.as_deref(), Some("ls"));
2497    }
2498
2499    #[test]
2500    fn version_release_make_latest_parses_explicit_value() {
2501        let cli = Cli::try_parse_from([
2502            "xbp",
2503            "version",
2504            "release",
2505            "--version",
2506            "1.2.3",
2507            "--make-latest",
2508            "false",
2509        ])
2510        .expect("parse");
2511        let Some(Commands::Version(version_cmd)) = cli.command else {
2512            panic!("expected version command");
2513        };
2514        assert!(version_cmd.explicit_version.is_none());
2515        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2516            panic!("expected version release subcommand");
2517        };
2518        assert!(matches!(
2519            release_cmd.make_latest,
2520            VersionReleaseLatest::False
2521        ));
2522    }
2523
2524    #[test]
2525    fn version_release_make_latest_defaults_to_legacy() {
2526        let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
2527            .expect("parse");
2528        let Some(Commands::Version(version_cmd)) = cli.command else {
2529            panic!("expected version command");
2530        };
2531        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2532            panic!("expected version release subcommand");
2533        };
2534        assert!(matches!(
2535            release_cmd.make_latest,
2536            VersionReleaseLatest::Legacy
2537        ));
2538        assert!(!release_cmd.dry_run);
2539        assert!(!release_cmd.publish);
2540    }
2541
2542    #[test]
2543    fn version_release_parses_dry_run_and_publish() {
2544        let cli = Cli::try_parse_from([
2545            "xbp",
2546            "version",
2547            "release",
2548            "--version",
2549            "1.2.3",
2550            "--publish",
2551            "--dry-run",
2552        ])
2553        .expect("parse");
2554        let Some(Commands::Version(version_cmd)) = cli.command else {
2555            panic!("expected version command");
2556        };
2557        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2558            panic!("expected version release subcommand");
2559        };
2560        assert!(release_cmd.dry_run);
2561        assert!(release_cmd.publish);
2562    }
2563
2564    #[test]
2565    fn version_explicit_flag_parses() {
2566        let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
2567        let Some(Commands::Version(version_cmd)) = cli.command else {
2568            panic!("expected version command");
2569        };
2570        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
2571        assert!(version_cmd.target.is_none());
2572        assert!(version_cmd.command.is_none());
2573    }
2574
2575    #[test]
2576    fn version_short_explicit_flag_parses_with_alias() {
2577        let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
2578        let Some(Commands::Version(version_cmd)) = cli.command else {
2579            panic!("expected version command");
2580        };
2581        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
2582        assert!(version_cmd.target.is_none());
2583        assert!(version_cmd.command.is_none());
2584    }
2585}