Skip to main content

xbp_cli/cli/
commands.rs

1use clap::{Args, Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4#[derive(Parser, Debug)]
5#[command(
6    name = "xbp",
7    version,
8    about = "Deploy, operate, and debug services with one CLI.",
9    long_about = "XBP is an operations-first CLI for deployments, diagnostics, service orchestration,\nnetwork controls, and runtime observability.",
10    disable_help_subcommand = false,
11    next_line_help = true,
12    help_template = "{before-help}{name} {version}\n{about-with-newline}\
13{usage-heading} {usage}\n\n\
14{all-args}\
15{after-help}",
16    after_help = "Quick start:\n  xbp diag\n  xbp services\n  xbp service start <name>\n  xbp api install --port 8080\n\nUse `xbp <command> -h` for command-specific examples."
17)]
18pub struct Cli {
19    #[arg(long, global = true, help = "Enable verbose debugging output")]
20    pub debug: bool,
21    #[arg(short = 'l', help = "List pm2 processes")]
22    pub list: bool,
23    #[arg(short = 'p', long = "port", help = "Filter by port number")]
24    pub port: Option<u16>,
25    #[arg(long, help = "Open logs directory")]
26    pub logs: bool,
27
28    #[command(subcommand)]
29    pub command: Option<Commands>,
30}
31
32#[derive(Subcommand, Debug)]
33pub enum Commands {
34    #[command(about = "Inspect or manage listening ports")]
35    Ports(PortsCmd),
36    #[command(about = "Initialize an XBP project in the current directory")]
37    Init,
38    #[command(about = "Install common dependencies for host setup")]
39    Setup,
40    #[command(about = "Redeploy one service or the entire project")]
41    Redeploy {
42        #[arg(
43            help = "Service name to redeploy (optional, uses legacy redeploy.sh if not provided)"
44        )]
45        service_name: Option<String>,
46    },
47    #[command(about = "Run the legacy remote redeploy workflow over SSH")]
48    RedeployV2(RedeployV2Cmd),
49    #[command(about = "Inspect project/global config and manage provider keys")]
50    Config(ConfigCmd),
51    #[command(
52        about = "Install supported host packages or project tooling",
53        after_help = crate::commands::INSTALL_COMMAND_AFTER_HELP
54    )]
55    Install {
56        #[arg(short = 'l', long = "list", help = "List installable targets and exit")]
57        list: bool,
58        #[arg(help = "Install target (leave empty to show installable options)")]
59        package: Option<String>,
60    },
61    #[command(about = "Tail local or remote logs")]
62    Logs(LogsCmd),
63    #[command(about = "List PM2 processes")]
64    List,
65    #[command(about = "Fetch an HTTP endpoint with sane defaults")]
66    Curl(CurlCmd),
67    #[command(about = "List configured services from project config")]
68    Services,
69    #[command(about = "Run service-level commands (build/install/start/dev)")]
70    Service {
71        #[arg(help = "Command to run: build, install, start, dev, or --help")]
72        command: Option<String>,
73        #[arg(help = "Service name")]
74        service_name: Option<String>,
75    },
76    #[command(about = "Manage NGINX site configs and upstream mappings")]
77    Nginx(NginxCmd),
78    #[command(about = "Manage host network configuration and floating IPs")]
79    Network(NetworkCmd),
80    #[command(about = "Run full system diagnostics and readiness checks")]
81    Diag(DiagCmd),
82    #[command(about = "Run health-check monitoring commands")]
83    Monitor(MonitorCmd),
84    #[command(about = "Capture a PM2 snapshot for later restore")]
85    Snapshot,
86    #[command(about = "Restore PM2 state from dump or latest snapshot")]
87    Resurrect,
88    #[command(about = "Stop a PM2 process by name or stop all")]
89    Stop {
90        #[arg(help = "PM2 process name or 'all' (default: all)")]
91        target: Option<String>,
92    },
93    #[command(about = "Flush PM2 logs globally or for a specific process")]
94    Flush {
95        #[arg(help = "Optional PM2 process name")]
96        target: Option<String>,
97    },
98    #[command(about = "Run login flow against configured XBP API")]
99    Login,
100    #[command(about = "Inspect, reconcile, or bump project versions")]
101    Version(VersionCmd),
102    #[command(about = "Show PM2 environment by name or numeric id")]
103    Env {
104        #[arg(help = "PM2 process name or id")]
105        target: String,
106    },
107    #[command(about = "Tail app logs or Kafka logs")]
108    Tail(TailCmd),
109    #[command(about = "Start a binary/process under PM2")]
110    Start {
111        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
112        args: Vec<String>,
113    },
114    #[command(about = "Generate helper artifacts such as systemd units")]
115    Generate(GenerateCmd),
116    #[cfg(feature = "secrets")]
117    #[command(about = "Manage env vars and GitHub Actions environment variables (feature-gated)")]
118    Secrets(SecretsCmd),
119    #[command(
120        about = "Generate 'what did I get done' Markdown report from git commits across repos"
121    )]
122    Done(DoneCmd),
123    #[cfg(feature = "kubernetes")]
124    #[command(about = "Experimental Kubernetes cluster manager (feature-gated)")]
125    Kubernetes(KubernetesCmd),
126    #[cfg(feature = "nordvpn")]
127    #[command(about = "NordVPN meshnet setup and passthrough (feature-gated)")]
128    Nordvpn(NordvpnCmd),
129    #[cfg(feature = "monitoring")]
130    Monitoring(MonitoringCmd),
131    #[command(about = "Manage the XBP API server")]
132    Api(ApiCmd),
133    #[cfg(feature = "docker")]
134    #[command(about = "Pass-through wrapper around the Docker CLI")]
135    Docker(DockerCmd),
136}
137
138pub fn command_label(command: &Commands) -> &'static str {
139    match command {
140        Commands::Ports(_) => "ports",
141        Commands::Init => "init",
142        Commands::Setup => "setup",
143        Commands::Redeploy { .. } => "redeploy",
144        Commands::RedeployV2(_) => "redeploy-v2",
145        Commands::Config(_) => "config",
146        Commands::Install { .. } => "install",
147        Commands::Logs(_) => "logs",
148        Commands::List => "list",
149        Commands::Curl(_) => "curl",
150        Commands::Services => "services",
151        Commands::Service { .. } => "service",
152        Commands::Nginx(_) => "nginx",
153        Commands::Network(_) => "network",
154        Commands::Diag(_) => "diag",
155        Commands::Monitor(_) => "monitor",
156        Commands::Snapshot => "snapshot",
157        Commands::Resurrect => "resurrect",
158        Commands::Stop { .. } => "stop",
159        Commands::Flush { .. } => "flush",
160        Commands::Login => "login",
161        Commands::Version(_) => "version",
162        Commands::Env { .. } => "env",
163        Commands::Tail(_) => "tail",
164        Commands::Start { .. } => "start",
165        Commands::Generate(_) => "generate",
166        #[cfg(feature = "secrets")]
167        Commands::Secrets(_) => "secrets",
168        Commands::Done(_) => "done",
169        #[cfg(feature = "kubernetes")]
170        Commands::Kubernetes(_) => "kubernetes",
171        #[cfg(feature = "nordvpn")]
172        Commands::Nordvpn(_) => "nordvpn",
173        #[cfg(feature = "monitoring")]
174        Commands::Monitoring(_) => "monitoring",
175        Commands::Api(_) => "api",
176        #[cfg(feature = "docker")]
177        Commands::Docker(_) => "docker",
178    }
179}
180
181#[derive(Args, Debug)]
182pub struct PortsCmd {
183    #[arg(short = 'p', long = "port")]
184    pub port: Option<u16>,
185    #[arg(long = "kill")]
186    pub kill: bool,
187    #[arg(short = 'n', long = "nginx")]
188    pub nginx: bool,
189    #[arg(
190        long = "full",
191        help = "Show one unified ports view (reconciled listeners + exposure + security flags)"
192    )]
193    pub full: bool,
194    #[arg(
195        long = "no-local",
196        help = "Exclude connections where LocalAddr equals RemoteAddr"
197    )]
198    pub no_local: bool,
199    #[arg(
200        long = "exposure",
201        help = "Diagnose external exposure per port (binding + firewall layer)"
202    )]
203    pub exposure: bool,
204}
205
206#[derive(Args, Debug)]
207pub struct ConfigCmd {
208    #[arg(
209        long,
210        help = "Show the current project config instead of opening global XBP paths"
211    )]
212    pub project: bool,
213    #[arg(long, help = "Print global XBP paths without opening them")]
214    pub no_open: bool,
215    #[command(subcommand)]
216    pub provider: Option<ConfigProviderCmd>,
217}
218
219#[derive(Subcommand, Debug)]
220pub enum ConfigProviderCmd {
221    #[command(about = "Manage the OpenRouter API key used by AI-enabled commands")]
222    Openrouter(ConfigSecretCmd),
223    #[command(about = "Manage the GitHub OAuth2 token used for release automation")]
224    Github(ConfigSecretCmd),
225    #[command(
226        about = "Manage the Linear API key used for release-note issue linking and initiative publishing"
227    )]
228    Linear(LinearConfigCmd),
229}
230
231#[derive(Args, Debug)]
232pub struct ConfigSecretCmd {
233    #[command(subcommand)]
234    pub action: ConfigSecretAction,
235}
236
237#[derive(Subcommand, Debug)]
238pub enum ConfigSecretAction {
239    #[command(about = "Set provider key (omit value to enter it securely)")]
240    SetKey {
241        #[arg(help = "Provider key/token value")]
242        key: Option<String>,
243    },
244    #[command(about = "Delete the stored provider key")]
245    DeleteKey,
246    #[command(about = "Show whether a key is configured (masked by default)")]
247    Show {
248        #[arg(long, help = "Print full key/token value (not masked)")]
249        raw: bool,
250    },
251}
252
253#[derive(Args, Debug)]
254pub struct LinearConfigCmd {
255    #[command(subcommand)]
256    pub action: LinearConfigAction,
257}
258
259#[derive(Subcommand, Debug)]
260pub enum LinearConfigAction {
261    #[command(about = "Set Linear API key (omit value to enter it securely)")]
262    SetKey {
263        #[arg(help = "Linear API key/token value")]
264        key: Option<String>,
265    },
266    #[command(about = "Delete the stored Linear API key")]
267    DeleteKey,
268    #[command(about = "Show whether a Linear API key is configured (masked by default)")]
269    Show {
270        #[arg(long, help = "Print full key/token value (not masked)")]
271        raw: bool,
272    },
273    #[command(
274        name = "select-initiative",
275        about = "Pick a Linear initiative for the current repo and save it to .xbp/xbp.yaml"
276    )]
277    SelectInitiative,
278}
279
280#[derive(Args, Debug)]
281pub struct CurlCmd {
282    #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
283    pub url: Option<String>,
284    #[arg(long, help = "Disable the default 15 second timeout")]
285    pub no_timeout: bool,
286}
287
288#[derive(Args, Debug)]
289#[command(subcommand_precedence_over_arg = true)]
290pub struct VersionCmd {
291    #[arg(
292        help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
293    )]
294    pub target: Option<String>,
295    #[arg(long, help = "Show normalized git tags from `git tag --list`")]
296    pub git: bool,
297    #[command(subcommand)]
298    pub command: Option<VersionSubCommand>,
299}
300
301#[derive(Subcommand, Debug)]
302pub enum VersionSubCommand {
303    #[command(about = "Create and push a git tag for this version, then create a GitHub release")]
304    Release(VersionReleaseCmd),
305}
306
307#[derive(Args, Debug)]
308pub struct VersionReleaseCmd {
309    #[arg(
310        long,
311        help = "Release this version instead of auto-detecting from tracked files"
312    )]
313    pub version: Option<String>,
314    #[arg(
315        long,
316        help = "Allow releasing with uncommitted changes in the working tree"
317    )]
318    pub allow_dirty: bool,
319    #[arg(long, help = "Release title (defaults to <version> - <repo>)")]
320    pub title: Option<String>,
321    #[arg(long, help = "Release notes body (Markdown)")]
322    pub notes: Option<String>,
323    #[arg(long, help = "Read release notes body from a file")]
324    pub notes_file: Option<PathBuf>,
325    #[arg(long, help = "Create as draft release")]
326    pub draft: bool,
327    #[arg(long, help = "Mark release as pre-release")]
328    pub prerelease: bool,
329    #[arg(
330        long,
331        value_enum,
332        default_value_t = VersionReleaseLatest::Legacy,
333        help = "Control GitHub latest flag: true, false, or legacy"
334    )]
335    pub make_latest: VersionReleaseLatest,
336}
337
338#[derive(Copy, Clone, Debug, ValueEnum)]
339pub enum VersionReleaseLatest {
340    True,
341    False,
342    Legacy,
343}
344
345#[derive(Args, Debug)]
346pub struct RedeployV2Cmd {
347    #[arg(short = 'p', long = "password")]
348    pub password: Option<String>,
349    #[arg(short = 'u', long = "username")]
350    pub username: Option<String>,
351    #[arg(short = 'h', long = "host")]
352    pub host: Option<String>,
353    #[arg(short = 'd', long = "project-dir")]
354    pub project_dir: Option<String>,
355}
356
357#[derive(Args, Debug)]
358pub struct LogsCmd {
359    #[arg()]
360    pub project: Option<String>,
361    #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
362    pub ssh_host: Option<String>,
363    #[arg(long = "ssh-username", help = "SSH username for remote host")]
364    pub ssh_username: Option<String>,
365    #[arg(long = "ssh-password", help = "SSH password for remote host")]
366    pub ssh_password: Option<String>,
367}
368
369#[derive(Args, Debug)]
370pub struct NginxCmd {
371    #[command(subcommand)]
372    pub command: NginxSubCommand,
373}
374
375#[derive(Args, Debug)]
376pub struct NetworkCmd {
377    #[command(subcommand)]
378    pub command: NetworkSubCommand,
379}
380
381#[derive(Subcommand, Debug)]
382pub enum NetworkSubCommand {
383    #[command(about = "Manage persistent floating IP configuration")]
384    FloatingIp(NetworkFloatingIpCmd),
385    #[command(about = "Inspect discovered network configuration sources")]
386    Config(NetworkConfigCmd),
387}
388
389#[derive(Args, Debug)]
390pub struct NetworkFloatingIpCmd {
391    #[command(subcommand)]
392    pub command: NetworkFloatingIpSubCommand,
393}
394
395#[derive(Subcommand, Debug)]
396pub enum NetworkFloatingIpSubCommand {
397    #[command(about = "Add a persistent floating IP entry to detected network backend")]
398    Add {
399        #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
400        ip: String,
401        #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
402        cidr: Option<u8>,
403        #[arg(long, help = "Network interface override (auto-detected when omitted)")]
404        interface: Option<String>,
405        #[arg(long, help = "Optional label for backend metadata/file naming")]
406        label: Option<String>,
407        #[arg(long, help = "Apply network changes after writing config")]
408        apply: bool,
409        #[arg(long, help = "Preview computed changes without writing files")]
410        dry_run: bool,
411    },
412    #[command(about = "List floating IPs from runtime and persisted network config")]
413    List {
414        #[arg(long, help = "Emit JSON output")]
415        json: bool,
416    },
417}
418
419#[derive(Args, Debug)]
420pub struct NetworkConfigCmd {
421    #[command(subcommand)]
422    pub command: NetworkConfigSubCommand,
423}
424
425#[derive(Subcommand, Debug)]
426pub enum NetworkConfigSubCommand {
427    #[command(about = "List detected backend and configuration source files")]
428    List {
429        #[arg(long, help = "Emit JSON output")]
430        json: bool,
431    },
432}
433
434#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
435pub enum NginxDnsMode {
436    Manual,
437    Plugin,
438}
439
440#[derive(Subcommand, Debug)]
441pub enum NginxSubCommand {
442    #[command(
443        about = "Provision an HTTPS NGINX reverse proxy with Certbot",
444        long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
445and write final HTTP->HTTPS redirect + TLS proxy config.\n\
446\n\
447Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
448Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
449with --dns-plugin and --dns-creds for non-interactive provider automation."
450    )]
451    Setup {
452        #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
453        domain: String,
454        #[arg(short, long, help = "Port to proxy to")]
455        port: u16,
456        #[arg(
457            short,
458            long,
459            help = "Email used for Let's Encrypt account registration"
460        )]
461        email: String,
462        #[arg(
463            long,
464            value_enum,
465            default_value_t = NginxDnsMode::Manual,
466            help = "DNS challenge mode for wildcard certificates: manual or plugin"
467        )]
468        dns_mode: NginxDnsMode,
469        #[arg(
470            long,
471            help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
472        )]
473        dns_plugin: Option<String>,
474        #[arg(
475            long,
476            help = "Path to DNS plugin credentials file for --dns-mode plugin"
477        )]
478        dns_creds: Option<PathBuf>,
479        #[arg(
480            long,
481            default_value_t = true,
482            action = clap::ArgAction::Set,
483            value_parser = clap::builder::BoolishValueParser::new(),
484            help = "For wildcard domains, also request the base domain certificate (true|false)"
485        )]
486        include_base: bool,
487    },
488    #[command(about = "List discovered NGINX sites with listen/upstream ports")]
489    List,
490    #[command(about = "Show full NGINX config for one domain or all domains")]
491    Show {
492        #[arg(help = "Optional domain name to inspect")]
493        domain: Option<String>,
494    },
495    #[command(about = "Open an NGINX site config in your configured editor")]
496    Edit {
497        #[arg(help = "Domain name to edit")]
498        domain: String,
499    },
500    #[command(about = "Update upstream port for an existing NGINX site")]
501    Update {
502        #[arg(short, long, help = "Domain name to update")]
503        domain: String,
504        #[arg(short, long, help = "New port to proxy to")]
505        port: u16,
506    },
507}
508
509#[derive(Args, Debug)]
510pub struct DiagCmd {
511    #[arg(long, help = "Check Nginx configuration")]
512    pub nginx: bool,
513    #[arg(long, help = "Check specific ports (comma-separated)")]
514    pub ports: Option<String>,
515    #[arg(long, help = "Skip internet speed test")]
516    pub no_speed_test: bool,
517    #[arg(
518        long,
519        help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
520    )]
521    pub compose_file: Option<String>,
522}
523
524#[derive(Args, Debug)]
525pub struct MonitorCmd {
526    #[command(subcommand)]
527    pub command: Option<MonitorSubCommand>,
528}
529
530#[derive(Subcommand, Debug)]
531pub enum MonitorSubCommand {
532    Check,
533    Start,
534}
535
536#[cfg(feature = "monitoring")]
537#[derive(Args, Debug)]
538pub struct MonitoringCmd {
539    #[command(subcommand)]
540    pub command: MonitoringSubCommand,
541}
542
543#[cfg(feature = "monitoring")]
544#[derive(Subcommand, Debug)]
545pub enum MonitoringSubCommand {
546    Serve {
547        #[arg(
548            short,
549            long,
550            default_value = "prodzilla.yml",
551            help = "Monitoring config file"
552        )]
553        file: String,
554    },
555    RunOnce {
556        #[arg(
557            short,
558            long,
559            default_value = "prodzilla.yml",
560            help = "Monitoring config file"
561        )]
562        file: String,
563        #[arg(long, help = "Run probes only")]
564        probes_only: bool,
565        #[arg(long, help = "Run stories only")]
566        stories_only: bool,
567    },
568    List {
569        #[arg(
570            short,
571            long,
572            default_value = "prodzilla.yml",
573            help = "Monitoring config file"
574        )]
575        file: String,
576    },
577}
578
579#[derive(Args, Debug)]
580#[command(arg_required_else_help = true)]
581pub struct ApiCmd {
582    #[command(subcommand)]
583    pub command: ApiSubCommand,
584}
585
586#[cfg(feature = "docker")]
587#[derive(Args, Debug)]
588pub struct DockerCmd {
589    #[arg(
590        trailing_var_arg = true,
591        allow_hyphen_values = true,
592        help = "Arguments to pass directly to the Docker CLI (default: --help)"
593    )]
594    pub args: Vec<String>,
595}
596
597#[derive(Subcommand, Debug)]
598pub enum ApiSubCommand {
599    Install {
600        #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
601        port: u16,
602    },
603}
604#[derive(Args, Debug)]
605pub struct TailCmd {
606    #[arg(long, help = "Tail Kafka topic instead of log files")]
607    pub kafka: bool,
608    #[arg(long, help = "Ship logs to Kafka")]
609    pub ship: bool,
610}
611
612#[derive(Args, Debug)]
613pub struct GenerateCmd {
614    #[command(subcommand)]
615    pub command: GenerateSubCommand,
616}
617
618#[derive(Subcommand, Debug)]
619pub enum GenerateSubCommand {
620    #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
621    Config(GenerateConfigCmd),
622    Systemd(GenerateSystemdCmd),
623}
624
625#[derive(Args, Debug)]
626pub struct GenerateConfigCmd {
627    #[arg(
628        long,
629        help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
630    )]
631    pub force: bool,
632    #[arg(
633        long,
634        help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
635    )]
636    pub update: bool,
637    #[arg(
638        long,
639        help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
640    )]
641    pub from_json: Option<PathBuf>,
642}
643
644#[cfg(feature = "secrets")]
645#[derive(Args, Debug)]
646pub struct SecretsCmd {
647    #[arg(long, help = "GitHub repository override (owner/repo)")]
648    pub repo: Option<String>,
649    #[arg(long, help = "GitHub token to use (repo scope for private repos)")]
650    pub token: Option<String>,
651    #[arg(
652        long = "environment",
653        alias = "env",
654        value_enum,
655        default_value_t = SecretsEnvironment::XbpDev,
656        help = "GitHub Actions environment to sync (default: xbp-dev)"
657    )]
658    pub environment: SecretsEnvironment,
659    #[command(subcommand)]
660    pub command: Option<SecretsSubCommand>,
661}
662
663#[cfg(feature = "secrets")]
664#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
665pub enum SecretsEnvironment {
666    #[value(name = "xbp-dev")]
667    XbpDev,
668    #[value(name = "xbp-preview")]
669    XbpPreview,
670    #[value(name = "xbp-prod")]
671    XbpProd,
672}
673
674#[cfg(feature = "secrets")]
675impl SecretsEnvironment {
676    pub fn as_str(self) -> &'static str {
677        match self {
678            Self::XbpDev => "xbp-dev",
679            Self::XbpPreview => "xbp-preview",
680            Self::XbpProd => "xbp-prod",
681        }
682    }
683}
684
685#[cfg(feature = "secrets")]
686#[derive(Subcommand, Debug)]
687pub enum SecretsSubCommand {
688    /// List local env vars from the preferred env file
689    List(ListCmd),
690    /// Push local env vars to the secrets provider (GitHub)
691    Push(PushCmd),
692    /// Pull secrets from the provider into .env.local
693    Pull(PullCmd),
694    /// Generate .env.default from source code inspection
695    GenerateDefault(GenerateDefaultCmd),
696    /// Generate .env.example with categories and defaults
697    GenerateExample(GenerateExampleCmd),
698    /// Compare local env with remote (GitHub) variables
699    Diff,
700    /// Verify that all required env vars are available locally
701    Verify,
702    /// Check connectivity, token scope, and repo access for secrets
703    #[command(name = "diag", alias = "doctor")]
704    Diag,
705    /// Show secrets command usage
706    #[command(name = "usage")]
707    Usage,
708}
709
710#[cfg(feature = "secrets")]
711#[derive(Args, Debug)]
712pub struct ListCmd {
713    #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
714    pub file: Option<String>,
715    #[arg(long, help = "Output format: plain (default) or json")]
716    pub format: Option<String>,
717}
718
719#[cfg(feature = "secrets")]
720#[derive(Args, Debug)]
721pub struct PushCmd {
722    #[arg(long, help = "Path to env file (default: .env.local/.env)")]
723    pub file: Option<String>,
724    #[arg(
725        long,
726        help = "Force overwrite existing GitHub Actions environment variables"
727    )]
728    pub force: bool,
729    #[arg(long, help = "Show what would be pushed without making changes")]
730    pub dry_run: bool,
731}
732
733#[cfg(feature = "secrets")]
734#[derive(Args, Debug)]
735pub struct PullCmd {
736    #[arg(long, help = "Output file path (default: .env.local)")]
737    pub output: Option<String>,
738}
739
740#[cfg(feature = "secrets")]
741#[derive(Args, Debug)]
742pub struct GenerateDefaultCmd {
743    #[arg(long, help = "Output file path (default: .env.default)")]
744    pub output: Option<String>,
745}
746
747#[cfg(feature = "secrets")]
748#[derive(Args, Debug)]
749pub struct GenerateExampleCmd {
750    #[arg(long, help = "Output file path (default: .env.example)")]
751    pub output: Option<String>,
752    #[arg(long, help = "Remove keys from .env.local not in .env.example")]
753    pub clean: bool,
754    #[arg(long, help = "Only include vars matching prefix (repeatable)")]
755    pub include_prefix: Vec<String>,
756    #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
757    pub exclude_prefix: Vec<String>,
758}
759
760#[derive(Args, Debug)]
761pub struct GenerateSystemdCmd {
762    #[arg(
763        long,
764        default_value = "/etc/systemd/system",
765        help = "Directory where the systemd units are written"
766    )]
767    pub output_dir: PathBuf,
768    #[arg(long, help = "Only generate the unit for this service name")]
769    pub service: Option<String>,
770    #[arg(
771        long,
772        default_value_t = true,
773        help = "Also generate the xbp-api systemd unit alongside project/services"
774    )]
775    pub api: bool,
776}
777
778#[derive(Args, Debug)]
779pub struct DoneCmd {
780    #[arg(long, help = "Root directory under which to discover git repos")]
781    pub root: Option<std::path::PathBuf>,
782    #[arg(
783        long,
784        default_value = "24 hours ago",
785        help = "Git --since value (e.g. '7 days ago')"
786    )]
787    pub since: String,
788    #[arg(short, long, help = "Output Markdown file path")]
789    pub output: Option<std::path::PathBuf>,
790    #[arg(long, help = "Skip AI summarization (OpenRouter)")]
791    pub no_ai: bool,
792    #[arg(short, long, help = "Discover repos recursively")]
793    pub recursive: bool,
794    #[arg(long, help = "Exclude repo by name (repeatable)")]
795    pub exclude: Vec<String>,
796}
797
798#[cfg(feature = "nordvpn")]
799#[derive(Args, Debug)]
800pub struct NordvpnCmd {
801    #[arg(
802        trailing_var_arg = true,
803        allow_hyphen_values = true,
804        help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
805    )]
806    pub args: Vec<String>,
807}
808
809#[cfg(feature = "kubernetes")]
810#[derive(Args, Debug)]
811pub struct KubernetesCmd {
812    #[command(subcommand)]
813    pub command: KubernetesSubCommand,
814}
815
816#[cfg(feature = "kubernetes")]
817#[derive(Args, Debug)]
818pub struct KubernetesAddonCmd {
819    #[command(subcommand)]
820    pub command: KubernetesAddonSubCommand,
821}
822
823#[cfg(feature = "kubernetes")]
824#[derive(Subcommand, Debug)]
825pub enum KubernetesAddonSubCommand {
826    /// Show complete addon status (enabled/disabled) from `microk8s status`
827    List,
828    /// Enable a MicroK8s addon
829    Enable {
830        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
831        name: String,
832    },
833    /// Disable a MicroK8s addon
834    Disable {
835        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
836        name: String,
837    },
838}
839
840#[cfg(feature = "kubernetes")]
841#[derive(Subcommand, Debug)]
842pub enum KubernetesSubCommand {
843    /// Validate kubectl, current context, and node readiness
844    Check {
845        #[arg(long, help = "Kubeconfig context to target")]
846        context: Option<String>,
847        #[arg(
848            long,
849            default_value = "default",
850            help = "Namespace to probe for workload readiness"
851        )]
852        namespace: String,
853        #[arg(long, help = "Skip live cluster calls (tooling check only)")]
854        offline: bool,
855    },
856    /// Generate Deployment/Service/NetworkPolicy YAML
857    Generate {
858        #[arg(long, help = "Logical app name (used for resource names)")]
859        name: String,
860        #[arg(long, help = "Container image reference")]
861        image: String,
862        #[arg(long, default_value_t = 80, help = "Container port for the service")]
863        port: u16,
864        #[arg(long, default_value_t = 1, help = "Replica count")]
865        replicas: u16,
866        #[arg(
867            long,
868            default_value = "default",
869            help = "Namespace for generated resources"
870        )]
871        namespace: String,
872        #[arg(
873            long,
874            default_value = "k8s/xbp-manifest.yaml",
875            help = "Path to write the manifest bundle"
876        )]
877        output: String,
878        #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
879        host: Option<String>,
880    },
881    /// Apply a manifest bundle with kubectl apply -f
882    Apply {
883        #[arg(long, help = "Path to manifest file")]
884        file: String,
885        #[arg(long, help = "Override kube context")]
886        context: Option<String>,
887        #[arg(long, help = "Override namespace")]
888        namespace: Option<String>,
889        #[arg(long, help = "Use --dry-run=server")]
890        dry_run: bool,
891    },
892    /// Summarize deployments/services/pods in a namespace
893    Status {
894        #[arg(long, default_value = "default", help = "Namespace to summarize")]
895        namespace: String,
896        #[arg(long, help = "Override kube context")]
897        context: Option<String>,
898    },
899    /// Manage MicroK8s addons (list, enable, disable)
900    Addons(KubernetesAddonCmd),
901    /// Extract Kubernetes Dashboard login token from secret describe output
902    DashboardToken {
903        #[arg(
904            long,
905            default_value = "kube-system",
906            help = "Namespace containing the dashboard token secret"
907        )]
908        namespace: String,
909        #[arg(
910            long,
911            default_value = "microk8s-dashboard-token",
912            help = "Secret name containing the dashboard login token"
913        )]
914        secret: String,
915        #[arg(long, help = "Override kube context")]
916        context: Option<String>,
917    },
918    /// Print decoded Grafana admin credentials from observability secret
919    ObservabilityCreds {
920        #[arg(
921            long,
922            default_value = "observability",
923            help = "Namespace containing Grafana secret"
924        )]
925        namespace: String,
926        #[arg(
927            long,
928            default_value = "kube-prom-stack-grafana",
929            help = "Grafana secret name"
930        )]
931        secret: String,
932        #[arg(long, help = "Override kube context")]
933        context: Option<String>,
934    },
935    /// Create or update a cert-manager Issuer for Let's Encrypt
936    Issuer {
937        #[arg(
938            long,
939            help = "Email used for Let's Encrypt account registration (required)"
940        )]
941        email: String,
942        #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
943        name: String,
944        #[arg(
945            long,
946            default_value = "default",
947            help = "Namespace for the Issuer resource"
948        )]
949        namespace: String,
950        #[arg(
951            long,
952            default_value = "https://acme-v02.api.letsencrypt.org/directory",
953            help = "ACME server URL (production by default)"
954        )]
955        server: String,
956        #[arg(
957            long,
958            default_value = "letsencrypt-account-key",
959            help = "Secret used to store the ACME account private key"
960        )]
961        private_key_secret: String,
962        #[arg(
963            long,
964            default_value = "nginx",
965            help = "Ingress class name used for HTTP01 solving"
966        )]
967        ingress_class_name: String,
968        #[arg(long, help = "Override kube context")]
969        context: Option<String>,
970        #[arg(long, help = "Use --dry-run=server")]
971        dry_run: bool,
972    },
973}
974
975#[cfg(test)]
976mod tests {
977    use super::{
978        Cli, Commands, GenerateSubCommand, LinearConfigAction, NetworkFloatingIpSubCommand,
979        NetworkSubCommand,
980    };
981    #[cfg(feature = "secrets")]
982    use super::{SecretsEnvironment, SecretsSubCommand};
983    use clap::Parser;
984
985    #[test]
986    fn parses_network_floating_ip_add() {
987        let cli = Cli::parse_from([
988            "xbp",
989            "network",
990            "floating-ip",
991            "add",
992            "--ip",
993            "1.2.3.4",
994            "--apply",
995        ]);
996
997        match cli.command {
998            Some(Commands::Network(network)) => match network.command {
999                NetworkSubCommand::FloatingIp(fip) => match fip.command {
1000                    NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
1001                        assert_eq!(ip, "1.2.3.4");
1002                        assert!(apply);
1003                    }
1004                    _ => panic!("expected add subcommand"),
1005                },
1006                _ => panic!("expected floating-ip subcommand"),
1007            },
1008            _ => panic!("expected network command"),
1009        }
1010    }
1011
1012    #[test]
1013    fn parses_generate_config_update() {
1014        let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
1015
1016        match cli.command {
1017            Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
1018                GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
1019                _ => panic!("expected generate config command"),
1020            },
1021            _ => panic!("expected generate command"),
1022        }
1023    }
1024
1025    #[test]
1026    fn parses_linear_select_initiative_config_command() {
1027        let cli = Cli::parse_from(["xbp", "config", "linear", "select-initiative"]);
1028
1029        match cli.command {
1030            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
1031                Some(super::ConfigProviderCmd::Linear(linear_cmd)) => {
1032                    assert!(matches!(
1033                        linear_cmd.action,
1034                        LinearConfigAction::SelectInitiative
1035                    ));
1036                }
1037                _ => panic!("expected linear config provider"),
1038            },
1039            _ => panic!("expected config command"),
1040        }
1041    }
1042
1043    #[cfg(feature = "secrets")]
1044    #[test]
1045    fn parses_secrets_diag_command() {
1046        let cli = Cli::parse_from(["xbp", "secrets", "diag"]);
1047
1048        match cli.command {
1049            Some(Commands::Secrets(secrets_cmd)) => {
1050                assert!(matches!(secrets_cmd.command, Some(SecretsSubCommand::Diag)));
1051                assert!(matches!(
1052                    secrets_cmd.environment,
1053                    SecretsEnvironment::XbpDev
1054                ));
1055            }
1056            _ => panic!("expected secrets command"),
1057        }
1058    }
1059
1060    #[cfg(feature = "secrets")]
1061    #[test]
1062    fn parses_secrets_environment_override() {
1063        let cli = Cli::parse_from(["xbp", "secrets", "--environment", "xbp-prod", "push"]);
1064
1065        match cli.command {
1066            Some(Commands::Secrets(secrets_cmd)) => {
1067                assert!(matches!(
1068                    secrets_cmd.environment,
1069                    SecretsEnvironment::XbpProd
1070                ));
1071                assert!(matches!(
1072                    secrets_cmd.command,
1073                    Some(SecretsSubCommand::Push(_))
1074                ));
1075            }
1076            _ => panic!("expected secrets command"),
1077        }
1078    }
1079}