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