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