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