Skip to main content

xbp_cli/cli/
mod.rs

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    // Keep plain `xbp` aligned with `xbp --help` instead of entering
51    // interactive project selection flow.
52    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}