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(package: Option<String>, debug: bool) -> CliResult<()> {
312 let Some(package) = package else {
313 crate::commands::print_install_targets_help();
314 return Ok(());
315 };
316
317 if package.is_empty() || package == "--help" || package == "help" {
318 crate::commands::print_install_targets_help();
319 return Ok(());
320 }
321
322 let install_msg: String = format!("Installing package: {}", package);
323 let _ = log_info("install", &install_msg, None).await;
324 match ui::with_loader(
325 &format!("Installing package `{}`", package),
326 install_package(&package, debug),
327 )
328 .await
329 {
330 Ok(()) => {
331 let success_msg = format!("Successfully installed: {}", package);
332 let _ = log_success("install", &success_msg, None).await;
333 Ok(())
334 }
335 Err(e) => Err(e.into()),
336 }
337}
338
339pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
340 let url = cmd
341 .url
342 .unwrap_or_else(|| "https://example.com/api".to_string());
343 if let Err(e) = ui::with_loader(
344 &format!("Requesting {}", url),
345 curl::run_curl(&url, cmd.no_timeout, debug),
346 )
347 .await
348 {
349 let _ = log_error("curl", "Curl command failed", Some(&e)).await;
350 return Err(ErrorFactory::operation(
351 "curl",
352 "execute request",
353 e,
354 Some("Double-check the URL and network connectivity."),
355 ));
356 }
357 Ok(())
358}
359
360pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
361 if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
362 let _ = log_error("services", "Failed to list services", Some(&e)).await;
363 return Err(ErrorFactory::operation(
364 "services",
365 "list services",
366 e,
367 Some("Ensure xbp config is present and valid."),
368 ));
369 }
370 Ok(())
371}
372
373pub(super) async fn handle_service(
374 command: Option<String>,
375 service_name: Option<String>,
376 debug: bool,
377) -> CliResult<()> {
378 if let Some(cmd) = command {
379 if cmd == "--help" || cmd == "help" {
380 if let Some(name) = service_name {
381 if let Err(e) = show_service_help(&name).await {
382 let _ = log_error("service", "Failed to show service help", Some(&e)).await;
383 return Err(ErrorFactory::operation(
384 "service",
385 "show service help",
386 e,
387 None,
388 ));
389 }
390 } else {
391 print_service_usage();
392 }
393 } else if let Some(name) = service_name {
394 if let Err(e) = run_service_command(&cmd, &name, debug).await {
395 let _ = log_error(
396 "service",
397 &format!("Service command '{}' failed", cmd),
398 Some(&e),
399 )
400 .await;
401 return Err(ErrorFactory::operation(
402 "service",
403 &format!("run `{}` for `{}`", cmd, name),
404 e,
405 Some("Check available services via `xbp services`."),
406 ));
407 }
408 } else {
409 let _ = log_error("service", "Service name required", None).await;
410 return Err(ErrorFactory::validation(
411 "service",
412 "Service name required.",
413 Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
414 ));
415 }
416 } else {
417 print_service_usage();
418 }
419 Ok(())
420}
421
422pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
423 if let Err(e) = run_nginx(cmd, debug).await {
424 let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
425 return Err(ErrorFactory::operation(
426 "nginx",
427 "execute nginx command",
428 e.to_string(),
429 Some("Try `xbp nginx --help` for command syntax."),
430 ));
431 }
432 Ok(())
433}
434
435pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
436 if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
437 let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
438 return Err(ErrorFactory::operation(
439 "diag",
440 "run diagnostics",
441 e.to_string(),
442 Some("Re-run with `--debug` for more context."),
443 ));
444 }
445 Ok(())
446}
447
448pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
449 match cmd.command {
450 commands::GenerateSubCommand::Config(subcmd) => {
451 let args = GenerateConfigArgs {
452 force: subcmd.force,
453 update: subcmd.update,
454 from_json: subcmd.from_json,
455 };
456 if let Err(e) = run_generate_config(args, debug).await {
457 let _ = log_error(
458 "generate-config",
459 "Failed to generate project config",
460 Some(&e),
461 )
462 .await;
463 return Err(ErrorFactory::operation(
464 "generate-config",
465 "generate .xbp/xbp.yaml",
466 e,
467 Some("Use --update to refresh an existing config or --force to overwrite it."),
468 ));
469 }
470 }
471 commands::GenerateSubCommand::Systemd(subcmd) => {
472 let args = GenerateSystemdArgs {
473 output_dir: subcmd.output_dir,
474 service: subcmd.service,
475 api: subcmd.api,
476 };
477 if let Err(e) = run_generate_systemd(args, debug).await {
478 let _ = log_error(
479 "generate-systemd",
480 "Failed to generate systemd units",
481 Some(&e),
482 )
483 .await;
484 return Err(ErrorFactory::operation(
485 "generate-systemd",
486 "generate unit files",
487 e,
488 Some("Use a writable `--output-dir` or run with elevated permissions."),
489 ));
490 }
491 }
492 }
493 Ok(())
494}
495
496pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
497 if let Err(e) = crate::commands::run_done(
498 cmd.root,
499 cmd.since,
500 cmd.output,
501 cmd.no_ai,
502 cmd.recursive,
503 cmd.exclude,
504 )
505 .await
506 {
507 let _ = log_error("done", "Done command failed", Some(&e)).await;
508 return Err(e.into());
509 }
510 Ok(())
511}
512
513pub(super) async fn handle_login() -> CliResult<()> {
514 if let Err(e) = run_login().await {
515 let _ = log_error("login", "Login failed", Some(&e)).await;
516 return Err(e.into());
517 }
518 Ok(())
519}
520
521pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
522 let commands::VersionCmd {
523 target,
524 git,
525 command,
526 } = cmd;
527
528 if command.is_some() && (target.is_some() || git) {
529 return Err(ErrorFactory::validation(
530 "version",
531 "`xbp version release` cannot be combined with `--git` or positional targets.",
532 Some("Run `xbp version release` as a standalone command."),
533 ));
534 }
535
536 if let Some(subcommand) = command {
537 match subcommand {
538 commands::VersionSubCommand::Release(release_cmd) => {
539 let options = VersionReleaseOptions {
540 explicit_version: release_cmd.version,
541 allow_dirty: release_cmd.allow_dirty,
542 title: release_cmd.title,
543 notes: release_cmd.notes,
544 notes_file: release_cmd.notes_file,
545 draft: release_cmd.draft,
546 prerelease: release_cmd.prerelease,
547 latest_policy: match release_cmd.make_latest {
548 commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
549 commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
550 commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
551 },
552 };
553 if let Err(e) = run_version_release_command(options).await {
554 return Err(ErrorFactory::operation(
555 "version",
556 "release version",
557 e,
558 Some("Use `--allow-dirty` if only generated files changed."),
559 ));
560 }
561 return Ok(());
562 }
563 }
564 }
565
566 if let Err(e) = run_version_command(target, git, debug).await {
567 return Err(ErrorFactory::operation(
568 "version",
569 "run version command",
570 e,
571 Some("Run `xbp version -h` to inspect supported usage."),
572 ));
573 }
574 Ok(())
575}
576
577fn should_print_help(cli: &Cli) -> bool {
578 cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
579}
580
581fn print_service_usage() {
582 println!(
583 "\n{} {}",
584 "Usage:".bright_blue().bold(),
585 "xbp service <command> <service-name>".bright_white()
586 );
587 println!("{} build, install, start, dev", "Commands:".bright_blue(),);
588 println!("{} xbp service build zeus", "Example:".bright_blue());
589 println!(
590 "{} xbp service --help <service-name>",
591 "Tip:".bright_yellow().bold(),
592 );
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
599
600 #[test]
601 fn plain_cli_invocation_prints_help() {
602 let cli = Cli::try_parse_from(["xbp"]).expect("parse");
603 assert!(should_print_help(&cli));
604 }
605
606 #[test]
607 fn list_flag_skips_help_short_circuit() {
608 let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
609 assert!(!should_print_help(&cli));
610 }
611
612 #[test]
613 fn install_without_package_parses_and_is_optional() {
614 let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
615 let Some(Commands::Install { package }) = cli.command else {
616 panic!("expected install command");
617 };
618 assert!(package.is_none());
619 }
620
621 #[test]
622 fn install_with_package_still_parses() {
623 let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
624 let Some(Commands::Install { package }) = cli.command else {
625 panic!("expected install command");
626 };
627 assert_eq!(package.as_deref(), Some("docker"));
628 }
629
630 #[test]
631 fn version_release_make_latest_parses_explicit_value() {
632 let cli = Cli::try_parse_from([
633 "xbp",
634 "version",
635 "release",
636 "--version",
637 "1.2.3",
638 "--make-latest",
639 "false",
640 ])
641 .expect("parse");
642 let Some(Commands::Version(version_cmd)) = cli.command else {
643 panic!("expected version command");
644 };
645 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
646 panic!("expected version release subcommand");
647 };
648 assert!(matches!(
649 release_cmd.make_latest,
650 VersionReleaseLatest::False
651 ));
652 }
653
654 #[test]
655 fn version_release_make_latest_defaults_to_legacy() {
656 let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
657 .expect("parse");
658 let Some(Commands::Version(version_cmd)) = cli.command else {
659 panic!("expected version command");
660 };
661 let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
662 panic!("expected version release subcommand");
663 };
664 assert!(matches!(
665 release_cmd.make_latest,
666 VersionReleaseLatest::Legacy
667 ));
668 }
669}