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