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