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