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