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