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_generate_config, run_init, run_login,
19    run_redeploy, run_redeploy_service, run_service_command, run_setup, run_version_command,
20    run_version_release_command, show_service_help, GenerateConfigArgs, ReleaseLatestPolicy,
21    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        }
235    } else if project {
236        if let Err(e) = run_config(debug).await {
237            return Err(ErrorFactory::operation(
238                "config",
239                "read project config",
240                e,
241                Some("Ensure you're inside an XBP project root."),
242            ));
243        }
244    } else if let Err(e) = open_global_config(no_open).await {
245        let _ = log_error("config", "Failed to open global config", Some(&e)).await;
246        return Err(ErrorFactory::operation(
247            "config",
248            "open global config",
249            e,
250            None,
251        ));
252    }
253    Ok(())
254}
255
256pub(super) async fn handle_install(package: Option<String>, debug: bool) -> CliResult<()> {
257    let Some(package) = package else {
258        crate::commands::print_install_targets_help();
259        return Ok(());
260    };
261
262    if package.is_empty() || package == "--help" || package == "help" {
263        crate::commands::print_install_targets_help();
264        return Ok(());
265    }
266
267    let install_msg: String = format!("Installing package: {}", package);
268    let _ = log_info("install", &install_msg, None).await;
269    match ui::with_loader(
270        &format!("Installing package `{}`", package),
271        install_package(&package, debug),
272    )
273    .await
274    {
275        Ok(()) => {
276            let success_msg = format!("Successfully installed: {}", package);
277            let _ = log_success("install", &success_msg, None).await;
278            Ok(())
279        }
280        Err(e) => Err(e.into()),
281    }
282}
283
284pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
285    let url = cmd
286        .url
287        .unwrap_or_else(|| "https://example.com/api".to_string());
288    if let Err(e) = ui::with_loader(
289        &format!("Requesting {}", url),
290        curl::run_curl(&url, cmd.no_timeout, debug),
291    )
292    .await
293    {
294        let _ = log_error("curl", "Curl command failed", Some(&e)).await;
295        return Err(ErrorFactory::operation(
296            "curl",
297            "execute request",
298            e,
299            Some("Double-check the URL and network connectivity."),
300        ));
301    }
302    Ok(())
303}
304
305pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
306    if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
307        let _ = log_error("services", "Failed to list services", Some(&e)).await;
308        return Err(ErrorFactory::operation(
309            "services",
310            "list services",
311            e,
312            Some("Ensure xbp config is present and valid."),
313        ));
314    }
315    Ok(())
316}
317
318pub(super) async fn handle_service(
319    command: Option<String>,
320    service_name: Option<String>,
321    debug: bool,
322) -> CliResult<()> {
323    if let Some(cmd) = command {
324        if cmd == "--help" || cmd == "help" {
325            if let Some(name) = service_name {
326                if let Err(e) = show_service_help(&name).await {
327                    let _ = log_error("service", "Failed to show service help", Some(&e)).await;
328                    return Err(ErrorFactory::operation(
329                        "service",
330                        "show service help",
331                        e,
332                        None,
333                    ));
334                }
335            } else {
336                print_service_usage();
337            }
338        } else if let Some(name) = service_name {
339            if let Err(e) = run_service_command(&cmd, &name, debug).await {
340                let _ = log_error(
341                    "service",
342                    &format!("Service command '{}' failed", cmd),
343                    Some(&e),
344                )
345                .await;
346                return Err(ErrorFactory::operation(
347                    "service",
348                    &format!("run `{}` for `{}`", cmd, name),
349                    e,
350                    Some("Check available services via `xbp services`."),
351                ));
352            }
353        } else {
354            let _ = log_error("service", "Service name required", None).await;
355            return Err(ErrorFactory::validation(
356                "service",
357                "Service name required.",
358                Some("Usage: `xbp service <build|install|start|dev> <service-name>`"),
359            ));
360        }
361    } else {
362        print_service_usage();
363    }
364    Ok(())
365}
366
367pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
368    if let Err(e) = run_nginx(cmd, debug).await {
369        let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
370        return Err(ErrorFactory::operation(
371            "nginx",
372            "execute nginx command",
373            e.to_string(),
374            Some("Try `xbp nginx --help` for command syntax."),
375        ));
376    }
377    Ok(())
378}
379
380pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
381    if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
382        let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
383        return Err(ErrorFactory::operation(
384            "diag",
385            "run diagnostics",
386            e.to_string(),
387            Some("Re-run with `--debug` for more context."),
388        ));
389    }
390    Ok(())
391}
392
393pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
394    match cmd.command {
395        commands::GenerateSubCommand::Config(subcmd) => {
396            let args = GenerateConfigArgs {
397                force: subcmd.force,
398                update: subcmd.update,
399                from_json: subcmd.from_json,
400            };
401            if let Err(e) = run_generate_config(args, debug).await {
402                let _ = log_error(
403                    "generate-config",
404                    "Failed to generate project config",
405                    Some(&e),
406                )
407                .await;
408                return Err(ErrorFactory::operation(
409                    "generate-config",
410                    "generate .xbp/xbp.yaml",
411                    e,
412                    Some("Use --update to refresh an existing config or --force to overwrite it."),
413                ));
414            }
415        }
416        commands::GenerateSubCommand::Systemd(subcmd) => {
417            let args = GenerateSystemdArgs {
418                output_dir: subcmd.output_dir,
419                service: subcmd.service,
420                api: subcmd.api,
421            };
422            if let Err(e) = run_generate_systemd(args, debug).await {
423                let _ = log_error(
424                    "generate-systemd",
425                    "Failed to generate systemd units",
426                    Some(&e),
427                )
428                .await;
429                return Err(ErrorFactory::operation(
430                    "generate-systemd",
431                    "generate unit files",
432                    e,
433                    Some("Use a writable `--output-dir` or run with elevated permissions."),
434                ));
435            }
436        }
437    }
438    Ok(())
439}
440
441pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
442    if let Err(e) = crate::commands::run_done(
443        cmd.root,
444        cmd.since,
445        cmd.output,
446        cmd.no_ai,
447        cmd.recursive,
448        cmd.exclude,
449    )
450    .await
451    {
452        let _ = log_error("done", "Done command failed", Some(&e)).await;
453        return Err(e.into());
454    }
455    Ok(())
456}
457
458pub(super) async fn handle_login() -> CliResult<()> {
459    if let Err(e) = run_login().await {
460        let _ = log_error("login", "Login failed", Some(&e)).await;
461        return Err(e.into());
462    }
463    Ok(())
464}
465
466pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
467    let commands::VersionCmd {
468        target,
469        git,
470        command,
471    } = cmd;
472
473    if command.is_some() && (target.is_some() || git) {
474        return Err(ErrorFactory::validation(
475            "version",
476            "`xbp version release` cannot be combined with `--git` or positional targets.",
477            Some("Run `xbp version release` as a standalone command."),
478        ));
479    }
480
481    if let Some(subcommand) = command {
482        match subcommand {
483            commands::VersionSubCommand::Release(release_cmd) => {
484                let options = VersionReleaseOptions {
485                    explicit_version: release_cmd.version,
486                    allow_dirty: release_cmd.allow_dirty,
487                    title: release_cmd.title,
488                    notes: release_cmd.notes,
489                    notes_file: release_cmd.notes_file,
490                    draft: release_cmd.draft,
491                    prerelease: release_cmd.prerelease,
492                    latest_policy: match release_cmd.make_latest {
493                        commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
494                        commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
495                        commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
496                    },
497                };
498                if let Err(e) = run_version_release_command(options).await {
499                    return Err(ErrorFactory::operation(
500                        "version",
501                        "release version",
502                        e,
503                        Some("Use `--allow-dirty` if only generated files changed."),
504                    ));
505                }
506                return Ok(());
507            }
508        }
509    }
510
511    if let Err(e) = run_version_command(target, git, debug).await {
512        return Err(ErrorFactory::operation(
513            "version",
514            "run version command",
515            e,
516            Some("Run `xbp version -h` to inspect supported usage."),
517        ));
518    }
519    Ok(())
520}
521
522fn should_print_help(cli: &Cli) -> bool {
523    cli.command.is_none() && !cli.list && !cli.logs && cli.port.is_none()
524}
525
526fn print_service_usage() {
527    println!(
528        "\n{} {}",
529        "Usage:".bright_blue().bold(),
530        "xbp service <command> <service-name>".bright_white()
531    );
532    println!("{} build, install, start, dev", "Commands:".bright_blue(),);
533    println!("{} xbp service build zeus", "Example:".bright_blue());
534    println!(
535        "{} xbp service --help <service-name>",
536        "Tip:".bright_yellow().bold(),
537    );
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
544
545    #[test]
546    fn plain_cli_invocation_prints_help() {
547        let cli = Cli::try_parse_from(["xbp"]).expect("parse");
548        assert!(should_print_help(&cli));
549    }
550
551    #[test]
552    fn list_flag_skips_help_short_circuit() {
553        let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
554        assert!(!should_print_help(&cli));
555    }
556
557    #[test]
558    fn install_without_package_parses_and_is_optional() {
559        let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
560        let Some(Commands::Install { package }) = cli.command else {
561            panic!("expected install command");
562        };
563        assert!(package.is_none());
564    }
565
566    #[test]
567    fn install_with_package_still_parses() {
568        let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
569        let Some(Commands::Install { package }) = cli.command else {
570            panic!("expected install command");
571        };
572        assert_eq!(package.as_deref(), Some("docker"));
573    }
574
575    #[test]
576    fn version_release_make_latest_parses_explicit_value() {
577        let cli = Cli::try_parse_from([
578            "xbp",
579            "version",
580            "release",
581            "--version",
582            "1.2.3",
583            "--make-latest",
584            "false",
585        ])
586        .expect("parse");
587        let Some(Commands::Version(version_cmd)) = cli.command else {
588            panic!("expected version command");
589        };
590        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
591            panic!("expected version release subcommand");
592        };
593        assert!(matches!(
594            release_cmd.make_latest,
595            VersionReleaseLatest::False
596        ));
597    }
598
599    #[test]
600    fn version_release_make_latest_defaults_to_legacy() {
601        let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
602            .expect("parse");
603        let Some(Commands::Version(version_cmd)) = cli.command else {
604            panic!("expected version command");
605        };
606        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
607            panic!("expected version release subcommand");
608        };
609        assert!(matches!(
610            release_cmd.make_latest,
611            VersionReleaseLatest::Legacy
612        ));
613    }
614}