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