Skip to main content

xbp_cli/cli/
mod.rs

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