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