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