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(ListCmd),
624 Push(PushCmd),
626 Pull(PullCmd),
628 GenerateDefault(GenerateDefaultCmd),
630 GenerateExample(GenerateExampleCmd),
632 Diff,
634 Verify,
636 Doctor,
638 #[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 List,
758 Enable {
760 #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
761 name: String,
762 },
763 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 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 {
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 {
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 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 Addons(KubernetesAddonCmd),
831 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 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 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}