1pub mod app;
2pub mod auto_commit;
3pub mod commands;
4pub mod error;
5pub mod features;
6pub mod handlers;
7pub mod help_render;
8pub mod mcp_tools_export;
9pub mod router;
10pub mod ui;
11
12pub use handlers::*;
13
14use crate::cli::app::AppContext;
15use crate::cli::error::{CliError, CliResult, ErrorFactory};
16use crate::commands::cli_session::{
17 post_cli_error_report, require_authenticated_cli_session, run_login_status, run_logout,
18 run_whoami, CliErrorReportPayload,
19};
20use crate::commands::curl;
21#[cfg(feature = "openapi-gen")]
22use crate::commands::generate_openapi::{run_generate_openapi, GenerateOpenApiArgs};
23use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
24use crate::commands::redeploy_v2::run_redeploy_v2;
25use crate::commands::{
26 install_package, list_services, open_global_config, pick_service_for_command, run_commit,
27 run_config, run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
28 run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
29 run_config_cloudflare_status, run_config_crates_login, run_config_crates_logout,
30 run_config_discord, run_config_openapi_setup, run_config_openapi_show,
31 run_config_publish_setup, run_config_release_setup, run_config_secret_delete,
32 run_config_secret_set, run_config_secret_show, run_cursor_ingest, run_generate_config,
33 run_github, run_init,
34 run_login, run_migrate_config_file, run_publish_command, run_redeploy, run_redeploy_service,
35 run_service_command, run_service_interactive, run_setup, run_todos, run_update_check,
36 run_version_bump_command, run_version_command, run_version_discover_services,
37 run_version_domain_command, run_version_release_command, run_version_workspace_command,
38 show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
39 PublishCommandOptions, ReleaseLatestPolicy, UpdateCheckOptions, VersionDomainCommand,
40 VersionDomainCommandOptions, VersionDomainDiagnoseOptions, VersionDomainDoctorOptions,
41 VersionDomainInitOptions, VersionDomainReleaseOptions, VersionDomainSyncOptions,
42 VersionReleaseOptions, WorkspacePublishHealOptions, WorkspacePublishPlanOptions,
43 WorkspacePublishRunOptions, WorkspaceVersionCheckOptions, WorkspaceVersionCommand,
44 WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
45};
46#[cfg(feature = "linear")]
47use crate::commands::{run_config_linear_select_initiative, run_linear};
48use crate::commands::{run_diag, run_nginx};
49use crate::config::{resolve_device_identity, sync_versioning_files_registry};
50use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
51use crate::utils::{
52 find_xbp_config_upwards, git_remote_url_from_metadata, parse_github_repo_from_remote_url,
53};
54use chrono::Utc;
55use clap::{error::ErrorKind as ClapErrorKind, Parser};
56use colored::Colorize;
57use commands::Cli;
58use std::env;
59
60pub async fn run() -> CliResult<()> {
61 let 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 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 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
170async 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 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 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 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 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 (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 (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 (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 (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 (None, Some(name)) => {
1243 if let Err(e) = show_service_help(name).await {
1244 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 (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
1793fn 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}