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