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::curl;
15use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
16use crate::commands::redeploy_v2::run_redeploy_v2;
17use crate::commands::{
18 install_package, list_services, open_global_config, run_config,
19 run_config_linear_select_initiative, run_config_secret_delete, run_config_secret_set,
20 run_config_secret_show, run_generate_config, run_init, run_login, run_redeploy,
21 run_redeploy_service, run_service_command, run_setup, run_version_command,
22 run_version_release_command, show_service_help, GenerateConfigArgs, ReleaseLatestPolicy,
23 VersionReleaseOptions,
24};
25use crate::commands::{run_diag, run_nginx};
26use crate::config::sync_versioning_files_registry;
27use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
28use clap::{error::ErrorKind as ClapErrorKind, CommandFactory, Parser};
29use colored::Colorize;
30use commands::Cli;
31
32pub async fn run() -> CliResult<()> {
33 let cli: Cli = match Cli::try_parse() {
34 Ok(cli) => cli,
35 Err(err) => {
36 let kind = err.kind();
37 let rendered = err.to_string();
38 let _ = err.print();
39 if matches!(
40 kind,
41 ClapErrorKind::DisplayHelp
42 | ClapErrorKind::DisplayVersion
43 | ClapErrorKind::MissingSubcommand
44 ) || rendered.contains("Manage the XBP API server")
45 {
46 return Ok(());
47 }
48 return Err(ErrorFactory::clap_parse(err));
49 }
50 };
51
52 if should_print_help(&cli) {
55 let mut cmd = Cli::command();
56 let _ = cmd.print_help();
57 println!();
58 return Ok(());
59 }
60
61 let debug: bool = cli.debug;
62 ui::configure_color_output();
63 let command_name = cli
64 .command
65 .as_ref()
66 .map(commands::command_label)
67 .unwrap_or("interactive");
68 ui::print_cli_header(command_name, debug);
69
70 if let Err(e) = init_logger(debug).await {
71 let _ = log_error(
72 "system",
73 "Failed to initialize logger",
74 Some(&e.to_string()),
75 )
76 .await;
77 }
78 if let Err(e) = sync_versioning_files_registry() {
79 let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
80 }
81
82 let mut ctx = AppContext::new(debug);
83 router::dispatch(cli, &mut ctx).await
84}
85
86pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
87 if let Err(e) = run_init(debug).await {
88 let _ = log_error("init", "Init failed", Some(&e)).await;
89 return Err(e.into());
90 }
91 Ok(())
92}
93
94pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
95 if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
96 let _ = log_error("setup", "Setup failed", Some(&e)).await;
97 return Err(ErrorFactory::operation(
98 "setup",
99 "setup environment",
100 e,
101 Some("Run with `--debug` for command-level output."),
102 ));
103 }
104 Ok(())
105}
106
107pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
108 if let Some(name) = service_name {
109 if let Err(e) = ui::with_loader(
110 &format!("Redeploying service `{}`", name),
111 run_redeploy_service(&name, debug),
112 )
113 .await
114 {
115 let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
116 return Err(ErrorFactory::operation(
117 "redeploy",
118 &format!("redeploy service `{}`", name),
119 e,
120 Some("Verify service name with `xbp services`."),
121 ));
122 }
123 } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
124 let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
125 return Err(ErrorFactory::operation(
126 "redeploy",
127 "redeploy project",
128 e,
129 Some("Try `xbp redeploy <service>` for scoped retries."),
130 ));
131 }
132 Ok(())
133}
134
135pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
136 let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
137 match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
138 Ok(()) => Ok(()),
139 Err(e) => {
140 let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
141 Err(e.into())
142 }
143 }
144}
145
146pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
147 let commands::ConfigCmd {
148 project,
149 no_open,
150 provider,
151 } = cmd;
152
153 if provider.is_some() && (project || no_open) {
154 return Err(ErrorFactory::validation(
155 "config",
156 "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
157 Some("Run either provider key management OR project/global config actions."),
158 ));
159 }
160
161 if let Some(provider_cmd) = provider {
162 match provider_cmd {
163 commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
164 commands::ConfigSecretAction::SetKey { key } => {
165 if let Err(e) = run_config_secret_set("openrouter", key).await {
166 let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
167 return Err(ErrorFactory::operation(
168 "config",
169 "set OpenRouter key",
170 e,
171 Some("Run `xbp config openrouter show` to confirm key state."),
172 ));
173 }
174 }
175 commands::ConfigSecretAction::DeleteKey => {
176 if let Err(e) = run_config_secret_delete("openrouter").await {
177 let _ =
178 log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
179 return Err(ErrorFactory::operation(
180 "config",
181 "delete OpenRouter key",
182 e,
183 Some("Use `xbp config openrouter show` to verify removal."),
184 ));
185 }
186 }
187 commands::ConfigSecretAction::Show { raw } => {
188 if let Err(e) = run_config_secret_show("openrouter", raw).await {
189 let _ =
190 log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
191 return Err(ErrorFactory::operation(
192 "config",
193 "show OpenRouter key",
194 e,
195 None,
196 ));
197 }
198 }
199 },
200 commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
201 commands::ConfigSecretAction::SetKey { key } => {
202 if let Err(e) = run_config_secret_set("github", key).await {
203 let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
204 return Err(ErrorFactory::operation(
205 "config",
206 "set GitHub token",
207 e,
208 Some("Use a token with repo scope for private repos."),
209 ));
210 }
211 }
212 commands::ConfigSecretAction::DeleteKey => {
213 if let Err(e) = run_config_secret_delete("github").await {
214 let _ =
215 log_error("config", "Failed to delete GitHub token", Some(&e)).await;
216 return Err(ErrorFactory::operation(
217 "config",
218 "delete GitHub token",
219 e,
220 None,
221 ));
222 }
223 }
224 commands::ConfigSecretAction::Show { raw } => {
225 if let Err(e) = run_config_secret_show("github", raw).await {
226 let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
227 return Err(ErrorFactory::operation(
228 "config",
229 "show GitHub token",
230 e,
231 None,
232 ));
233 }
234 }
235 },
236 commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
237 commands::LinearConfigAction::SetKey { key } => {
238 if let Err(e) = run_config_secret_set("linear", key).await {
239 let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
240 return Err(ErrorFactory::operation(
241 "config",
242 "set Linear API key",
243 e,
244 Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
245 ));
246 }
247 }
248 commands::LinearConfigAction::DeleteKey => {
249 if let Err(e) = run_config_secret_delete("linear").await {
250 let _ =
251 log_error("config", "Failed to delete Linear API key", Some(&e)).await;
252 return Err(ErrorFactory::operation(
253 "config",
254 "delete Linear API key",
255 e,
256 None,
257 ));
258 }
259 }
260 commands::LinearConfigAction::Show { raw } => {
261 if let Err(e) = run_config_secret_show("linear", raw).await {
262 let _ =
263 log_error("config", "Failed to show Linear API key", Some(&e)).await;
264 return Err(ErrorFactory::operation(
265 "config",
266 "show Linear API key",
267 e,
268 None,
269 ));
270 }
271 }
272 commands::LinearConfigAction::SelectInitiative => {
273 if let Err(e) = run_config_linear_select_initiative().await {
274 let _ = log_error(
275 "config",
276 "Failed to select repo Linear initiative",
277 Some(&e),
278 )
279 .await;
280 return Err(ErrorFactory::operation(
281 "config",
282 "select repo Linear initiative",
283 e,
284 Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
285 ));
286 }
287 }
288 },
289 }
290 } else if project {
291 if let Err(e) = run_config(debug).await {
292 return Err(ErrorFactory::operation(
293 "config",
294 "read project config",
295 e,
296 Some("Ensure you're inside an XBP project root."),
297 ));
298 }
299 } else if let Err(e) = open_global_config(no_open).await {
300 let _ = log_error("config", "Failed to open global config", Some(&e)).await;
301 return Err(ErrorFactory::operation(
302 "config",
303 "open global config",
304 e,
305 None,
306 ));
307 }
308 Ok(())
309}
310
311pub(super) async fn handle_install(
312 package: Option<String>,
313 list: bool,
314 debug: bool,
315) -> CliResult<()> {
316 if list {
317 crate::commands::print_install_targets_help();
318 return Ok(());
319 }
320
321 let Some(package) = package else {
322 crate::commands::print_install_empty_state();
323 return Ok(());
324 };
325
326 if package.trim().is_empty() {
327 crate::commands::print_install_empty_state();
328 return Ok(());
329 }
330
331 if crate::commands::is_install_listing_request(&package) {
332 crate::commands::print_install_targets_help();
333 return Ok(());
334 }
335
336 let install_msg: String = format!("Installing package: {}", package);
337 let _ = log_info("install", &install_msg, None).await;
338 match ui::with_loader(
339 &format!("Installing package `{}`", package),
340 install_package(&package, debug),
341 )
342 .await
343 {
344 Ok(()) => {
345 let success_msg = format!("Successfully installed: {}", package);
346 let _ = log_success("install", &success_msg, None).await;
347 Ok(())
348 }
349 Err(e) => Err(e.into()),
350 }
351}
352
353pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
354 let url = cmd
355 .url
356 .unwrap_or_else(|| "https://example.com/api".to_string());
357 if let Err(e) = ui::with_loader(
358 &format!("Requesting {}", url),
359 curl::run_curl(&url, cmd.no_timeout, debug),
360 )
361 .await
362 {
363 let _ = log_error("curl", "Curl command failed", Some(&e)).await;
364 return Err(ErrorFactory::operation(
365 "curl",
366 "execute request",
367 e,
368 Some("Double-check the URL and network connectivity."),
369 ));
370 }
371 Ok(())
372}
373
374pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
375 if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
376 let _ = log_error("services", "Failed to list services", Some(&e)).await;
377 return Err(ErrorFactory::operation(
378 "services",
379 "list services",
380 e,
381 Some("Ensure xbp config is present and valid."),
382 ));
383 }
384 Ok(())
385}
386
387pub(super) async fn handle_service(
388 command: Option<String>,
389 service_name: Option<String>,
390 debug: bool,
391) -> CliResult<()> {
392 if let Some(cmd) = command {
393 if cmd == "--help" || cmd == "help" {
394 if let Some(name) = service_name {
395 if let Err(e) = show_service_help(&name).await {
396 let _ = log_error("service", "Failed to show service help", Some(&e)).await;
397 return Err(ErrorFactory::operation(
398 "service",
399 "show service help",
400 e,
401 None,
402 ));
403 }
404 } else {
405 print_service_usage();
406 }
407 } else if let Some(name) = service_name {
408 if let Err(e) = run_service_command(&cmd, &name, debug).await {
409 let _ = log_error(
410 "service",
411 &format!("Service command '{}' failed", cmd),
412 Some(&e),
413 )
414 .await;
415 return Err(ErrorFactory::operation(
416 "service",
417 &format!("run `{}` for `{}`", cmd, name),
418 e,
419 Some("Check available services via `xbp services`."),
420 ));
421 }
422 } else {
423 let _ = log_error("service", "Service name required", None).await;
424 return Err(ErrorFactory::validation(
425 "service",
426 "Service name required.",
427 Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
428 ));
429 }
430 } else {
431 print_service_usage();
432 }
433 Ok(())
434}
435
436pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
437 if let Err(e) = run_nginx(cmd, debug).await {
438 let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
439 return Err(ErrorFactory::operation(
440 "nginx",
441 "execute nginx command",
442 e.to_string(),
443 Some("Try `xbp nginx --help` for command syntax."),
444 ));
445 }
446 Ok(())
447}
448
449pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
450 if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
451 let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
452 return Err(ErrorFactory::operation(
453 "diag",
454 "run diagnostics",
455 e.to_string(),
456 Some("Re-run with `--debug` for more context."),
457 ));
458 }
459 Ok(())
460}
461
462pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
463 match cmd.command {
464 commands::GenerateSubCommand::Config(subcmd) => {
465 let args = GenerateConfigArgs {
466 force: subcmd.force,
467 update: subcmd.update,
468 from_json: subcmd.from_json,
469 };
470 if let Err(e) = run_generate_config(args, debug).await {
471 let _ = log_error(
472 "generate-config",
473 "Failed to generate project config",
474 Some(&e),
475 )
476 .await;
477 return Err(ErrorFactory::operation(
478 "generate-config",
479 "generate .xbp/xbp.yaml",
480 e,
481 Some("Use --update to refresh an existing config or --force to overwrite it."),
482 ));
483 }
484 }
485 commands::GenerateSubCommand::Systemd(subcmd) => {
486 let args = GenerateSystemdArgs {
487 output_dir: subcmd.output_dir,
488 service: subcmd.service,
489 api: subcmd.api,
490 };
491 if let Err(e) = run_generate_systemd(args, debug).await {
492 let _ = log_error(
493 "generate-systemd",
494 "Failed to generate systemd units",
495 Some(&e),
496 )
497 .await;
498 return Err(ErrorFactory::operation(
499 "generate-systemd",
500 "generate unit files",
501 e,
502 Some("Use a writable `--output-dir` or run with elevated permissions."),
503 ));
504 }
505 }
506 }
507 Ok(())
508}
509
510pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
511 if let Err(e) = crate::commands::run_done(
512 cmd.root,
513 cmd.since,
514 cmd.output,
515 cmd.no_ai,
516 cmd.recursive,
517 cmd.exclude,
518 )
519 .await
520 {
521 let _ = log_error("done", "Done command failed", Some(&e)).await;
522 return Err(e.into());
523 }
524 Ok(())
525}
526
527pub(super) async fn handle_login() -> CliResult<()> {
528 if let Err(e) = run_login().await {
529 let _ = log_error("login", "Login failed", Some(&e)).await;
530 return Err(e.into());
531 }
532 Ok(())
533}
534
535pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
536 let commands::VersionCmd {
537 target,
538 git,
539 command,
540 } = cmd;
541
542 if command.is_some() && (target.is_some() || git) {
543 return Err(ErrorFactory::validation(
544 "version",
545 "`xbp version release` cannot be combined with `--git` or positional targets.",
546 Some("Run `xbp version release` as a standalone command."),
547 ));
548 }
549
550 if let Some(subcommand) = command {
551 match subcommand {
552 commands::VersionSubCommand::Release(release_cmd) => {
553 let options = VersionReleaseOptions {
554 explicit_version: release_cmd.version,
555 allow_dirty: release_cmd.allow_dirty,
556 title: release_cmd.title,
557 notes: release_cmd.notes,
558 notes_file: release_cmd.notes_file,
559 draft: release_cmd.draft,
560 prerelease: release_cmd.prerelease,
561 latest_policy: match release_cmd.make_latest {
562 commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
563 commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
564 commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
565 },
566 };
567 if let Err(e) = run_version_release_command(options).await {
568 return Err(ErrorFactory::operation(
569 "version",
570 "release version",
571 e,
572 Some("Use `--allow-dirty` if only generated files changed."),
573 ));
574 }
575 return Ok(());
576 }
577 }
578 }
579
580 if let Err(e) = run_version_command(target, git, debug).await {
581 return Err(ErrorFactory::operation(
582 "version",
583 "run version command",
584 e,
585 Some("Run `xbp version -h` to inspect supported usage."),
586 ));
587 }
588 Ok(())
589}
590
591fn should_print_help(cli: &Cli) -> bool {
592 cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
593}
594
595fn print_service_usage() {
596 println!(
597 "\n{} {}",
598 "Usage:".bright_blue().bold(),
599 "xbp service <command> <service-name>".bright_white()
600 );
601 println!("{} build, install, start, dev", "Commands:".bright_blue(),);
602 println!("{} xbp service build zeus", "Example:".bright_blue());
603 println!(
604 "{} xbp service --help <service-name>",
605 "Tip:".bright_yellow().bold(),
606 );
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
613
614 #[test]
615 fn plain_cli_invocation_prints_help() {
616 let cli = Cli::try_parse_from(["xbp"]).expect("parse");
617 assert!(should_print_help(&cli));
618 }
619
620 #[test]
621 fn list_flag_skips_help_short_circuit() {
622 let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
623 assert!(!should_print_help(&cli));
624 }
625
626 #[test]
627 fn install_without_package_parses_and_is_optional() {
628 let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
629 let Some(Commands::Install { list, package }) = cli.command else {
630 panic!("expected install command");
631 };
632 assert!(!list);
633 assert!(package.is_none());
634 }
635
636 #[test]
637 fn install_with_package_still_parses() {
638 let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
639 let Some(Commands::Install { list, package }) = cli.command else {
640 panic!("expected install command");
641 };
642 assert!(!list);
643 assert_eq!(package.as_deref(), Some("docker"));
644 }
645
646 #[test]
647 fn install_list_flag_parses() {
648 let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
649 let Some(Commands::Install { list, package }) = cli.command else {
650 panic!("expected install command");
651 };
652 assert!(list);
653 assert!(package.is_none());
654 }
655
656 #[test]
657 fn install_short_list_flag_parses() {
658 let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
659 let Some(Commands::Install { list, package }) = cli.command else {
660 panic!("expected install command");
661 };
662 assert!(list);
663 assert!(package.is_none());
664 }
665
666 #[test]
667 fn install_ls_alias_parses_as_package() {
668 let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
669 let Some(Commands::Install { list, package }) = cli.command else {
670 panic!("expected install command");
671 };
672 assert!(!list);
673 assert_eq!(package.as_deref(), Some("ls"));
674 }
675
676 #[test]
677 fn version_release_make_latest_parses_explicit_value() {
678 let cli = Cli::try_parse_from([
679 "xbp",
680 "version",
681 "release",
682 "--version",
683 "1.2.3",
684 "--make-latest",
685 "false",
686 ])
687 .expect("parse");
688 let Some(Commands::Version(version_cmd)) = cli.command else {
689 panic!("expected version command");
690 };
691 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
692 panic!("expected version release subcommand");
693 };
694 assert!(matches!(
695 release_cmd.make_latest,
696 VersionReleaseLatest::False
697 ));
698 }
699
700 #[test]
701 fn version_release_make_latest_defaults_to_legacy() {
702 let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
703 .expect("parse");
704 let Some(Commands::Version(version_cmd)) = cli.command else {
705 panic!("expected version command");
706 };
707 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
708 panic!("expected version release subcommand");
709 };
710 assert!(matches!(
711 release_cmd.make_latest,
712 VersionReleaseLatest::Legacy
713 ));
714 }
715}