Skip to main content

xbp_cli/cli/
mod.rs

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