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