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