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 repository secrets (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}
221
222#[derive(Args, Debug)]
223pub struct ConfigSecretCmd {
224    #[command(subcommand)]
225    pub action: ConfigSecretAction,
226}
227
228#[derive(Subcommand, Debug)]
229pub enum ConfigSecretAction {
230    #[command(about = "Set provider key (omit value to enter it securely)")]
231    SetKey {
232        #[arg(help = "Provider key/token value")]
233        key: Option<String>,
234    },
235    #[command(about = "Delete the stored provider key")]
236    DeleteKey,
237    #[command(about = "Show whether a key is configured (masked by default)")]
238    Show {
239        #[arg(long, help = "Print full key/token value (not masked)")]
240        raw: bool,
241    },
242}
243
244#[derive(Args, Debug)]
245pub struct CurlCmd {
246    #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
247    pub url: Option<String>,
248    #[arg(long, help = "Disable the default 15 second timeout")]
249    pub no_timeout: bool,
250}
251
252#[derive(Args, Debug)]
253#[command(subcommand_precedence_over_arg = true)]
254pub struct VersionCmd {
255    #[arg(
256        help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
257    )]
258    pub target: Option<String>,
259    #[arg(long, help = "Show normalized git tags from `git tag --list`")]
260    pub git: bool,
261    #[command(subcommand)]
262    pub command: Option<VersionSubCommand>,
263}
264
265#[derive(Subcommand, Debug)]
266pub enum VersionSubCommand {
267    #[command(about = "Create and push a git tag for this version, then create a GitHub release")]
268    Release(VersionReleaseCmd),
269}
270
271#[derive(Args, Debug)]
272pub struct VersionReleaseCmd {
273    #[arg(
274        long,
275        help = "Release this version instead of auto-detecting from tracked files"
276    )]
277    pub version: Option<String>,
278    #[arg(
279        long,
280        help = "Allow releasing with uncommitted changes in the working tree"
281    )]
282    pub allow_dirty: bool,
283    #[arg(long, help = "Release title (defaults to v<version>)")]
284    pub title: Option<String>,
285    #[arg(long, help = "Release notes body (Markdown)")]
286    pub notes: Option<String>,
287    #[arg(long, help = "Read release notes body from a file")]
288    pub notes_file: Option<PathBuf>,
289    #[arg(long, help = "Create as draft release")]
290    pub draft: bool,
291    #[arg(long, help = "Mark release as pre-release")]
292    pub prerelease: bool,
293    #[arg(
294        long,
295        value_enum,
296        default_value_t = VersionReleaseLatest::Legacy,
297        help = "Control GitHub latest flag: true, false, or legacy"
298    )]
299    pub make_latest: VersionReleaseLatest,
300}
301
302#[derive(Copy, Clone, Debug, ValueEnum)]
303pub enum VersionReleaseLatest {
304    True,
305    False,
306    Legacy,
307}
308
309#[derive(Args, Debug)]
310pub struct RedeployV2Cmd {
311    #[arg(short = 'p', long = "password")]
312    pub password: Option<String>,
313    #[arg(short = 'u', long = "username")]
314    pub username: Option<String>,
315    #[arg(short = 'h', long = "host")]
316    pub host: Option<String>,
317    #[arg(short = 'd', long = "project-dir")]
318    pub project_dir: Option<String>,
319}
320
321#[derive(Args, Debug)]
322pub struct LogsCmd {
323    #[arg()]
324    pub project: Option<String>,
325    #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
326    pub ssh_host: Option<String>,
327    #[arg(long = "ssh-username", help = "SSH username for remote host")]
328    pub ssh_username: Option<String>,
329    #[arg(long = "ssh-password", help = "SSH password for remote host")]
330    pub ssh_password: Option<String>,
331}
332
333#[derive(Args, Debug)]
334pub struct NginxCmd {
335    #[command(subcommand)]
336    pub command: NginxSubCommand,
337}
338
339#[derive(Args, Debug)]
340pub struct NetworkCmd {
341    #[command(subcommand)]
342    pub command: NetworkSubCommand,
343}
344
345#[derive(Subcommand, Debug)]
346pub enum NetworkSubCommand {
347    #[command(about = "Manage persistent floating IP configuration")]
348    FloatingIp(NetworkFloatingIpCmd),
349    #[command(about = "Inspect discovered network configuration sources")]
350    Config(NetworkConfigCmd),
351}
352
353#[derive(Args, Debug)]
354pub struct NetworkFloatingIpCmd {
355    #[command(subcommand)]
356    pub command: NetworkFloatingIpSubCommand,
357}
358
359#[derive(Subcommand, Debug)]
360pub enum NetworkFloatingIpSubCommand {
361    #[command(about = "Add a persistent floating IP entry to detected network backend")]
362    Add {
363        #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
364        ip: String,
365        #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
366        cidr: Option<u8>,
367        #[arg(long, help = "Network interface override (auto-detected when omitted)")]
368        interface: Option<String>,
369        #[arg(long, help = "Optional label for backend metadata/file naming")]
370        label: Option<String>,
371        #[arg(long, help = "Apply network changes after writing config")]
372        apply: bool,
373        #[arg(long, help = "Preview computed changes without writing files")]
374        dry_run: bool,
375    },
376    #[command(about = "List floating IPs from runtime and persisted network config")]
377    List {
378        #[arg(long, help = "Emit JSON output")]
379        json: bool,
380    },
381}
382
383#[derive(Args, Debug)]
384pub struct NetworkConfigCmd {
385    #[command(subcommand)]
386    pub command: NetworkConfigSubCommand,
387}
388
389#[derive(Subcommand, Debug)]
390pub enum NetworkConfigSubCommand {
391    #[command(about = "List detected backend and configuration source files")]
392    List {
393        #[arg(long, help = "Emit JSON output")]
394        json: bool,
395    },
396}
397
398#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
399pub enum NginxDnsMode {
400    Manual,
401    Plugin,
402}
403
404#[derive(Subcommand, Debug)]
405pub enum NginxSubCommand {
406    #[command(
407        about = "Provision an HTTPS NGINX reverse proxy with Certbot",
408        long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
409and write final HTTP->HTTPS redirect + TLS proxy config.\n\
410\n\
411Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
412Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
413with --dns-plugin and --dns-creds for non-interactive provider automation."
414    )]
415    Setup {
416        #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
417        domain: String,
418        #[arg(short, long, help = "Port to proxy to")]
419        port: u16,
420        #[arg(
421            short,
422            long,
423            help = "Email used for Let's Encrypt account registration"
424        )]
425        email: String,
426        #[arg(
427            long,
428            value_enum,
429            default_value_t = NginxDnsMode::Manual,
430            help = "DNS challenge mode for wildcard certificates: manual or plugin"
431        )]
432        dns_mode: NginxDnsMode,
433        #[arg(
434            long,
435            help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
436        )]
437        dns_plugin: Option<String>,
438        #[arg(
439            long,
440            help = "Path to DNS plugin credentials file for --dns-mode plugin"
441        )]
442        dns_creds: Option<PathBuf>,
443        #[arg(
444            long,
445            default_value_t = true,
446            action = clap::ArgAction::Set,
447            value_parser = clap::builder::BoolishValueParser::new(),
448            help = "For wildcard domains, also request the base domain certificate (true|false)"
449        )]
450        include_base: bool,
451    },
452    #[command(about = "List discovered NGINX sites with listen/upstream ports")]
453    List,
454    #[command(about = "Show full NGINX config for one domain or all domains")]
455    Show {
456        #[arg(help = "Optional domain name to inspect")]
457        domain: Option<String>,
458    },
459    #[command(about = "Open an NGINX site config in your configured editor")]
460    Edit {
461        #[arg(help = "Domain name to edit")]
462        domain: String,
463    },
464    #[command(about = "Update upstream port for an existing NGINX site")]
465    Update {
466        #[arg(short, long, help = "Domain name to update")]
467        domain: String,
468        #[arg(short, long, help = "New port to proxy to")]
469        port: u16,
470    },
471}
472
473#[derive(Args, Debug)]
474pub struct DiagCmd {
475    #[arg(long, help = "Check Nginx configuration")]
476    pub nginx: bool,
477    #[arg(long, help = "Check specific ports (comma-separated)")]
478    pub ports: Option<String>,
479    #[arg(long, help = "Skip internet speed test")]
480    pub no_speed_test: bool,
481    #[arg(
482        long,
483        help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
484    )]
485    pub compose_file: Option<String>,
486}
487
488#[derive(Args, Debug)]
489pub struct MonitorCmd {
490    #[command(subcommand)]
491    pub command: Option<MonitorSubCommand>,
492}
493
494#[derive(Subcommand, Debug)]
495pub enum MonitorSubCommand {
496    Check,
497    Start,
498}
499
500#[cfg(feature = "monitoring")]
501#[derive(Args, Debug)]
502pub struct MonitoringCmd {
503    #[command(subcommand)]
504    pub command: MonitoringSubCommand,
505}
506
507#[cfg(feature = "monitoring")]
508#[derive(Subcommand, Debug)]
509pub enum MonitoringSubCommand {
510    Serve {
511        #[arg(
512            short,
513            long,
514            default_value = "prodzilla.yml",
515            help = "Monitoring config file"
516        )]
517        file: String,
518    },
519    RunOnce {
520        #[arg(
521            short,
522            long,
523            default_value = "prodzilla.yml",
524            help = "Monitoring config file"
525        )]
526        file: String,
527        #[arg(long, help = "Run probes only")]
528        probes_only: bool,
529        #[arg(long, help = "Run stories only")]
530        stories_only: bool,
531    },
532    List {
533        #[arg(
534            short,
535            long,
536            default_value = "prodzilla.yml",
537            help = "Monitoring config file"
538        )]
539        file: String,
540    },
541}
542
543#[derive(Args, Debug)]
544#[command(arg_required_else_help = true)]
545pub struct ApiCmd {
546    #[command(subcommand)]
547    pub command: ApiSubCommand,
548}
549
550#[cfg(feature = "docker")]
551#[derive(Args, Debug)]
552pub struct DockerCmd {
553    #[arg(
554        trailing_var_arg = true,
555        allow_hyphen_values = true,
556        help = "Arguments to pass directly to the Docker CLI (default: --help)"
557    )]
558    pub args: Vec<String>,
559}
560
561#[derive(Subcommand, Debug)]
562pub enum ApiSubCommand {
563    Install {
564        #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
565        port: u16,
566    },
567}
568#[derive(Args, Debug)]
569pub struct TailCmd {
570    #[arg(long, help = "Tail Kafka topic instead of log files")]
571    pub kafka: bool,
572    #[arg(long, help = "Ship logs to Kafka")]
573    pub ship: bool,
574}
575
576#[derive(Args, Debug)]
577pub struct GenerateCmd {
578    #[command(subcommand)]
579    pub command: GenerateSubCommand,
580}
581
582#[derive(Subcommand, Debug)]
583pub enum GenerateSubCommand {
584    #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
585    Config(GenerateConfigCmd),
586    Systemd(GenerateSystemdCmd),
587}
588
589#[derive(Args, Debug)]
590pub struct GenerateConfigCmd {
591    #[arg(
592        long,
593        help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
594    )]
595    pub force: bool,
596    #[arg(
597        long,
598        help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
599    )]
600    pub update: bool,
601    #[arg(
602        long,
603        help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
604    )]
605    pub from_json: Option<PathBuf>,
606}
607
608#[cfg(feature = "secrets")]
609#[derive(Args, Debug)]
610pub struct SecretsCmd {
611    #[arg(long, help = "GitHub repository override (owner/repo)")]
612    pub repo: Option<String>,
613    #[arg(long, help = "GitHub token to use (repo scope for private repos)")]
614    pub token: Option<String>,
615    #[command(subcommand)]
616    pub command: Option<SecretsSubCommand>,
617}
618
619#[cfg(feature = "secrets")]
620#[derive(Subcommand, Debug)]
621pub enum SecretsSubCommand {
622    /// List local env vars from the preferred env file
623    List(ListCmd),
624    /// Push local env vars to the secrets provider (GitHub)
625    Push(PushCmd),
626    /// Pull secrets from the provider into .env.local
627    Pull(PullCmd),
628    /// Generate .env.default from source code inspection
629    GenerateDefault(GenerateDefaultCmd),
630    /// Generate .env.example with categories and defaults
631    GenerateExample(GenerateExampleCmd),
632    /// Compare local env with remote (GitHub) variables
633    Diff,
634    /// Verify that all required env vars are available locally
635    Verify,
636    /// Check connectivity, token scope, and repo access for secrets
637    Doctor,
638    /// Show secrets command usage
639    #[command(name = "usage")]
640    Usage,
641}
642
643#[cfg(feature = "secrets")]
644#[derive(Args, Debug)]
645pub struct ListCmd {
646    #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
647    pub file: Option<String>,
648    #[arg(long, help = "Output format: plain (default) or json")]
649    pub format: Option<String>,
650}
651
652#[cfg(feature = "secrets")]
653#[derive(Args, Debug)]
654pub struct PushCmd {
655    #[arg(long, help = "Path to env file (default: .env.local/.env)")]
656    pub file: Option<String>,
657    #[arg(long, help = "Force overwrite existing repository variables")]
658    pub force: bool,
659    #[arg(long, help = "Show what would be pushed without making changes")]
660    pub dry_run: bool,
661}
662
663#[cfg(feature = "secrets")]
664#[derive(Args, Debug)]
665pub struct PullCmd {
666    #[arg(long, help = "Output file path (default: .env.local)")]
667    pub output: Option<String>,
668}
669
670#[cfg(feature = "secrets")]
671#[derive(Args, Debug)]
672pub struct GenerateDefaultCmd {
673    #[arg(long, help = "Output file path (default: .env.default)")]
674    pub output: Option<String>,
675}
676
677#[cfg(feature = "secrets")]
678#[derive(Args, Debug)]
679pub struct GenerateExampleCmd {
680    #[arg(long, help = "Output file path (default: .env.example)")]
681    pub output: Option<String>,
682    #[arg(long, help = "Remove keys from .env.local not in .env.example")]
683    pub clean: bool,
684    #[arg(long, help = "Only include vars matching prefix (repeatable)")]
685    pub include_prefix: Vec<String>,
686    #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
687    pub exclude_prefix: Vec<String>,
688}
689
690#[derive(Args, Debug)]
691pub struct GenerateSystemdCmd {
692    #[arg(
693        long,
694        default_value = "/etc/systemd/system",
695        help = "Directory where the systemd units are written"
696    )]
697    pub output_dir: PathBuf,
698    #[arg(long, help = "Only generate the unit for this service name")]
699    pub service: Option<String>,
700    #[arg(
701        long,
702        default_value_t = true,
703        help = "Also generate the xbp-api systemd unit alongside project/services"
704    )]
705    pub api: bool,
706}
707
708#[derive(Args, Debug)]
709pub struct DoneCmd {
710    #[arg(long, help = "Root directory under which to discover git repos")]
711    pub root: Option<std::path::PathBuf>,
712    #[arg(
713        long,
714        default_value = "24 hours ago",
715        help = "Git --since value (e.g. '7 days ago')"
716    )]
717    pub since: String,
718    #[arg(short, long, help = "Output Markdown file path")]
719    pub output: Option<std::path::PathBuf>,
720    #[arg(long, help = "Skip AI summarization (OpenRouter)")]
721    pub no_ai: bool,
722    #[arg(short, long, help = "Discover repos recursively")]
723    pub recursive: bool,
724    #[arg(long, help = "Exclude repo by name (repeatable)")]
725    pub exclude: Vec<String>,
726}
727
728#[cfg(feature = "nordvpn")]
729#[derive(Args, Debug)]
730pub struct NordvpnCmd {
731    #[arg(
732        trailing_var_arg = true,
733        allow_hyphen_values = true,
734        help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
735    )]
736    pub args: Vec<String>,
737}
738
739#[cfg(feature = "kubernetes")]
740#[derive(Args, Debug)]
741pub struct KubernetesCmd {
742    #[command(subcommand)]
743    pub command: KubernetesSubCommand,
744}
745
746#[cfg(feature = "kubernetes")]
747#[derive(Args, Debug)]
748pub struct KubernetesAddonCmd {
749    #[command(subcommand)]
750    pub command: KubernetesAddonSubCommand,
751}
752
753#[cfg(feature = "kubernetes")]
754#[derive(Subcommand, Debug)]
755pub enum KubernetesAddonSubCommand {
756    /// Show complete addon status (enabled/disabled) from `microk8s status`
757    List,
758    /// Enable a MicroK8s addon
759    Enable {
760        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
761        name: String,
762    },
763    /// Disable a MicroK8s addon
764    Disable {
765        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
766        name: String,
767    },
768}
769
770#[cfg(feature = "kubernetes")]
771#[derive(Subcommand, Debug)]
772pub enum KubernetesSubCommand {
773    /// Validate kubectl, current context, and node readiness
774    Check {
775        #[arg(long, help = "Kubeconfig context to target")]
776        context: Option<String>,
777        #[arg(
778            long,
779            default_value = "default",
780            help = "Namespace to probe for workload readiness"
781        )]
782        namespace: String,
783        #[arg(long, help = "Skip live cluster calls (tooling check only)")]
784        offline: bool,
785    },
786    /// Generate Deployment/Service/NetworkPolicy YAML
787    Generate {
788        #[arg(long, help = "Logical app name (used for resource names)")]
789        name: String,
790        #[arg(long, help = "Container image reference")]
791        image: String,
792        #[arg(long, default_value_t = 80, help = "Container port for the service")]
793        port: u16,
794        #[arg(long, default_value_t = 1, help = "Replica count")]
795        replicas: u16,
796        #[arg(
797            long,
798            default_value = "default",
799            help = "Namespace for generated resources"
800        )]
801        namespace: String,
802        #[arg(
803            long,
804            default_value = "k8s/xbp-manifest.yaml",
805            help = "Path to write the manifest bundle"
806        )]
807        output: String,
808        #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
809        host: Option<String>,
810    },
811    /// Apply a manifest bundle with kubectl apply -f
812    Apply {
813        #[arg(long, help = "Path to manifest file")]
814        file: String,
815        #[arg(long, help = "Override kube context")]
816        context: Option<String>,
817        #[arg(long, help = "Override namespace")]
818        namespace: Option<String>,
819        #[arg(long, help = "Use --dry-run=server")]
820        dry_run: bool,
821    },
822    /// Summarize deployments/services/pods in a namespace
823    Status {
824        #[arg(long, default_value = "default", help = "Namespace to summarize")]
825        namespace: String,
826        #[arg(long, help = "Override kube context")]
827        context: Option<String>,
828    },
829    /// Manage MicroK8s addons (list, enable, disable)
830    Addons(KubernetesAddonCmd),
831    /// Extract Kubernetes Dashboard login token from secret describe output
832    DashboardToken {
833        #[arg(
834            long,
835            default_value = "kube-system",
836            help = "Namespace containing the dashboard token secret"
837        )]
838        namespace: String,
839        #[arg(
840            long,
841            default_value = "microk8s-dashboard-token",
842            help = "Secret name containing the dashboard login token"
843        )]
844        secret: String,
845        #[arg(long, help = "Override kube context")]
846        context: Option<String>,
847    },
848    /// Print decoded Grafana admin credentials from observability secret
849    ObservabilityCreds {
850        #[arg(
851            long,
852            default_value = "observability",
853            help = "Namespace containing Grafana secret"
854        )]
855        namespace: String,
856        #[arg(
857            long,
858            default_value = "kube-prom-stack-grafana",
859            help = "Grafana secret name"
860        )]
861        secret: String,
862        #[arg(long, help = "Override kube context")]
863        context: Option<String>,
864    },
865    /// Create or update a cert-manager Issuer for Let's Encrypt
866    Issuer {
867        #[arg(
868            long,
869            help = "Email used for Let's Encrypt account registration (required)"
870        )]
871        email: String,
872        #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
873        name: String,
874        #[arg(
875            long,
876            default_value = "default",
877            help = "Namespace for the Issuer resource"
878        )]
879        namespace: String,
880        #[arg(
881            long,
882            default_value = "https://acme-v02.api.letsencrypt.org/directory",
883            help = "ACME server URL (production by default)"
884        )]
885        server: String,
886        #[arg(
887            long,
888            default_value = "letsencrypt-account-key",
889            help = "Secret used to store the ACME account private key"
890        )]
891        private_key_secret: String,
892        #[arg(
893            long,
894            default_value = "nginx",
895            help = "Ingress class name used for HTTP01 solving"
896        )]
897        ingress_class_name: String,
898        #[arg(long, help = "Override kube context")]
899        context: Option<String>,
900        #[arg(long, help = "Use --dry-run=server")]
901        dry_run: bool,
902    },
903}
904
905#[cfg(test)]
906mod tests {
907    use super::{
908        Cli, Commands, GenerateSubCommand, NetworkFloatingIpSubCommand, NetworkSubCommand,
909    };
910    use clap::Parser;
911
912    #[test]
913    fn parses_network_floating_ip_add() {
914        let cli = Cli::parse_from([
915            "xbp",
916            "network",
917            "floating-ip",
918            "add",
919            "--ip",
920            "1.2.3.4",
921            "--apply",
922        ]);
923
924        match cli.command {
925            Some(Commands::Network(network)) => match network.command {
926                NetworkSubCommand::FloatingIp(fip) => match fip.command {
927                    NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
928                        assert_eq!(ip, "1.2.3.4");
929                        assert!(apply);
930                    }
931                    _ => panic!("expected add subcommand"),
932                },
933                _ => panic!("expected floating-ip subcommand"),
934            },
935            _ => panic!("expected network command"),
936        }
937    }
938
939    #[test]
940    fn parses_generate_config_update() {
941        let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
942
943        match cli.command {
944            Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
945                GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
946                _ => panic!("expected generate config command"),
947            },
948            _ => panic!("expected generate command"),
949        }
950    }
951}