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