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 router;
9pub mod ui;
10
11pub use handlers::*;
12
13use crate::cli::app::AppContext;
14use crate::cli::error::{CliError, CliResult, ErrorFactory};
15use crate::commands::cli_session::{
16 require_authenticated_cli_session, run_login_status, run_logout,
17};
18use crate::commands::curl;
19use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
20use crate::commands::redeploy_v2::run_redeploy_v2;
21use crate::commands::{
22 install_package, list_services, open_global_config, run_commit, run_config,
23 run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
24 run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
25 run_config_cloudflare_status, run_config_crates_login, run_config_crates_logout,
26 run_config_linear_select_initiative, run_config_publish_setup, run_config_secret_delete,
27 run_config_secret_set, run_config_secret_show, run_cursor_ingest, run_generate_config,
28 run_init, run_login, run_publish_command, run_redeploy, run_redeploy_service,
29 run_service_command, run_setup, run_version_bump_command, run_version_command,
30 run_version_discover_services, run_version_release_command, run_version_workspace_command,
31 show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
32 PublishCommandOptions, ReleaseLatestPolicy, VersionReleaseOptions, WorkspacePublishPlanOptions,
33 WorkspacePublishRunOptions, WorkspaceVersionCheckOptions, WorkspaceVersionCommand,
34 WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
35};
36use crate::commands::{run_diag, run_nginx};
37use crate::config::sync_versioning_files_registry;
38use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
39use clap::{error::ErrorKind as ClapErrorKind, Parser};
40use colored::Colorize;
41use commands::Cli;
42
43pub async fn run() -> CliResult<()> {
44 let cli: Cli = match Cli::try_parse() {
45 Ok(cli) => cli,
46 Err(err) => {
47 let kind = err.kind();
48 let rendered = err.to_string();
49 if kind == ClapErrorKind::DisplayVersion {
50 help_render::emit_version_line(env!("CARGO_PKG_VERSION"));
51 return Ok(());
52 }
53 if kind == ClapErrorKind::DisplayHelp
54 || kind == ClapErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
55 || rendered.contains("Manage the XBP API server")
56 {
57 ui::configure_color_output();
58 if help_render::is_root_help_text(&rendered) {
59 crate::commands::print_help().await;
60 } else {
61 help_render::emit_styled_help(&rendered, help_render::HelpScope::Auto);
62 }
63 return Ok(());
64 }
65 return Err(ErrorFactory::clap_parse(err));
66 }
67 };
68
69 if cli.commands {
70 help_render::print_command_catalog();
71 return Ok(());
72 }
73
74 if should_print_help(&cli) {
77 ui::configure_color_output();
78 crate::commands::print_help().await;
79 return Ok(());
80 }
81
82 let debug: bool = cli.debug;
83 ui::configure_color_output();
84 let command_name = cli
85 .command
86 .as_ref()
87 .map(commands::command_label)
88 .unwrap_or("interactive");
89 ui::print_cli_header(command_name, debug);
90
91 if let Err(e) = init_logger(debug).await {
92 let _ = log_error(
93 "system",
94 "Failed to initialize logger",
95 Some(&e.to_string()),
96 )
97 .await;
98 }
99 if let Err(e) = sync_versioning_files_registry() {
100 let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
101 }
102
103 let background_cursor_trigger =
104 background_cursor_ingest_trigger(cli.command.as_ref()).map(str::to_string);
105 let background_system_inventory_trigger =
106 background_system_inventory_refresh_trigger(cli.command.as_ref()).map(str::to_string);
107 let mut ctx = AppContext::new(debug);
108 let result = router::dispatch(cli, &mut ctx).await;
109
110 if result.is_ok() {
111 if let Some(trigger) = background_cursor_trigger {
112 if let Err(error) =
113 crate::commands::cursor_ingest::maybe_start_background_cursor_ingest(&trigger)
114 {
115 let _ = log_warn(
116 "cursor",
117 "Background Cursor ingest could not be started",
118 Some(&error),
119 )
120 .await;
121 }
122 }
123 if let Some(trigger) = background_system_inventory_trigger {
124 if let Err(error) = crate::commands::system_inventory_refresh::
125 maybe_start_background_system_inventory_refresh(&trigger)
126 {
127 let _ = log_warn(
128 "codetime",
129 "Background system inventory refresh could not be started",
130 Some(&error),
131 )
132 .await;
133 }
134 }
135 }
136
137 result
138}
139
140pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
141 if let Err(e) = run_init(debug).await {
142 let _ = log_error("init", "Init failed", Some(&e)).await;
143 return Err(e.into());
144 }
145 Ok(())
146}
147
148pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
149 let args = CommitArgs {
150 dry_run: cmd.dry_run,
151 push: cmd.push,
152 no_ai: cmd.no_ai,
153 model: cmd.model,
154 scope: cmd.scope,
155 };
156
157 match run_commit(args).await {
158 Ok(CommitRunOutcome::Completed) | Ok(CommitRunOutcome::NothingToCommit) => Ok(()),
159 Err(error) => Err(map_commit_error(error)),
160 }
161}
162
163fn map_commit_error(error: CommitError) -> CliError {
164 match error {
165 CommitError::PushFailed {
166 summary,
167 commit_sha,
168 } => {
169 let details = if let Some(commit_sha) = commit_sha {
170 format!("Created local commit {commit_sha} but push failed: {summary}")
171 } else {
172 format!("Push failed: {summary}")
173 };
174 ErrorFactory::operation(
175 "commit",
176 "push changes",
177 details,
178 commit_push_hint(&summary),
179 )
180 }
181 CommitError::TerminalSessionsBlocked(message) => ErrorFactory::validation(
182 "commit",
183 message,
184 Some("Add `/terminals` to `.gitignore` and run `git rm -r --cached terminals/`."),
185 ),
186 CommitError::Operation(message) => ErrorFactory::operation(
187 "commit",
188 "create conventional commit",
189 message,
190 Some("Run `xbp commit --dry-run` after saving changes."),
191 ),
192 }
193}
194
195fn commit_push_hint(summary: &str) -> Option<&'static str> {
196 let lowered = summary.to_ascii_lowercase();
197 if lowered.contains("behind") || lowered.contains("non-fast-forward") {
198 Some("Run `git pull --rebase origin <branch>` and then `git push`.")
199 } else {
200 Some("Verify GitHub auth and branch permissions, then push again.")
201 }
202}
203
204pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
205 let options = PublishCommandOptions {
206 dry_run: cmd.dry_run,
207 allow_dirty: cmd.allow_dirty,
208 force: cmd.force,
209 include_prereqs: cmd.include_prereqs,
210 target: cmd.target,
211 manifest_path: cmd.manifest_path,
212 expected_version: None,
213 };
214
215 if let Err(e) = run_publish_command(options).await {
216 let _ = log_error("publish", "Publish command failed", Some(&e)).await;
217 return Err(ErrorFactory::operation(
218 "publish",
219 "run publish workflow",
220 e,
221 Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
222 ));
223 }
224
225 Ok(())
226}
227
228pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
229 if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
230 let _ = log_error("setup", "Setup failed", Some(&e)).await;
231 return Err(ErrorFactory::operation(
232 "setup",
233 "setup environment",
234 e,
235 Some("Run with `--debug` for command-level output."),
236 ));
237 }
238 Ok(())
239}
240
241pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
242 if let Some(name) = service_name {
243 if let Err(e) = ui::with_loader(
244 &format!("Redeploying service `{}`", name),
245 run_redeploy_service(&name, debug),
246 )
247 .await
248 {
249 let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
250 return Err(ErrorFactory::operation(
251 "redeploy",
252 &format!("redeploy service `{}`", name),
253 e,
254 Some("Verify service name with `xbp services`."),
255 ));
256 }
257 } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
258 let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
259 return Err(ErrorFactory::operation(
260 "redeploy",
261 "redeploy project",
262 e,
263 Some("Try `xbp redeploy <service>` for scoped retries."),
264 ));
265 }
266 Ok(())
267}
268
269pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
270 let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
271 match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
272 Ok(()) => Ok(()),
273 Err(e) => {
274 let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
275 Err(e.into())
276 }
277 }
278}
279
280pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
281 let commands::ConfigCmd {
282 project,
283 no_open,
284 provider,
285 } = cmd;
286
287 if provider.is_some() && (project || no_open) {
288 return Err(ErrorFactory::validation(
289 "config",
290 "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
291 Some("Run either provider key management OR project/global config actions."),
292 ));
293 }
294
295 if let Some(provider_cmd) = provider {
296 match provider_cmd {
297 commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
298 commands::ConfigSecretAction::SetKey { key } => {
299 if let Err(e) = run_config_secret_set("openrouter", key).await {
300 let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
301 return Err(ErrorFactory::operation(
302 "config",
303 "set OpenRouter key",
304 e,
305 Some("Run `xbp config openrouter show` to confirm key state."),
306 ));
307 }
308 }
309 commands::ConfigSecretAction::DeleteKey => {
310 if let Err(e) = run_config_secret_delete("openrouter").await {
311 let _ =
312 log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
313 return Err(ErrorFactory::operation(
314 "config",
315 "delete OpenRouter key",
316 e,
317 Some("Use `xbp config openrouter show` to verify removal."),
318 ));
319 }
320 }
321 commands::ConfigSecretAction::Show { raw } => {
322 if let Err(e) = run_config_secret_show("openrouter", raw).await {
323 let _ =
324 log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
325 return Err(ErrorFactory::operation(
326 "config",
327 "show OpenRouter key",
328 e,
329 None,
330 ));
331 }
332 }
333 },
334 commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
335 commands::ConfigSecretAction::SetKey { key } => {
336 if let Err(e) = run_config_secret_set("github", key).await {
337 let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
338 return Err(ErrorFactory::operation(
339 "config",
340 "set GitHub token",
341 e,
342 Some("Use a token with repo scope for private repos."),
343 ));
344 }
345 }
346 commands::ConfigSecretAction::DeleteKey => {
347 if let Err(e) = run_config_secret_delete("github").await {
348 let _ =
349 log_error("config", "Failed to delete GitHub token", Some(&e)).await;
350 return Err(ErrorFactory::operation(
351 "config",
352 "delete GitHub token",
353 e,
354 None,
355 ));
356 }
357 }
358 commands::ConfigSecretAction::Show { raw } => {
359 if let Err(e) = run_config_secret_show("github", raw).await {
360 let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
361 return Err(ErrorFactory::operation(
362 "config",
363 "show GitHub token",
364 e,
365 None,
366 ));
367 }
368 }
369 },
370 commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
371 None => {
372 if let Err(e) = run_config_cloudflare_setup().await {
373 let _ =
374 log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
375 return Err(ErrorFactory::operation(
376 "config",
377 "configure Cloudflare",
378 e,
379 Some(
380 "Run `xbp config cloudflare status` to inspect credential sources.",
381 ),
382 ));
383 }
384 }
385 Some(commands::CloudflareConfigAction::SetKey { key }) => {
386 if let Err(e) = run_config_secret_set("cloudflare", key).await {
387 let _ =
388 log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
389 return Err(ErrorFactory::operation(
390 "config",
391 "set Cloudflare token",
392 e,
393 Some("Use a Cloudflare API token with Secrets Store and DNS permissions."),
394 ));
395 }
396 }
397 Some(commands::CloudflareConfigAction::DeleteKey) => {
398 if let Err(e) = run_config_secret_delete("cloudflare").await {
399 let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
400 .await;
401 return Err(ErrorFactory::operation(
402 "config",
403 "delete Cloudflare token",
404 e,
405 None,
406 ));
407 }
408 }
409 Some(commands::CloudflareConfigAction::ShowKey { raw }) => {
410 if let Err(e) = run_config_secret_show("cloudflare", raw).await {
411 let _ =
412 log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
413 return Err(ErrorFactory::operation(
414 "config",
415 "show Cloudflare token",
416 e,
417 None,
418 ));
419 }
420 }
421 Some(commands::CloudflareConfigAction::SetAccountId { account_id }) => {
422 if let Err(e) = run_config_cloudflare_account_set(account_id).await {
423 let _ =
424 log_error("config", "Failed to set Cloudflare account ID", Some(&e))
425 .await;
426 return Err(ErrorFactory::operation(
427 "config",
428 "set Cloudflare account ID",
429 e,
430 None,
431 ));
432 }
433 }
434 Some(commands::CloudflareConfigAction::DeleteAccountId) => {
435 if let Err(e) = run_config_cloudflare_account_delete().await {
436 let _ =
437 log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
438 .await;
439 return Err(ErrorFactory::operation(
440 "config",
441 "delete Cloudflare account ID",
442 e,
443 None,
444 ));
445 }
446 }
447 Some(commands::CloudflareConfigAction::ShowAccountId { raw }) => {
448 if let Err(e) = run_config_cloudflare_account_show(raw).await {
449 let _ =
450 log_error("config", "Failed to show Cloudflare account ID", Some(&e))
451 .await;
452 return Err(ErrorFactory::operation(
453 "config",
454 "show Cloudflare account ID",
455 e,
456 None,
457 ));
458 }
459 }
460 Some(commands::CloudflareConfigAction::Login) => {
461 if let Err(e) = run_config_cloudflare_login().await {
462 let _ = log_error("config", "Failed to link Cloudflare", Some(&e)).await;
463 return Err(ErrorFactory::operation(
464 "config",
465 "link Cloudflare",
466 e,
467 None,
468 ));
469 }
470 }
471 Some(commands::CloudflareConfigAction::Status) => {
472 if let Err(e) = run_config_cloudflare_status().await {
473 let _ =
474 log_error("config", "Failed to show Cloudflare status", Some(&e)).await;
475 return Err(ErrorFactory::operation(
476 "config",
477 "show Cloudflare status",
478 e,
479 None,
480 ));
481 }
482 }
483 Some(commands::CloudflareConfigAction::Setup) => {
484 if let Err(e) = run_config_cloudflare_setup().await {
485 let _ =
486 log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
487 return Err(ErrorFactory::operation(
488 "config",
489 "configure Cloudflare",
490 e,
491 None,
492 ));
493 }
494 }
495 },
496 commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
497 commands::LinearConfigAction::SetKey { key } => {
498 if let Err(e) = run_config_secret_set("linear", key).await {
499 let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
500 return Err(ErrorFactory::operation(
501 "config",
502 "set Linear API key",
503 e,
504 Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
505 ));
506 }
507 }
508 commands::LinearConfigAction::DeleteKey => {
509 if let Err(e) = run_config_secret_delete("linear").await {
510 let _ =
511 log_error("config", "Failed to delete Linear API key", Some(&e)).await;
512 return Err(ErrorFactory::operation(
513 "config",
514 "delete Linear API key",
515 e,
516 None,
517 ));
518 }
519 }
520 commands::LinearConfigAction::Show { raw } => {
521 if let Err(e) = run_config_secret_show("linear", raw).await {
522 let _ =
523 log_error("config", "Failed to show Linear API key", Some(&e)).await;
524 return Err(ErrorFactory::operation(
525 "config",
526 "show Linear API key",
527 e,
528 None,
529 ));
530 }
531 }
532 commands::LinearConfigAction::SelectInitiative => {
533 if let Err(e) = run_config_linear_select_initiative().await {
534 let _ = log_error(
535 "config",
536 "Failed to select repo Linear initiative",
537 Some(&e),
538 )
539 .await;
540 return Err(ErrorFactory::operation(
541 "config",
542 "select repo Linear initiative",
543 e,
544 Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
545 ));
546 }
547 }
548 },
549 commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
550 commands::RegistryConfigAction::SetKey { key } => {
551 if let Err(e) = run_config_secret_set("npm", key).await {
552 let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
553 return Err(ErrorFactory::operation(
554 "config",
555 "set npm token",
556 e,
557 Some("Use a valid npm automation or granular publish token."),
558 ));
559 }
560 }
561 commands::RegistryConfigAction::DeleteKey => {
562 if let Err(e) = run_config_secret_delete("npm").await {
563 let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
564 return Err(ErrorFactory::operation(
565 "config",
566 "delete npm token",
567 e,
568 None,
569 ));
570 }
571 }
572 commands::RegistryConfigAction::Show { raw } => {
573 if let Err(e) = run_config_secret_show("npm", raw).await {
574 let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
575 return Err(ErrorFactory::operation("config", "show npm token", e, None));
576 }
577 }
578 commands::RegistryConfigAction::SetupRelease => {
579 if let Err(e) = run_config_publish_setup("npm").await {
580 let _ =
581 log_error("config", "Failed to configure npm publish", Some(&e)).await;
582 return Err(ErrorFactory::operation(
583 "config",
584 "configure npm publish",
585 e,
586 Some("Run this inside the target XBP project and ensure the package manifest exists."),
587 ));
588 }
589 }
590 },
591 commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
592 commands::CratesConfigAction::SetKey { key } => {
593 if let Err(e) = run_config_secret_set("crates", key).await {
594 let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
595 return Err(ErrorFactory::operation(
596 "config",
597 "set crates token",
598 e,
599 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."),
600 ));
601 }
602 }
603 commands::CratesConfigAction::DeleteKey => {
604 if let Err(e) = run_config_secret_delete("crates").await {
605 let _ =
606 log_error("config", "Failed to delete crates token", Some(&e)).await;
607 return Err(ErrorFactory::operation(
608 "config",
609 "delete crates token",
610 e,
611 None,
612 ));
613 }
614 }
615 commands::CratesConfigAction::Show { raw } => {
616 if let Err(e) = run_config_secret_show("crates", raw).await {
617 let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
618 return Err(ErrorFactory::operation(
619 "config",
620 "show crates token",
621 e,
622 None,
623 ));
624 }
625 }
626 commands::CratesConfigAction::SetupRelease => {
627 if let Err(e) = run_config_publish_setup("crates").await {
628 let _ = log_error("config", "Failed to configure crates publish", Some(&e))
629 .await;
630 return Err(ErrorFactory::operation(
631 "config",
632 "configure crates publish",
633 e,
634 Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
635 ));
636 }
637 }
638 commands::CratesConfigAction::Login { key } => {
639 if let Err(e) = run_config_crates_login(key).await {
640 let _ = log_error("config", "Failed to log Cargo into crates.io", Some(&e))
641 .await;
642 return Err(ErrorFactory::operation(
643 "config",
644 "log Cargo into crates.io",
645 e,
646 Some("Store a crates token with `xbp config crates set-key` first, or pass it directly to `xbp config crates login <token>`."),
647 ));
648 }
649 }
650 commands::CratesConfigAction::Logout => {
651 if let Err(e) = run_config_crates_logout().await {
652 let _ =
653 log_error("config", "Failed to log Cargo out of crates.io", Some(&e))
654 .await;
655 return Err(ErrorFactory::operation(
656 "config",
657 "log Cargo out of crates.io",
658 e,
659 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."),
660 ));
661 }
662 }
663 },
664 }
665 } else if project {
666 if let Err(e) = run_config(debug).await {
667 return Err(ErrorFactory::operation(
668 "config",
669 "read project config",
670 e,
671 Some("Ensure you're inside an XBP project root."),
672 ));
673 }
674 } else if let Err(e) = open_global_config(no_open).await {
675 let _ = log_error("config", "Failed to open global config", Some(&e)).await;
676 return Err(ErrorFactory::operation(
677 "config",
678 "open global config",
679 e,
680 None,
681 ));
682 }
683 Ok(())
684}
685
686pub(super) async fn handle_install(
687 package: Option<String>,
688 list: bool,
689 debug: bool,
690) -> CliResult<()> {
691 if list {
692 crate::commands::print_install_targets_help();
693 return Ok(());
694 }
695
696 let Some(package) = package else {
697 crate::commands::print_install_empty_state();
698 return Ok(());
699 };
700
701 if package.trim().is_empty() {
702 crate::commands::print_install_empty_state();
703 return Ok(());
704 }
705
706 if crate::commands::is_install_listing_request(&package) {
707 crate::commands::print_install_targets_help();
708 return Ok(());
709 }
710
711 let install_msg: String = format!("Installing package: {}", package);
712 let _ = log_info("install", &install_msg, None).await;
713 match ui::with_loader(
714 &format!("Installing package `{}`", package),
715 install_package(&package, debug),
716 )
717 .await
718 {
719 Ok(()) => {
720 let success_msg = format!("Successfully installed: {}", package);
721 let _ = log_success("install", &success_msg, None).await;
722 Ok(())
723 }
724 Err(e) => Err(e.into()),
725 }
726}
727
728pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
729 let url = cmd
730 .url
731 .unwrap_or_else(|| "https://example.com/api".to_string());
732 if let Err(e) = ui::with_loader(
733 &format!("Requesting {}", url),
734 curl::run_curl(&url, cmd.no_timeout, debug),
735 )
736 .await
737 {
738 let _ = log_error("curl", "Curl command failed", Some(&e)).await;
739 return Err(ErrorFactory::operation(
740 "curl",
741 "execute request",
742 e,
743 Some("Double-check the URL and network connectivity."),
744 ));
745 }
746 Ok(())
747}
748
749pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
750 if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
751 let _ = log_error("services", "Failed to list services", Some(&e)).await;
752 return Err(ErrorFactory::operation(
753 "services",
754 "list services",
755 e,
756 Some("Ensure xbp config is present and valid."),
757 ));
758 }
759 Ok(())
760}
761
762pub(super) async fn handle_service(
763 command: Option<String>,
764 service_name: Option<String>,
765 debug: bool,
766) -> CliResult<()> {
767 if let Some(cmd) = command {
768 if cmd == "--help" || cmd == "help" {
769 if let Some(name) = service_name {
770 if let Err(e) = show_service_help(&name).await {
771 let _ = log_error("service", "Failed to show service help", Some(&e)).await;
772 return Err(ErrorFactory::operation(
773 "service",
774 "show service help",
775 e,
776 None,
777 ));
778 }
779 } else {
780 print_service_usage();
781 }
782 } else if let Some(name) = service_name {
783 if let Err(e) = run_service_command(&cmd, &name, debug).await {
784 let _ = log_error(
785 "service",
786 &format!("Service command '{}' failed", cmd),
787 Some(&e),
788 )
789 .await;
790 return Err(ErrorFactory::operation(
791 "service",
792 &format!("run `{}` for `{}`", cmd, name),
793 e,
794 Some("Check available services via `xbp services`."),
795 ));
796 }
797 } else {
798 let _ = log_error("service", "Service name required", None).await;
799 return Err(ErrorFactory::validation(
800 "service",
801 "Service name required.",
802 Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
803 ));
804 }
805 } else {
806 print_service_usage();
807 }
808 Ok(())
809}
810
811pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
812 if let Err(e) = run_nginx(cmd, debug).await {
813 let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
814 return Err(ErrorFactory::operation(
815 "nginx",
816 "execute nginx command",
817 e.to_string(),
818 Some("Try `xbp nginx --help` for command syntax."),
819 ));
820 }
821 Ok(())
822}
823
824pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
825 if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
826 let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
827 return Err(ErrorFactory::operation(
828 "diag",
829 "run diagnostics",
830 e.to_string(),
831 Some("Re-run with `--debug` for more context."),
832 ));
833 }
834 Ok(())
835}
836
837pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
838 match cmd.command {
839 commands::GenerateSubCommand::Config(subcmd) => {
840 let args = GenerateConfigArgs {
841 force: subcmd.force,
842 update: subcmd.update,
843 from_json: subcmd.from_json,
844 };
845 if let Err(e) = run_generate_config(args, debug).await {
846 let _ = log_error(
847 "generate-config",
848 "Failed to generate project config",
849 Some(&e),
850 )
851 .await;
852 return Err(ErrorFactory::operation(
853 "generate-config",
854 "generate .xbp/xbp.yaml",
855 e,
856 Some("Use --update to refresh an existing config or --force to overwrite it."),
857 ));
858 }
859 }
860 commands::GenerateSubCommand::Systemd(subcmd) => {
861 let args = GenerateSystemdArgs {
862 output_dir: subcmd.output_dir,
863 service: subcmd.service,
864 api: subcmd.api,
865 };
866 if let Err(e) = run_generate_systemd(args, debug).await {
867 let _ = log_error(
868 "generate-systemd",
869 "Failed to generate systemd units",
870 Some(&e),
871 )
872 .await;
873 return Err(ErrorFactory::operation(
874 "generate-systemd",
875 "generate unit files",
876 e,
877 Some("Use a writable `--output-dir` or run with elevated permissions."),
878 ));
879 }
880 }
881 }
882 Ok(())
883}
884
885pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
886 if let Err(e) = crate::commands::run_done(
887 cmd.root,
888 cmd.since,
889 cmd.output,
890 cmd.no_ai,
891 cmd.recursive,
892 cmd.exclude,
893 )
894 .await
895 {
896 let _ = log_error("done", "Done command failed", Some(&e)).await;
897 return Err(e.into());
898 }
899 Ok(())
900}
901
902pub(super) async fn handle_fix_process_monitor_json(
903 cmd: commands::FixProcessMonitorJsonCmd,
904 _debug: bool,
905) -> CliResult<()> {
906 if let Err(error) =
907 crate::commands::run_fix_process_monitor_json(cmd.path, cmd.check, cmd.stdout)
908 {
909 let _ = log_error(
910 "fix-process-monitor-json",
911 "Failed to repair process monitor JSON",
912 Some(&error),
913 )
914 .await;
915 return Err(error.into());
916 }
917 Ok(())
918}
919
920pub(super) async fn handle_cursor(cmd: commands::CursorCmd) -> CliResult<()> {
921 let result = match cmd.command {
922 commands::CursorSubCommand::Ingest { dry_run } => run_cursor_ingest(dry_run).await,
923 };
924
925 if let Err(error) = result {
926 let _ = log_error("cursor", "Cursor command failed", Some(&error)).await;
927 return Err(error.into());
928 }
929 Ok(())
930}
931
932pub(super) async fn handle_login(cmd: commands::LoginCmd) -> CliResult<()> {
933 let result = match cmd.action {
934 Some(commands::LoginSubCommand::Status) => run_login_status().await,
935 Some(commands::LoginSubCommand::Logout) => run_logout().await,
936 None => run_login().await,
937 };
938
939 if let Err(e) = result {
940 let _ = log_error("login", "Login command failed", Some(&e)).await;
941 return Err(e.into());
942 }
943
944 Ok(())
945}
946
947pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
948 if let Err(e) = require_authenticated_cli_session().await {
949 return Err(ErrorFactory::operation(
950 "version",
951 "verify CLI login",
952 e,
953 Some("Run `xbp login` before using version workflows."),
954 ));
955 }
956
957 let commands::VersionCmd {
958 target,
959 explicit_version,
960 git,
961 command,
962 } = cmd;
963 let resolved_target = explicit_version.or(target);
964
965 if command.is_some() && (resolved_target.is_some() || git) {
966 return Err(ErrorFactory::validation(
967 "version",
968 "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
969 Some(
970 "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
971 ),
972 ));
973 }
974
975 if let Some(subcommand) = command {
976 match subcommand {
977 commands::VersionSubCommand::Release(release_cmd) => {
978 let options = VersionReleaseOptions {
979 explicit_version: release_cmd.version,
980 allow_dirty: release_cmd.allow_dirty,
981 title: release_cmd.title,
982 notes: release_cmd.notes,
983 notes_file: release_cmd.notes_file,
984 draft: release_cmd.draft,
985 prerelease: release_cmd.prerelease,
986 publish: release_cmd.publish,
987 force: release_cmd.force,
988 latest_policy: match release_cmd.make_latest {
989 commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
990 commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
991 commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
992 },
993 };
994 if let Err(e) = run_version_release_command(options).await {
995 let hint = version_release_error_hint(&e);
996 return Err(ErrorFactory::operation(
997 "version",
998 "release version",
999 e,
1000 hint,
1001 ));
1002 }
1003 return Ok(());
1004 }
1005 commands::VersionSubCommand::Discover(discover_cmd) => {
1006 if let Err(e) = run_version_discover_services(&discover_cmd).await {
1007 return Err(ErrorFactory::operation(
1008 "version",
1009 "discover and register project services",
1010 e,
1011 Some(
1012 "Run `xbp version discover --dry-run` to preview package roots, or `--no-register` to opt out of writing.",
1013 ),
1014 ));
1015 }
1016 return Ok(());
1017 }
1018 commands::VersionSubCommand::Bump(bump_cmd) => {
1019 if let Err(e) = run_version_bump_command(&bump_cmd).await {
1020 return Err(ErrorFactory::operation(
1021 "version",
1022 "bump mutated package versions",
1023 e,
1024 Some(
1025 "Run `xbp version bump --dry-run` to preview candidates, or `xbp version bump --all --patch` in non-interactive shells.",
1026 ),
1027 ));
1028 }
1029 return Ok(());
1030 }
1031 commands::VersionSubCommand::Workspace(workspace_cmd) => {
1032 let (repo, json, workspace_command) = match workspace_cmd.command {
1033 commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
1034 check_cmd.target.repo,
1035 check_cmd.target.json,
1036 WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
1037 version: check_cmd.version,
1038 }),
1039 ),
1040 commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
1041 sync_cmd.target.repo,
1042 sync_cmd.target.json,
1043 WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
1044 version: sync_cmd.version,
1045 write: sync_cmd.write,
1046 }),
1047 ),
1048 commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
1049 validate_cmd.target.repo,
1050 validate_cmd.target.json,
1051 WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
1052 package: validate_cmd.package,
1053 cargo_check: validate_cmd.cargo_check,
1054 package_dry_run: validate_cmd.package_dry_run,
1055 }),
1056 ),
1057 commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
1058 match publish_cmd.command {
1059 commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
1060 plan_cmd.target.repo,
1061 plan_cmd.target.json,
1062 WorkspaceVersionCommand::PublishPlan(WorkspacePublishPlanOptions {
1063 only: plan_cmd.only,
1064 include_prereqs: plan_cmd.include_prereqs,
1065 }),
1066 ),
1067 commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
1068 run_cmd.target.repo,
1069 run_cmd.target.json,
1070 WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
1071 dry_run: run_cmd.dry_run,
1072 from: run_cmd.from,
1073 only: run_cmd.only,
1074 include_prereqs: run_cmd.include_prereqs,
1075 continue_on_error: run_cmd.continue_on_error,
1076 allow_dirty: run_cmd.allow_dirty,
1077 timeout_seconds: run_cmd.timeout_seconds,
1078 poll_interval_seconds: run_cmd.poll_interval_seconds,
1079 }),
1080 ),
1081 }
1082 }
1083 };
1084
1085 if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
1086 repo,
1087 json,
1088 command: workspace_command,
1089 })
1090 .await
1091 {
1092 return Err(ErrorFactory::operation(
1093 "version",
1094 "run workspace version command",
1095 e,
1096 Some("Run `xbp version workspace -h` to inspect supported usage."),
1097 ));
1098 }
1099 return Ok(());
1100 }
1101 }
1102 }
1103
1104 if let Err(e) = run_version_command(resolved_target, git, debug).await {
1105 return Err(ErrorFactory::operation(
1106 "version",
1107 "run version command",
1108 e,
1109 Some("Run `xbp version -h` to inspect supported usage."),
1110 ));
1111 }
1112 Ok(())
1113}
1114
1115fn version_release_error_hint(error: &str) -> Option<&'static str> {
1116 if error.contains("Working tree is dirty") || error.contains("uncommitted changes") {
1117 return Some(
1118 "Use `--allow-dirty` if only generated files changed. `--force` also allows a dirty tree when `--publish` is enabled.",
1119 );
1120 }
1121 if error.contains("CARGO_REGISTRY_TOKEN") || error.contains("no token found") {
1122 return Some(
1123 "Configure a crates.io token with `xbp config crates set-key` or export `CARGO_REGISTRY_TOKEN`.",
1124 );
1125 }
1126 if error.contains("Workspace version drift detected") {
1127 return Some(
1128 "Run `xbp version workspace sync --write` to align crate versions, or pass `--force` to auto-sync during release.",
1129 );
1130 }
1131 if error.contains("Workspace closure detected")
1132 || error.contains("workspace publish command failed")
1133 {
1134 return Some(
1135 "For multi-crate workspaces, inspect the publish output and run `xbp version workspace publish plan --include-prereqs` first.",
1136 );
1137 }
1138 if error.contains("Release cancelled") {
1139 return Some(
1140 "Re-run with `--allow-dirty` to skip dirty-tree prompts, or `--force` to auto-heal workspace versions and allow dirty publishes.",
1141 );
1142 }
1143 None
1144}
1145
1146fn should_print_help(cli: &Cli) -> bool {
1147 cli.command.is_none() && !cli.list && !cli.logs && !cli.commands && cli.port.is_none()
1148}
1149
1150fn background_cursor_ingest_trigger(command: Option<&commands::Commands>) -> Option<&'static str> {
1151 match command {
1152 None => None,
1153 Some(commands::Commands::Cursor(_)) => None,
1154 Some(commands::Commands::Diag(_)) => None,
1155 Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1156 None => Some("login"),
1157 Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1158 },
1159 Some(other) => Some(commands::command_label(other)),
1160 }
1161}
1162
1163fn background_system_inventory_refresh_trigger(
1164 command: Option<&commands::Commands>,
1165) -> Option<&'static str> {
1166 match command {
1167 None => None,
1168 Some(commands::Commands::Cursor(_)) => None,
1169 Some(commands::Commands::Diag(_)) => None,
1170 Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1171 None => Some("login"),
1172 Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1173 },
1174 Some(other) => Some(commands::command_label(other)),
1175 }
1176}
1177
1178fn print_service_usage() {
1179 ui::configure_color_output();
1180 println!();
1181 println!("{}", "xbp service".bright_magenta().bold());
1182 ui::divider(28);
1183 println!(
1184 "{} {}",
1185 "Usage:".bright_cyan().bold(),
1186 "xbp service <command> <service-name>".bright_white()
1187 );
1188 ui::section("Commands");
1189 for command in ["build", "install", "start", "dev"] {
1190 println!(" {}", command.bright_cyan());
1191 }
1192 ui::section("Examples");
1193 println!(" {}", "xbp service build zeus".dimmed());
1194 ui::tip("Run `xbp service --help <service-name>` for service-specific guidance.");
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200 use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
1201
1202 #[test]
1203 fn plain_cli_invocation_prints_help() {
1204 let cli = Cli::try_parse_from(["xbp"]).expect("parse");
1205 assert!(should_print_help(&cli));
1206 }
1207
1208 #[test]
1209 fn list_flag_skips_help_short_circuit() {
1210 let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
1211 assert!(!should_print_help(&cli));
1212 }
1213
1214 #[test]
1215 fn commands_flag_skips_help_short_circuit() {
1216 let cli = Cli::try_parse_from(["xbp", "--commands"]).expect("parse");
1217 assert!(!should_print_help(&cli));
1218 assert!(cli.commands);
1219 }
1220
1221 #[test]
1222 fn install_without_package_parses_and_is_optional() {
1223 let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
1224 let Some(Commands::Install { list, package }) = cli.command else {
1225 panic!("expected install command");
1226 };
1227 assert!(!list);
1228 assert!(package.is_none());
1229 }
1230
1231 #[test]
1232 fn install_with_package_still_parses() {
1233 let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
1234 let Some(Commands::Install { list, package }) = cli.command else {
1235 panic!("expected install command");
1236 };
1237 assert!(!list);
1238 assert_eq!(package.as_deref(), Some("docker"));
1239 }
1240
1241 #[test]
1242 fn install_list_flag_parses() {
1243 let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
1244 let Some(Commands::Install { list, package }) = cli.command else {
1245 panic!("expected install command");
1246 };
1247 assert!(list);
1248 assert!(package.is_none());
1249 }
1250
1251 #[test]
1252 fn install_short_list_flag_parses() {
1253 let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
1254 let Some(Commands::Install { list, package }) = cli.command else {
1255 panic!("expected install command");
1256 };
1257 assert!(list);
1258 assert!(package.is_none());
1259 }
1260
1261 #[test]
1262 fn install_ls_alias_parses_as_package() {
1263 let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
1264 let Some(Commands::Install { list, package }) = cli.command else {
1265 panic!("expected install command");
1266 };
1267 assert!(!list);
1268 assert_eq!(package.as_deref(), Some("ls"));
1269 }
1270
1271 #[test]
1272 fn version_release_make_latest_parses_explicit_value() {
1273 let cli = Cli::try_parse_from([
1274 "xbp",
1275 "version",
1276 "release",
1277 "--version",
1278 "1.2.3",
1279 "--make-latest",
1280 "false",
1281 ])
1282 .expect("parse");
1283 let Some(Commands::Version(version_cmd)) = cli.command else {
1284 panic!("expected version command");
1285 };
1286 assert!(version_cmd.explicit_version.is_none());
1287 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1288 panic!("expected version release subcommand");
1289 };
1290 assert!(matches!(
1291 release_cmd.make_latest,
1292 VersionReleaseLatest::False
1293 ));
1294 }
1295
1296 #[test]
1297 fn version_release_make_latest_defaults_to_legacy() {
1298 let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
1299 .expect("parse");
1300 let Some(Commands::Version(version_cmd)) = cli.command else {
1301 panic!("expected version command");
1302 };
1303 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1304 panic!("expected version release subcommand");
1305 };
1306 assert!(matches!(
1307 release_cmd.make_latest,
1308 VersionReleaseLatest::Legacy
1309 ));
1310 }
1311
1312 #[test]
1313 fn version_explicit_flag_parses() {
1314 let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
1315 let Some(Commands::Version(version_cmd)) = cli.command else {
1316 panic!("expected version command");
1317 };
1318 assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1319 assert!(version_cmd.target.is_none());
1320 assert!(version_cmd.command.is_none());
1321 }
1322
1323 #[test]
1324 fn version_short_explicit_flag_parses_with_alias() {
1325 let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
1326 let Some(Commands::Version(version_cmd)) = cli.command else {
1327 panic!("expected version command");
1328 };
1329 assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1330 assert!(version_cmd.target.is_none());
1331 assert!(version_cmd.command.is_none());
1332 }
1333}