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 = "Analyze the current git worktree and create a conventional commit")]
37 Commit(CommitCmd),
38 #[command(about = "Initialize an XBP project in the current directory")]
39 Init,
40 #[command(about = "Install common dependencies for host setup")]
41 Setup,
42 #[command(about = "Redeploy one service or the entire project")]
43 Redeploy {
44 #[arg(
45 help = "Service name to redeploy (optional, uses legacy redeploy.sh if not provided)"
46 )]
47 service_name: Option<String>,
48 },
49 #[command(about = "Run the legacy remote redeploy workflow over SSH")]
50 RedeployV2(RedeployV2Cmd),
51 #[command(about = "Inspect project/global config and manage provider keys")]
52 Config(ConfigCmd),
53 #[command(
54 about = "Install supported host packages or project tooling",
55 after_help = crate::commands::INSTALL_COMMAND_AFTER_HELP
56 )]
57 Install {
58 #[arg(short = 'l', long = "list", help = "List installable targets and exit")]
59 list: bool,
60 #[arg(help = "Install target (leave empty to show installable options)")]
61 package: Option<String>,
62 },
63 #[command(about = "Tail local or remote logs")]
64 Logs(LogsCmd),
65 #[command(about = "Open an interactive remote shell over SSH", visible_alias = "shell")]
66 Ssh(SshCmd),
67 #[command(about = "Open or manage cloudflared TCP forwarders")]
68 Cloudflared(CloudflaredCmd),
69 #[command(about = "List PM2 processes")]
70 List,
71 #[command(about = "Fetch an HTTP endpoint with sane defaults")]
72 Curl(CurlCmd),
73 #[command(about = "List configured services from project config")]
74 Services,
75 #[command(about = "Run service-level commands (build/install/start/dev)")]
76 Service {
77 #[arg(help = "Command to run: build, install, start, dev, or --help")]
78 command: Option<String>,
79 #[arg(help = "Service name")]
80 service_name: Option<String>,
81 },
82 #[command(about = "Manage NGINX site configs and upstream mappings")]
83 Nginx(NginxCmd),
84 #[command(about = "Manage host network configuration and floating IPs")]
85 Network(NetworkCmd),
86 #[command(about = "Run full system diagnostics and readiness checks")]
87 Diag(DiagCmd),
88 #[command(about = "Run health-check monitoring commands")]
89 Monitor(MonitorCmd),
90 #[command(about = "Capture a PM2 snapshot for later restore")]
91 Snapshot,
92 #[command(about = "Restore PM2 state from dump or latest snapshot")]
93 Resurrect,
94 #[command(about = "Stop a PM2 process by name or stop all")]
95 Stop {
96 #[arg(help = "PM2 process name or 'all' (default: all)")]
97 target: Option<String>,
98 },
99 #[command(about = "Flush PM2 logs globally or for a specific process")]
100 Flush {
101 #[arg(help = "Optional PM2 process name")]
102 target: Option<String>,
103 },
104 #[command(about = "Run login flow against configured XBP API")]
105 Login,
106 #[command(about = "Inspect, reconcile, or bump project versions")]
107 Version(VersionCmd),
108 #[command(about = "Show PM2 environment by name or numeric id")]
109 Env {
110 #[arg(help = "PM2 process name or id")]
111 target: String,
112 },
113 #[command(about = "Tail app logs or Kafka logs")]
114 Tail(TailCmd),
115 #[command(about = "Start a binary/process under PM2")]
116 Start {
117 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
118 args: Vec<String>,
119 },
120 #[command(about = "Generate helper artifacts such as systemd units")]
121 Generate(GenerateCmd),
122 #[cfg(feature = "secrets")]
123 #[command(about = "Manage env vars and GitHub Actions environment variables (feature-gated)")]
124 Secrets(SecretsCmd),
125 #[command(
126 about = "Generate 'what did I get done' Markdown report from git commits across repos"
127 )]
128 Done(DoneCmd),
129 #[cfg(feature = "kubernetes")]
130 #[command(about = "Experimental Kubernetes cluster manager (feature-gated)")]
131 Kubernetes(KubernetesCmd),
132 #[cfg(feature = "nordvpn")]
133 #[command(about = "NordVPN meshnet setup and passthrough (feature-gated)")]
134 Nordvpn(NordvpnCmd),
135 #[cfg(feature = "monitoring")]
136 Monitoring(MonitoringCmd),
137 #[command(about = "Manage the XBP API server")]
138 Api(ApiCmd),
139 #[cfg(feature = "docker")]
140 #[command(about = "Pass-through wrapper around the Docker CLI")]
141 Docker(DockerCmd),
142}
143
144pub fn command_label(command: &Commands) -> &'static str {
145 match command {
146 Commands::Ports(_) => "ports",
147 Commands::Commit(_) => "commit",
148 Commands::Init => "init",
149 Commands::Setup => "setup",
150 Commands::Redeploy { .. } => "redeploy",
151 Commands::RedeployV2(_) => "redeploy-v2",
152 Commands::Config(_) => "config",
153 Commands::Install { .. } => "install",
154 Commands::Logs(_) => "logs",
155 Commands::Ssh(_) => "ssh",
156 Commands::Cloudflared(_) => "cloudflared",
157 Commands::List => "list",
158 Commands::Curl(_) => "curl",
159 Commands::Services => "services",
160 Commands::Service { .. } => "service",
161 Commands::Nginx(_) => "nginx",
162 Commands::Network(_) => "network",
163 Commands::Diag(_) => "diag",
164 Commands::Monitor(_) => "monitor",
165 Commands::Snapshot => "snapshot",
166 Commands::Resurrect => "resurrect",
167 Commands::Stop { .. } => "stop",
168 Commands::Flush { .. } => "flush",
169 Commands::Login => "login",
170 Commands::Version(_) => "version",
171 Commands::Env { .. } => "env",
172 Commands::Tail(_) => "tail",
173 Commands::Start { .. } => "start",
174 Commands::Generate(_) => "generate",
175 #[cfg(feature = "secrets")]
176 Commands::Secrets(_) => "secrets",
177 Commands::Done(_) => "done",
178 #[cfg(feature = "kubernetes")]
179 Commands::Kubernetes(_) => "kubernetes",
180 #[cfg(feature = "nordvpn")]
181 Commands::Nordvpn(_) => "nordvpn",
182 #[cfg(feature = "monitoring")]
183 Commands::Monitoring(_) => "monitoring",
184 Commands::Api(_) => "api",
185 #[cfg(feature = "docker")]
186 Commands::Docker(_) => "docker",
187 }
188}
189
190#[derive(Args, Debug)]
191pub struct CommitCmd {
192 #[arg(
193 long,
194 help = "Generate and print the conventional commit message without creating a git commit"
195 )]
196 pub dry_run: bool,
197 #[arg(long, help = "Skip OpenRouter and use local heuristics only")]
198 pub no_ai: bool,
199 #[arg(
200 long,
201 default_value = "openai/gpt-4o-mini",
202 help = "OpenRouter model override used for commit generation"
203 )]
204 pub model: String,
205 #[arg(
206 long,
207 help = "Force the conventional commit scope (for example: cli, api, docs)"
208 )]
209 pub scope: Option<String>,
210}
211
212#[derive(Args, Debug)]
213pub struct PortsCmd {
214 #[arg(short = 'p', long = "port")]
215 pub port: Option<u16>,
216 #[arg(long = "kill")]
217 pub kill: bool,
218 #[arg(short = 'n', long = "nginx")]
219 pub nginx: bool,
220 #[arg(
221 long = "full",
222 help = "Show one unified ports view (reconciled listeners + exposure + security flags)"
223 )]
224 pub full: bool,
225 #[arg(
226 long = "no-local",
227 help = "Exclude connections where LocalAddr equals RemoteAddr"
228 )]
229 pub no_local: bool,
230 #[arg(
231 long = "exposure",
232 help = "Diagnose external exposure per port (binding + firewall layer)"
233 )]
234 pub exposure: bool,
235}
236
237#[derive(Args, Debug)]
238pub struct ConfigCmd {
239 #[arg(
240 long,
241 help = "Show the current project config instead of opening global XBP paths"
242 )]
243 pub project: bool,
244 #[arg(long, help = "Print global XBP paths without opening them")]
245 pub no_open: bool,
246 #[command(subcommand)]
247 pub provider: Option<ConfigProviderCmd>,
248}
249
250#[derive(Subcommand, Debug)]
251pub enum ConfigProviderCmd {
252 #[command(about = "Manage the OpenRouter API key used by AI-enabled commands")]
253 Openrouter(ConfigSecretCmd),
254 #[command(about = "Manage the GitHub OAuth2 token used for release automation")]
255 Github(ConfigSecretCmd),
256 #[command(
257 about = "Manage the Linear API key used for release-note issue linking and initiative publishing"
258 )]
259 Linear(LinearConfigCmd),
260}
261
262#[derive(Args, Debug)]
263pub struct ConfigSecretCmd {
264 #[command(subcommand)]
265 pub action: ConfigSecretAction,
266}
267
268#[derive(Subcommand, Debug)]
269pub enum ConfigSecretAction {
270 #[command(about = "Set provider key (omit value to enter it securely)")]
271 SetKey {
272 #[arg(help = "Provider key/token value")]
273 key: Option<String>,
274 },
275 #[command(about = "Delete the stored provider key")]
276 DeleteKey,
277 #[command(about = "Show whether a key is configured (masked by default)")]
278 Show {
279 #[arg(long, help = "Print full key/token value (not masked)")]
280 raw: bool,
281 },
282}
283
284#[derive(Args, Debug)]
285pub struct LinearConfigCmd {
286 #[command(subcommand)]
287 pub action: LinearConfigAction,
288}
289
290#[derive(Subcommand, Debug)]
291pub enum LinearConfigAction {
292 #[command(about = "Set Linear API key (omit value to enter it securely)")]
293 SetKey {
294 #[arg(help = "Linear API key/token value")]
295 key: Option<String>,
296 },
297 #[command(about = "Delete the stored Linear API key")]
298 DeleteKey,
299 #[command(about = "Show whether a Linear API key is configured (masked by default)")]
300 Show {
301 #[arg(long, help = "Print full key/token value (not masked)")]
302 raw: bool,
303 },
304 #[command(
305 name = "select-initiative",
306 about = "Pick a Linear initiative for the current repo and save it to .xbp/xbp.yaml"
307 )]
308 SelectInitiative,
309}
310
311#[derive(Args, Debug)]
312pub struct CurlCmd {
313 #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
314 pub url: Option<String>,
315 #[arg(long, help = "Disable the default 15 second timeout")]
316 pub no_timeout: bool,
317}
318
319#[derive(Args, Debug)]
320#[command(subcommand_precedence_over_arg = true)]
321pub struct VersionCmd {
322 #[arg(
323 help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
324 )]
325 pub target: Option<String>,
326 #[arg(long, help = "Show normalized git tags from `git tag --list`")]
327 pub git: bool,
328 #[command(subcommand)]
329 pub command: Option<VersionSubCommand>,
330}
331
332#[derive(Subcommand, Debug)]
333pub enum VersionSubCommand {
334 #[command(about = "Create and push a git tag for this version, then create a GitHub release")]
335 Release(VersionReleaseCmd),
336}
337
338#[derive(Args, Debug)]
339pub struct VersionReleaseCmd {
340 #[arg(
341 long,
342 help = "Release this version instead of auto-detecting from tracked files"
343 )]
344 pub version: Option<String>,
345 #[arg(
346 long,
347 help = "Allow releasing with uncommitted changes in the working tree"
348 )]
349 pub allow_dirty: bool,
350 #[arg(long, help = "Release title (defaults to <version> - <repo>)")]
351 pub title: Option<String>,
352 #[arg(long, help = "Release notes body (Markdown)")]
353 pub notes: Option<String>,
354 #[arg(long, help = "Read release notes body from a file")]
355 pub notes_file: Option<PathBuf>,
356 #[arg(long, help = "Create as draft release")]
357 pub draft: bool,
358 #[arg(long, help = "Mark release as pre-release")]
359 pub prerelease: bool,
360 #[arg(
361 long,
362 value_enum,
363 default_value_t = VersionReleaseLatest::Legacy,
364 help = "Control GitHub latest flag: true, false, or legacy"
365 )]
366 pub make_latest: VersionReleaseLatest,
367}
368
369#[derive(Copy, Clone, Debug, ValueEnum)]
370pub enum VersionReleaseLatest {
371 True,
372 False,
373 Legacy,
374}
375
376#[derive(Args, Debug)]
377pub struct RedeployV2Cmd {
378 #[arg(short = 'p', long = "password")]
379 pub password: Option<String>,
380 #[arg(short = 'u', long = "username")]
381 pub username: Option<String>,
382 #[arg(short = 'h', long = "host")]
383 pub host: Option<String>,
384 #[arg(short = 'd', long = "project-dir")]
385 pub project_dir: Option<String>,
386}
387
388#[derive(Args, Debug)]
389pub struct LogsCmd {
390 #[arg()]
391 pub project: Option<String>,
392 #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
393 pub ssh_host: Option<String>,
394 #[arg(long = "ssh-username", help = "SSH username for remote host")]
395 pub ssh_username: Option<String>,
396 #[arg(long = "ssh-password", help = "SSH password for remote host")]
397 pub ssh_password: Option<String>,
398}
399
400#[derive(Args, Debug)]
401pub struct SshCmd {
402 #[arg(long = "host", alias = "ssh-host", help = "SSH host or IP address")]
403 pub ssh_host: Option<String>,
404 #[arg(
405 long = "port",
406 default_value_t = 22,
407 help = "SSH port for direct connections"
408 )]
409 pub ssh_port: u16,
410 #[arg(
411 long = "username",
412 alias = "ssh-username",
413 help = "SSH username for the remote host"
414 )]
415 pub ssh_username: Option<String>,
416 #[arg(
417 long = "password",
418 alias = "ssh-password",
419 help = "SSH password (omit to use stored config or a secure prompt)"
420 )]
421 pub ssh_password: Option<String>,
422 #[arg(long, help = "Path to a private key file to use instead of password auth")]
423 pub private_key: Option<PathBuf>,
424 #[arg(long, help = "Passphrase for --private-key when required")]
425 pub private_key_passphrase: Option<String>,
426 #[arg(
427 long,
428 help = "Run this remote command in a PTY instead of opening the default login shell"
429 )]
430 pub command: Option<String>,
431 #[arg(
432 long,
433 help = "TERM value sent to the server (default: TERM env var or xterm-256color)"
434 )]
435 pub term: Option<String>,
436 #[arg(long, help = "Disable SSH host key verification")]
437 pub no_host_key_check: bool,
438 #[arg(
439 long,
440 help = "Pin the SSH host key as a base64 blob when using tunnels or first-connect flows"
441 )]
442 pub host_key: Option<String>,
443 #[arg(long, help = "Path to a known_hosts file used for SSH host verification")]
444 pub known_hosts_file: Option<PathBuf>,
445 #[arg(
446 long,
447 help = "Cloudflare Access hostname used to open a local cloudflared TCP forwarder"
448 )]
449 pub cloudflared_hostname: Option<String>,
450 #[arg(long, help = "Override the cloudflared binary path")]
451 pub cloudflared_binary: Option<PathBuf>,
452 #[arg(
453 long,
454 help = "Optional destination host:port passed to cloudflared access tcp"
455 )]
456 pub cloudflared_destination: Option<String>,
457}
458
459#[derive(Args, Debug)]
460pub struct CloudflaredCmd {
461 #[command(subcommand)]
462 pub command: CloudflaredSubCommand,
463}
464
465#[derive(Subcommand, Debug)]
466pub enum CloudflaredSubCommand {
467 #[command(about = "Start a local cloudflared Access TCP forwarder")]
468 Tcp(CloudflaredTcpCmd),
469}
470
471#[derive(Args, Debug)]
472pub struct CloudflaredTcpCmd {
473 #[arg(long, help = "Protected Cloudflare Access hostname")]
474 pub hostname: String,
475 #[arg(
476 long,
477 help = "Local listener address for the forwarder (default: auto-allocate 127.0.0.1:<port>)"
478 )]
479 pub listener: Option<String>,
480 #[arg(long, help = "Optional destination host:port passed to cloudflared access tcp")]
481 pub destination: Option<String>,
482 #[arg(long, help = "Override the cloudflared binary path")]
483 pub binary: Option<PathBuf>,
484}
485
486#[derive(Args, Debug)]
487pub struct NginxCmd {
488 #[command(subcommand)]
489 pub command: NginxSubCommand,
490}
491
492#[derive(Args, Debug)]
493pub struct NetworkCmd {
494 #[command(subcommand)]
495 pub command: NetworkSubCommand,
496}
497
498#[derive(Subcommand, Debug)]
499pub enum NetworkSubCommand {
500 #[command(about = "Manage persistent floating IP configuration")]
501 FloatingIp(NetworkFloatingIpCmd),
502 #[command(about = "Inspect discovered network configuration sources")]
503 Config(NetworkConfigCmd),
504 #[command(about = "Manage Hetzner-specific Linux network configuration")]
505 Hetzner(NetworkHetznerCmd),
506}
507
508#[derive(Args, Debug)]
509pub struct NetworkFloatingIpCmd {
510 #[command(subcommand)]
511 pub command: NetworkFloatingIpSubCommand,
512}
513
514#[derive(Subcommand, Debug)]
515pub enum NetworkFloatingIpSubCommand {
516 #[command(about = "Add a persistent floating IP entry to detected network backend")]
517 Add {
518 #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
519 ip: String,
520 #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
521 cidr: Option<u8>,
522 #[arg(long, help = "Network interface override (auto-detected when omitted)")]
523 interface: Option<String>,
524 #[arg(long, help = "Optional label for backend metadata/file naming")]
525 label: Option<String>,
526 #[arg(long, help = "Apply network changes after writing config")]
527 apply: bool,
528 #[arg(long, help = "Preview computed changes without writing files")]
529 dry_run: bool,
530 },
531 #[command(about = "List floating IPs from runtime and persisted network config")]
532 List {
533 #[arg(long, help = "Emit JSON output")]
534 json: bool,
535 },
536}
537
538#[derive(Args, Debug)]
539pub struct NetworkConfigCmd {
540 #[command(subcommand)]
541 pub command: NetworkConfigSubCommand,
542}
543
544#[derive(Subcommand, Debug)]
545pub enum NetworkConfigSubCommand {
546 #[command(about = "List detected backend and configuration source files")]
547 List {
548 #[arg(long, help = "Emit JSON output")]
549 json: bool,
550 },
551}
552
553#[derive(Args, Debug)]
554pub struct NetworkHetznerCmd {
555 #[command(subcommand)]
556 pub command: NetworkHetznerSubCommand,
557}
558
559#[derive(Subcommand, Debug)]
560pub enum NetworkHetznerSubCommand {
561 #[command(about = "Configure a Hetzner vSwitch VLAN interface persistently")]
562 Vswitch(NetworkHetznerVswitchCmd),
563}
564
565#[derive(Args, Debug)]
566pub struct NetworkHetznerVswitchCmd {
567 #[command(subcommand)]
568 pub command: NetworkHetznerVswitchSubCommand,
569}
570
571#[derive(Subcommand, Debug)]
572pub enum NetworkHetznerVswitchSubCommand {
573 #[command(about = "Write persistent Linux config for a Hetzner vSwitch VLAN interface")]
574 Setup {
575 #[arg(long, help = "Private IPv4 address to assign on the vSwitch VLAN interface")]
576 ip: String,
577 #[arg(long, default_value_t = 24, help = "CIDR prefix for --ip (default: 24)")]
578 cidr: u8,
579 #[arg(long, help = "Physical parent interface (auto-detected when omitted)")]
580 interface: Option<String>,
581 #[arg(long, help = "Hetzner vSwitch VLAN ID")]
582 vlan_id: u16,
583 #[arg(long, default_value_t = 1400, help = "Interface MTU (default: 1400)")]
584 mtu: u16,
585 #[arg(
586 long,
587 default_value = "10.0.3.1",
588 help = "Gateway for the routed Hetzner cloud network"
589 )]
590 gateway: String,
591 #[arg(
592 long,
593 default_value = "10.0.0.0/16",
594 help = "Destination CIDR routed through the Hetzner vSwitch gateway"
595 )]
596 route_cidr: String,
597 #[arg(long, help = "Apply or activate the new config immediately")]
598 apply: bool,
599 #[arg(long, help = "Preview file changes without writing them")]
600 dry_run: bool,
601 },
602}
603
604#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
605pub enum NginxDnsMode {
606 Manual,
607 Plugin,
608}
609
610#[derive(Subcommand, Debug)]
611pub enum NginxSubCommand {
612 #[command(
613 about = "Provision an HTTPS NGINX reverse proxy with Certbot",
614 long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
615and write final HTTP->HTTPS redirect + TLS proxy config.\n\
616\n\
617Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
618Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
619with --dns-plugin and --dns-creds for non-interactive provider automation."
620 )]
621 Setup {
622 #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
623 domain: String,
624 #[arg(short, long, help = "Port to proxy to")]
625 port: u16,
626 #[arg(
627 short,
628 long,
629 help = "Email used for Let's Encrypt account registration"
630 )]
631 email: String,
632 #[arg(
633 long,
634 value_enum,
635 default_value_t = NginxDnsMode::Manual,
636 help = "DNS challenge mode for wildcard certificates: manual or plugin"
637 )]
638 dns_mode: NginxDnsMode,
639 #[arg(
640 long,
641 help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
642 )]
643 dns_plugin: Option<String>,
644 #[arg(
645 long,
646 help = "Path to DNS plugin credentials file for --dns-mode plugin"
647 )]
648 dns_creds: Option<PathBuf>,
649 #[arg(
650 long,
651 default_value_t = true,
652 action = clap::ArgAction::Set,
653 value_parser = clap::builder::BoolishValueParser::new(),
654 help = "For wildcard domains, also request the base domain certificate (true|false)"
655 )]
656 include_base: bool,
657 },
658 #[command(about = "List discovered NGINX sites with listen/upstream ports")]
659 List,
660 #[command(about = "Show full NGINX config for one domain or all domains")]
661 Show {
662 #[arg(help = "Optional domain name to inspect")]
663 domain: Option<String>,
664 },
665 #[command(about = "Open an NGINX site config in your configured editor")]
666 Edit {
667 #[arg(help = "Domain name to edit")]
668 domain: String,
669 },
670 #[command(about = "Update upstream port for an existing NGINX site")]
671 Update {
672 #[arg(short, long, help = "Domain name to update")]
673 domain: String,
674 #[arg(short, long, help = "New port to proxy to")]
675 port: u16,
676 },
677}
678
679#[derive(Args, Debug)]
680pub struct DiagCmd {
681 #[arg(long, help = "Check Nginx configuration")]
682 pub nginx: bool,
683 #[arg(long, help = "Check specific ports (comma-separated)")]
684 pub ports: Option<String>,
685 #[arg(long, help = "Skip internet speed test")]
686 pub no_speed_test: bool,
687 #[arg(
688 long,
689 help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
690 )]
691 pub compose_file: Option<String>,
692}
693
694#[derive(Args, Debug)]
695pub struct MonitorCmd {
696 #[command(subcommand)]
697 pub command: Option<MonitorSubCommand>,
698}
699
700#[derive(Subcommand, Debug)]
701pub enum MonitorSubCommand {
702 Check,
703 Start,
704}
705
706#[cfg(feature = "monitoring")]
707#[derive(Args, Debug)]
708pub struct MonitoringCmd {
709 #[command(subcommand)]
710 pub command: MonitoringSubCommand,
711}
712
713#[cfg(feature = "monitoring")]
714#[derive(Subcommand, Debug)]
715pub enum MonitoringSubCommand {
716 Serve {
717 #[arg(
718 short,
719 long,
720 default_value = "prodzilla.yml",
721 help = "Monitoring config file"
722 )]
723 file: String,
724 },
725 RunOnce {
726 #[arg(
727 short,
728 long,
729 default_value = "prodzilla.yml",
730 help = "Monitoring config file"
731 )]
732 file: String,
733 #[arg(long, help = "Run probes only")]
734 probes_only: bool,
735 #[arg(long, help = "Run stories only")]
736 stories_only: bool,
737 },
738 List {
739 #[arg(
740 short,
741 long,
742 default_value = "prodzilla.yml",
743 help = "Monitoring config file"
744 )]
745 file: String,
746 },
747}
748
749#[derive(Args, Debug)]
750#[command(arg_required_else_help = true)]
751pub struct ApiCmd {
752 #[command(subcommand)]
753 pub command: ApiSubCommand,
754}
755
756#[cfg(feature = "docker")]
757#[derive(Args, Debug)]
758pub struct DockerCmd {
759 #[arg(
760 trailing_var_arg = true,
761 allow_hyphen_values = true,
762 help = "Arguments to pass directly to the Docker CLI (default: --help)"
763 )]
764 pub args: Vec<String>,
765}
766
767#[derive(Subcommand, Debug)]
768pub enum ApiSubCommand {
769 Install {
770 #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
771 port: u16,
772 },
773}
774#[derive(Args, Debug)]
775pub struct TailCmd {
776 #[arg(long, help = "Tail Kafka topic instead of log files")]
777 pub kafka: bool,
778 #[arg(long, help = "Ship logs to Kafka")]
779 pub ship: bool,
780}
781
782#[derive(Args, Debug)]
783pub struct GenerateCmd {
784 #[command(subcommand)]
785 pub command: GenerateSubCommand,
786}
787
788#[derive(Subcommand, Debug)]
789pub enum GenerateSubCommand {
790 #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
791 Config(GenerateConfigCmd),
792 Systemd(GenerateSystemdCmd),
793}
794
795#[derive(Args, Debug)]
796pub struct GenerateConfigCmd {
797 #[arg(
798 long,
799 help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
800 )]
801 pub force: bool,
802 #[arg(
803 long,
804 help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
805 )]
806 pub update: bool,
807 #[arg(
808 long,
809 help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
810 )]
811 pub from_json: Option<PathBuf>,
812}
813
814#[cfg(feature = "secrets")]
815#[derive(Args, Debug)]
816pub struct SecretsCmd {
817 #[arg(long, help = "GitHub repository override (owner/repo)")]
818 pub repo: Option<String>,
819 #[arg(long, help = "GitHub token to use (repo scope for private repos)")]
820 pub token: Option<String>,
821 #[arg(
822 long = "environment",
823 alias = "env",
824 value_enum,
825 default_value_t = SecretsEnvironment::XbpDev,
826 help = "GitHub Actions environment to sync (default: xbp-dev)"
827 )]
828 pub environment: SecretsEnvironment,
829 #[command(subcommand)]
830 pub command: Option<SecretsSubCommand>,
831}
832
833#[cfg(feature = "secrets")]
834#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
835pub enum SecretsEnvironment {
836 #[value(name = "xbp-dev")]
837 XbpDev,
838 #[value(name = "xbp-preview")]
839 XbpPreview,
840 #[value(name = "xbp-prod")]
841 XbpProd,
842}
843
844#[cfg(feature = "secrets")]
845impl SecretsEnvironment {
846 pub fn as_str(self) -> &'static str {
847 match self {
848 Self::XbpDev => "xbp-dev",
849 Self::XbpPreview => "xbp-preview",
850 Self::XbpProd => "xbp-prod",
851 }
852 }
853}
854
855#[cfg(feature = "secrets")]
856#[derive(Subcommand, Debug)]
857pub enum SecretsSubCommand {
858 List(ListCmd),
860 Push(PushCmd),
862 Pull(PullCmd),
864 GenerateDefault(GenerateDefaultCmd),
866 GenerateExample(GenerateExampleCmd),
868 Diff,
870 Verify,
872 #[command(name = "diag", alias = "doctor")]
874 Diag,
875 #[command(name = "usage")]
877 Usage,
878}
879
880#[cfg(feature = "secrets")]
881#[derive(Args, Debug)]
882pub struct ListCmd {
883 #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
884 pub file: Option<String>,
885 #[arg(long, help = "Output format: plain (default) or json")]
886 pub format: Option<String>,
887}
888
889#[cfg(feature = "secrets")]
890#[derive(Args, Debug)]
891pub struct PushCmd {
892 #[arg(long, help = "Path to env file (default: .env.local/.env)")]
893 pub file: Option<String>,
894 #[arg(
895 long,
896 help = "Force overwrite existing GitHub Actions environment variables"
897 )]
898 pub force: bool,
899 #[arg(long, help = "Show what would be pushed without making changes")]
900 pub dry_run: bool,
901}
902
903#[cfg(feature = "secrets")]
904#[derive(Args, Debug)]
905pub struct PullCmd {
906 #[arg(long, help = "Output file path (default: .env.local)")]
907 pub output: Option<String>,
908}
909
910#[cfg(feature = "secrets")]
911#[derive(Args, Debug)]
912pub struct GenerateDefaultCmd {
913 #[arg(long, help = "Output file path (default: .env.default)")]
914 pub output: Option<String>,
915}
916
917#[cfg(feature = "secrets")]
918#[derive(Args, Debug)]
919pub struct GenerateExampleCmd {
920 #[arg(long, help = "Output file path (default: .env.example)")]
921 pub output: Option<String>,
922 #[arg(long, help = "Remove keys from .env.local not in .env.example")]
923 pub clean: bool,
924 #[arg(long, help = "Only include vars matching prefix (repeatable)")]
925 pub include_prefix: Vec<String>,
926 #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
927 pub exclude_prefix: Vec<String>,
928}
929
930#[derive(Args, Debug)]
931pub struct GenerateSystemdCmd {
932 #[arg(
933 long,
934 default_value = "/etc/systemd/system",
935 help = "Directory where the systemd units are written"
936 )]
937 pub output_dir: PathBuf,
938 #[arg(long, help = "Only generate the unit for this service name")]
939 pub service: Option<String>,
940 #[arg(
941 long,
942 default_value_t = true,
943 help = "Also generate the xbp-api systemd unit alongside project/services"
944 )]
945 pub api: bool,
946}
947
948#[derive(Args, Debug)]
949pub struct DoneCmd {
950 #[arg(long, help = "Root directory under which to discover git repos")]
951 pub root: Option<std::path::PathBuf>,
952 #[arg(
953 long,
954 default_value = "24 hours ago",
955 help = "Git --since value (e.g. '7 days ago')"
956 )]
957 pub since: String,
958 #[arg(short, long, help = "Output Markdown file path")]
959 pub output: Option<std::path::PathBuf>,
960 #[arg(long, help = "Skip AI summarization (OpenRouter)")]
961 pub no_ai: bool,
962 #[arg(short, long, help = "Discover repos recursively")]
963 pub recursive: bool,
964 #[arg(long, help = "Exclude repo by name (repeatable)")]
965 pub exclude: Vec<String>,
966}
967
968#[cfg(feature = "nordvpn")]
969#[derive(Args, Debug)]
970pub struct NordvpnCmd {
971 #[arg(
972 trailing_var_arg = true,
973 allow_hyphen_values = true,
974 help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
975 )]
976 pub args: Vec<String>,
977}
978
979#[cfg(feature = "kubernetes")]
980#[derive(Args, Debug)]
981pub struct KubernetesCmd {
982 #[command(subcommand)]
983 pub command: KubernetesSubCommand,
984}
985
986#[cfg(feature = "kubernetes")]
987#[derive(Args, Debug)]
988pub struct KubernetesAddonCmd {
989 #[command(subcommand)]
990 pub command: KubernetesAddonSubCommand,
991}
992
993#[cfg(feature = "kubernetes")]
994#[derive(Subcommand, Debug)]
995pub enum KubernetesAddonSubCommand {
996 List,
998 Enable {
1000 #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
1001 name: String,
1002 },
1003 Disable {
1005 #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
1006 name: String,
1007 },
1008}
1009
1010#[cfg(feature = "kubernetes")]
1011#[derive(Subcommand, Debug)]
1012pub enum KubernetesSubCommand {
1013 Check {
1015 #[arg(long, help = "Kubeconfig context to target")]
1016 context: Option<String>,
1017 #[arg(
1018 long,
1019 default_value = "default",
1020 help = "Namespace to probe for workload readiness"
1021 )]
1022 namespace: String,
1023 #[arg(long, help = "Skip live cluster calls (tooling check only)")]
1024 offline: bool,
1025 },
1026 Generate {
1028 #[arg(long, help = "Logical app name (used for resource names)")]
1029 name: String,
1030 #[arg(long, help = "Container image reference")]
1031 image: String,
1032 #[arg(long, default_value_t = 80, help = "Container port for the service")]
1033 port: u16,
1034 #[arg(long, default_value_t = 1, help = "Replica count")]
1035 replicas: u16,
1036 #[arg(
1037 long,
1038 default_value = "default",
1039 help = "Namespace for generated resources"
1040 )]
1041 namespace: String,
1042 #[arg(
1043 long,
1044 default_value = "k8s/xbp-manifest.yaml",
1045 help = "Path to write the manifest bundle"
1046 )]
1047 output: String,
1048 #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
1049 host: Option<String>,
1050 },
1051 Apply {
1053 #[arg(long, help = "Path to manifest file")]
1054 file: String,
1055 #[arg(long, help = "Override kube context")]
1056 context: Option<String>,
1057 #[arg(long, help = "Override namespace")]
1058 namespace: Option<String>,
1059 #[arg(long, help = "Use --dry-run=server")]
1060 dry_run: bool,
1061 },
1062 Status {
1064 #[arg(long, default_value = "default", help = "Namespace to summarize")]
1065 namespace: String,
1066 #[arg(long, help = "Override kube context")]
1067 context: Option<String>,
1068 },
1069 Addons(KubernetesAddonCmd),
1071 DashboardToken {
1073 #[arg(
1074 long,
1075 default_value = "kube-system",
1076 help = "Namespace containing the dashboard token secret"
1077 )]
1078 namespace: String,
1079 #[arg(
1080 long,
1081 default_value = "microk8s-dashboard-token",
1082 help = "Secret name containing the dashboard login token"
1083 )]
1084 secret: String,
1085 #[arg(long, help = "Override kube context")]
1086 context: Option<String>,
1087 },
1088 ObservabilityCreds {
1090 #[arg(
1091 long,
1092 default_value = "observability",
1093 help = "Namespace containing Grafana secret"
1094 )]
1095 namespace: String,
1096 #[arg(
1097 long,
1098 default_value = "kube-prom-stack-grafana",
1099 help = "Grafana secret name"
1100 )]
1101 secret: String,
1102 #[arg(long, help = "Override kube context")]
1103 context: Option<String>,
1104 },
1105 Issuer {
1107 #[arg(
1108 long,
1109 help = "Email used for Let's Encrypt account registration (required)"
1110 )]
1111 email: String,
1112 #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
1113 name: String,
1114 #[arg(
1115 long,
1116 default_value = "default",
1117 help = "Namespace for the Issuer resource"
1118 )]
1119 namespace: String,
1120 #[arg(
1121 long,
1122 default_value = "https://acme-v02.api.letsencrypt.org/directory",
1123 help = "ACME server URL (production by default)"
1124 )]
1125 server: String,
1126 #[arg(
1127 long,
1128 default_value = "letsencrypt-account-key",
1129 help = "Secret used to store the ACME account private key"
1130 )]
1131 private_key_secret: String,
1132 #[arg(
1133 long,
1134 default_value = "nginx",
1135 help = "Ingress class name used for HTTP01 solving"
1136 )]
1137 ingress_class_name: String,
1138 #[arg(long, help = "Override kube context")]
1139 context: Option<String>,
1140 #[arg(long, help = "Use --dry-run=server")]
1141 dry_run: bool,
1142 },
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::{
1148 Cli, CloudflaredSubCommand, Commands, GenerateSubCommand, LinearConfigAction,
1149 NetworkFloatingIpSubCommand, NetworkHetznerSubCommand, NetworkHetznerVswitchSubCommand,
1150 NetworkSubCommand, SshCmd,
1151 };
1152 #[cfg(feature = "secrets")]
1153 use super::{SecretsEnvironment, SecretsSubCommand};
1154 use clap::Parser;
1155 use std::path::PathBuf;
1156
1157 #[test]
1158 fn parses_network_floating_ip_add() {
1159 let cli = Cli::parse_from([
1160 "xbp",
1161 "network",
1162 "floating-ip",
1163 "add",
1164 "--ip",
1165 "1.2.3.4",
1166 "--apply",
1167 ]);
1168
1169 match cli.command {
1170 Some(Commands::Network(network)) => match network.command {
1171 NetworkSubCommand::FloatingIp(fip) => match fip.command {
1172 NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
1173 assert_eq!(ip, "1.2.3.4");
1174 assert!(apply);
1175 }
1176 _ => panic!("expected add subcommand"),
1177 },
1178 _ => panic!("expected floating-ip subcommand"),
1179 },
1180 _ => panic!("expected network command"),
1181 }
1182 }
1183
1184 #[test]
1185 fn parses_generate_config_update() {
1186 let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
1187
1188 match cli.command {
1189 Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
1190 GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
1191 _ => panic!("expected generate config command"),
1192 },
1193 _ => panic!("expected generate command"),
1194 }
1195 }
1196
1197 #[test]
1198 fn parses_commit_command_with_dry_run() {
1199 let cli = Cli::parse_from(["xbp", "commit", "--dry-run", "--scope", "cli"]);
1200
1201 match cli.command {
1202 Some(Commands::Commit(commit_cmd)) => {
1203 assert!(commit_cmd.dry_run);
1204 assert_eq!(commit_cmd.scope.as_deref(), Some("cli"));
1205 assert_eq!(commit_cmd.model, "openai/gpt-4o-mini");
1206 }
1207 _ => panic!("expected commit command"),
1208 }
1209 }
1210
1211 #[test]
1212 fn parses_linear_select_initiative_config_command() {
1213 let cli = Cli::parse_from(["xbp", "config", "linear", "select-initiative"]);
1214
1215 match cli.command {
1216 Some(Commands::Config(config_cmd)) => match config_cmd.provider {
1217 Some(super::ConfigProviderCmd::Linear(linear_cmd)) => {
1218 assert!(matches!(
1219 linear_cmd.action,
1220 LinearConfigAction::SelectInitiative
1221 ));
1222 }
1223 _ => panic!("expected linear config provider"),
1224 },
1225 _ => panic!("expected config command"),
1226 }
1227 }
1228
1229 #[test]
1230 fn parses_ssh_command_with_cloudflared_and_key_auth() {
1231 let cli = Cli::parse_from([
1232 "xbp",
1233 "ssh",
1234 "--host",
1235 "ssh.internal",
1236 "--username",
1237 "deploy",
1238 "--private-key",
1239 "C:/Users/floris/.ssh/id_ed25519",
1240 "--cloudflared-hostname",
1241 "bastion.example.com",
1242 "--command",
1243 "htop",
1244 ]);
1245
1246 let Some(Commands::Ssh(SshCmd {
1247 ssh_host,
1248 ssh_username,
1249 private_key,
1250 cloudflared_hostname,
1251 command,
1252 ..
1253 })) = cli.command
1254 else {
1255 panic!("expected shell command");
1256 };
1257
1258 assert_eq!(ssh_host.as_deref(), Some("ssh.internal"));
1259 assert_eq!(ssh_username.as_deref(), Some("deploy"));
1260 assert_eq!(private_key, Some(PathBuf::from("C:/Users/floris/.ssh/id_ed25519")));
1261 assert_eq!(cloudflared_hostname.as_deref(), Some("bastion.example.com"));
1262 assert_eq!(command.as_deref(), Some("htop"));
1263 }
1264
1265 #[test]
1266 fn parses_cloudflared_tcp_command() {
1267 let cli = Cli::parse_from([
1268 "xbp",
1269 "cloudflared",
1270 "tcp",
1271 "--hostname",
1272 "bastion.example.com",
1273 "--listener",
1274 "127.0.0.1:2222",
1275 ]);
1276
1277 let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
1278 panic!("expected cloudflared command");
1279 };
1280
1281 match cloudflared_cmd.command {
1282 CloudflaredSubCommand::Tcp(tcp_cmd) => {
1283 assert_eq!(tcp_cmd.hostname, "bastion.example.com");
1284 assert_eq!(tcp_cmd.listener.as_deref(), Some("127.0.0.1:2222"));
1285 }
1286 }
1287 }
1288
1289 #[test]
1290 fn parses_shell_alias_as_ssh_command() {
1291 let cli = Cli::parse_from(["xbp", "shell", "--host", "ssh.internal"]);
1292
1293 let Some(Commands::Ssh(ssh_cmd)) = cli.command else {
1294 panic!("expected ssh command through shell alias");
1295 };
1296
1297 assert_eq!(ssh_cmd.ssh_host.as_deref(), Some("ssh.internal"));
1298 }
1299
1300 #[test]
1301 fn parses_hetzner_vswitch_setup_command() {
1302 let cli = Cli::parse_from([
1303 "xbp",
1304 "network",
1305 "hetzner",
1306 "vswitch",
1307 "setup",
1308 "--ip",
1309 "10.0.3.2",
1310 "--vlan-id",
1311 "4000",
1312 "--interface",
1313 "enp0s31f6",
1314 "--apply",
1315 ]);
1316
1317 let Some(Commands::Network(network_cmd)) = cli.command else {
1318 panic!("expected network command");
1319 };
1320
1321 match network_cmd.command {
1322 NetworkSubCommand::Hetzner(hetzner_cmd) => match hetzner_cmd.command {
1323 NetworkHetznerSubCommand::Vswitch(vswitch_cmd) => match vswitch_cmd.command {
1324 NetworkHetznerVswitchSubCommand::Setup {
1325 ip,
1326 cidr,
1327 interface,
1328 vlan_id,
1329 apply,
1330 ..
1331 } => {
1332 assert_eq!(ip, "10.0.3.2");
1333 assert_eq!(cidr, 24);
1334 assert_eq!(interface.as_deref(), Some("enp0s31f6"));
1335 assert_eq!(vlan_id, 4000);
1336 assert!(apply);
1337 }
1338 },
1339 },
1340 _ => panic!("expected hetzner subcommand"),
1341 }
1342 }
1343
1344 #[cfg(feature = "secrets")]
1345 #[test]
1346 fn parses_secrets_diag_command() {
1347 let cli = Cli::parse_from(["xbp", "secrets", "diag"]);
1348
1349 match cli.command {
1350 Some(Commands::Secrets(secrets_cmd)) => {
1351 assert!(matches!(secrets_cmd.command, Some(SecretsSubCommand::Diag)));
1352 assert!(matches!(
1353 secrets_cmd.environment,
1354 SecretsEnvironment::XbpDev
1355 ));
1356 }
1357 _ => panic!("expected secrets command"),
1358 }
1359 }
1360
1361 #[cfg(feature = "secrets")]
1362 #[test]
1363 fn parses_secrets_environment_override() {
1364 let cli = Cli::parse_from(["xbp", "secrets", "--environment", "xbp-prod", "push"]);
1365
1366 match cli.command {
1367 Some(Commands::Secrets(secrets_cmd)) => {
1368 assert!(matches!(
1369 secrets_cmd.environment,
1370 SecretsEnvironment::XbpProd
1371 ));
1372 assert!(matches!(
1373 secrets_cmd.command,
1374 Some(SecretsSubCommand::Push(_))
1375 ));
1376 }
1377 _ => panic!("expected secrets command"),
1378 }
1379 }
1380}