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