Skip to main content

xbp_cli/cli/
commands.rs

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 local env vars from the preferred env file
859    List(ListCmd),
860    /// Push local env vars to the secrets provider (GitHub)
861    Push(PushCmd),
862    /// Pull secrets from the provider into .env.local
863    Pull(PullCmd),
864    /// Generate .env.default from source code inspection
865    GenerateDefault(GenerateDefaultCmd),
866    /// Generate .env.example with categories and defaults
867    GenerateExample(GenerateExampleCmd),
868    /// Compare local env with remote (GitHub) variables
869    Diff,
870    /// Verify that all required env vars are available locally
871    Verify,
872    /// Check connectivity, token scope, and repo access for secrets
873    #[command(name = "diag", alias = "doctor")]
874    Diag,
875    /// Show secrets command usage
876    #[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    /// Show complete addon status (enabled/disabled) from `microk8s status`
997    List,
998    /// Enable a MicroK8s addon
999    Enable {
1000        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
1001        name: String,
1002    },
1003    /// Disable a MicroK8s addon
1004    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    /// Validate kubectl, current context, and node readiness
1014    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 Deployment/Service/NetworkPolicy YAML
1027    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 a manifest bundle with kubectl apply -f
1052    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    /// Summarize deployments/services/pods in a namespace
1063    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    /// Manage MicroK8s addons (list, enable, disable)
1070    Addons(KubernetesAddonCmd),
1071    /// Extract Kubernetes Dashboard login token from secret describe output
1072    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    /// Print decoded Grafana admin credentials from observability secret
1089    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    /// Create or update a cert-manager Issuer for Let's Encrypt
1106    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}