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