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