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