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