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(
37        about = "Analyze the current git worktree and create a conventional commit",
38        visible_alias = "c"
39    )]
40    Commit(CommitCmd),
41    #[command(about = "Initialize an XBP project in the current directory")]
42    Init,
43    #[command(about = "Install common dependencies for host setup")]
44    Setup,
45    #[command(about = "Redeploy one service or the entire project")]
46    Redeploy {
47        #[arg(
48            help = "Service name to redeploy (optional, uses legacy redeploy.sh if not provided)"
49        )]
50        service_name: Option<String>,
51    },
52    #[command(about = "Run the legacy remote redeploy workflow over SSH")]
53    RedeployV2(RedeployV2Cmd),
54    #[command(about = "Inspect project/global config and manage provider keys")]
55    Config(ConfigCmd),
56    #[command(
57        about = "Install supported host packages or project tooling",
58        after_help = crate::commands::INSTALL_COMMAND_AFTER_HELP
59    )]
60    Install {
61        #[arg(short = 'l', long = "list", help = "List installable targets and exit")]
62        list: bool,
63        #[arg(help = "Install target (leave empty to show installable options)")]
64        package: Option<String>,
65    },
66    #[command(about = "Tail local or remote logs")]
67    Logs(LogsCmd),
68    #[command(
69        about = "Open an interactive remote shell over SSH",
70        visible_alias = "shell"
71    )]
72    Ssh(SshCmd),
73    #[command(about = "Open or manage cloudflared TCP forwarders")]
74    Cloudflared(CloudflaredCmd),
75    #[command(about = "List PM2 processes")]
76    List,
77    #[command(about = "Fetch an HTTP endpoint with sane defaults")]
78    Curl(CurlCmd),
79    #[command(about = "List configured services from project config")]
80    Services,
81    #[command(about = "Run service-level commands (build/install/start/dev)")]
82    Service {
83        #[arg(help = "Command to run: build, install, start, dev, or --help")]
84        command: Option<String>,
85        #[arg(help = "Service name")]
86        service_name: Option<String>,
87    },
88    #[command(about = "Manage NGINX site configs and upstream mappings")]
89    Nginx(NginxCmd),
90    #[command(about = "Manage host network configuration and floating IPs")]
91    Network(NetworkCmd),
92    #[command(about = "Run full system diagnostics and readiness checks")]
93    Diag(DiagCmd),
94    #[command(about = "Run health-check monitoring commands")]
95    Monitor(MonitorCmd),
96    #[command(about = "Capture a PM2 snapshot for later restore")]
97    Snapshot,
98    #[command(about = "Restore PM2 state from dump or latest snapshot")]
99    Resurrect,
100    #[command(about = "Stop a PM2 process by name or stop all")]
101    Stop {
102        #[arg(help = "PM2 process name or 'all' (default: all)")]
103        target: Option<String>,
104    },
105    #[command(about = "Flush PM2 logs globally or for a specific process")]
106    Flush {
107        #[arg(help = "Optional PM2 process name")]
108        target: Option<String>,
109    },
110    #[command(about = "Run login flow against configured XBP API")]
111    Login,
112    #[command(
113        about = "Inspect, reconcile, or bump project versions",
114        visible_alias = "v"
115    )]
116    Version(VersionCmd),
117    #[command(about = "Run configured npm/crates publish workflows for the current XBP project")]
118    Publish(PublishCmd),
119    #[command(about = "Show PM2 environment by name or numeric id")]
120    Env {
121        #[arg(help = "PM2 process name or id")]
122        target: String,
123    },
124    #[command(about = "Tail app logs or Kafka logs")]
125    Tail(TailCmd),
126    #[command(about = "Start a binary/process under PM2")]
127    Start {
128        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
129        args: Vec<String>,
130    },
131    #[command(about = "Generate helper artifacts such as systemd units")]
132    Generate(GenerateCmd),
133    #[cfg(feature = "secrets")]
134    #[command(about = "Manage env vars and GitHub Actions environment variables (feature-gated)")]
135    Secrets(SecretsCmd),
136    #[command(about = "Manage DNS providers and records")]
137    Dns(DnsCmd),
138    #[command(about = "Discover and inspect registered domains")]
139    Domains(DomainsCmd),
140    #[command(
141        about = "Generate 'what did I get done' Markdown report from git commits across repos"
142    )]
143    Done(DoneCmd),
144    #[cfg(feature = "kubernetes")]
145    #[command(about = "Experimental Kubernetes cluster manager (feature-gated)")]
146    Kubernetes(KubernetesCmd),
147    #[cfg(feature = "nordvpn")]
148    #[command(about = "NordVPN meshnet setup and passthrough (feature-gated)")]
149    Nordvpn(NordvpnCmd),
150    #[cfg(feature = "monitoring")]
151    Monitoring(MonitoringCmd),
152    #[command(about = "Manage the XBP API server")]
153    Api(ApiCmd),
154    #[cfg(feature = "docker")]
155    #[command(about = "Pass-through wrapper around the Docker CLI")]
156    Docker(DockerCmd),
157}
158
159pub fn command_label(command: &Commands) -> &'static str {
160    match command {
161        Commands::Ports(_) => "ports",
162        Commands::Commit(_) => "commit",
163        Commands::Init => "init",
164        Commands::Setup => "setup",
165        Commands::Redeploy { .. } => "redeploy",
166        Commands::RedeployV2(_) => "redeploy-v2",
167        Commands::Config(_) => "config",
168        Commands::Install { .. } => "install",
169        Commands::Logs(_) => "logs",
170        Commands::Ssh(_) => "ssh",
171        Commands::Cloudflared(_) => "cloudflared",
172        Commands::List => "list",
173        Commands::Curl(_) => "curl",
174        Commands::Services => "services",
175        Commands::Service { .. } => "service",
176        Commands::Nginx(_) => "nginx",
177        Commands::Network(_) => "network",
178        Commands::Diag(_) => "diag",
179        Commands::Monitor(_) => "monitor",
180        Commands::Snapshot => "snapshot",
181        Commands::Resurrect => "resurrect",
182        Commands::Stop { .. } => "stop",
183        Commands::Flush { .. } => "flush",
184        Commands::Login => "login",
185        Commands::Version(_) => "version",
186        Commands::Publish(_) => "publish",
187        Commands::Env { .. } => "env",
188        Commands::Tail(_) => "tail",
189        Commands::Start { .. } => "start",
190        Commands::Generate(_) => "generate",
191        #[cfg(feature = "secrets")]
192        Commands::Secrets(_) => "secrets",
193        Commands::Dns(_) => "dns",
194        Commands::Domains(_) => "domains",
195        Commands::Done(_) => "done",
196        #[cfg(feature = "kubernetes")]
197        Commands::Kubernetes(_) => "kubernetes",
198        #[cfg(feature = "nordvpn")]
199        Commands::Nordvpn(_) => "nordvpn",
200        #[cfg(feature = "monitoring")]
201        Commands::Monitoring(_) => "monitoring",
202        Commands::Api(_) => "api",
203        #[cfg(feature = "docker")]
204        Commands::Docker(_) => "docker",
205    }
206}
207
208#[derive(Args, Debug)]
209pub struct CommitCmd {
210    #[arg(
211        long,
212        help = "Generate and print the conventional commit message without creating a git commit"
213    )]
214    pub dry_run: bool,
215    #[arg(
216        short = 'p',
217        long,
218        help = "Push after committing, or push pending local commits when nothing new needs committing"
219    )]
220    pub push: bool,
221    #[arg(long, help = "Skip OpenRouter and use local heuristics only")]
222    pub no_ai: bool,
223    #[arg(
224        long,
225        default_value = "openai/gpt-4o-mini",
226        help = "OpenRouter model override used for commit generation"
227    )]
228    pub model: String,
229    #[arg(
230        long,
231        help = "Force the conventional commit scope (for example: cli, api, docs)"
232    )]
233    pub scope: Option<String>,
234}
235
236#[derive(Args, Debug)]
237pub struct PortsCmd {
238    #[arg(short = 'p', long = "port")]
239    pub port: Option<u16>,
240    #[arg(long = "kill")]
241    pub kill: bool,
242    #[arg(short = 'n', long = "nginx")]
243    pub nginx: bool,
244    #[arg(
245        long = "full",
246        help = "Show one unified ports view (reconciled listeners + exposure + security flags)"
247    )]
248    pub full: bool,
249    #[arg(
250        long = "no-local",
251        help = "Exclude connections where LocalAddr equals RemoteAddr"
252    )]
253    pub no_local: bool,
254    #[arg(
255        long = "exposure",
256        help = "Diagnose external exposure per port (binding + firewall layer)"
257    )]
258    pub exposure: bool,
259}
260
261#[derive(Args, Debug)]
262pub struct ConfigCmd {
263    #[arg(
264        long,
265        help = "Show the current project config instead of opening global XBP paths"
266    )]
267    pub project: bool,
268    #[arg(long, help = "Print global XBP paths without opening them")]
269    pub no_open: bool,
270    #[command(subcommand)]
271    pub provider: Option<ConfigProviderCmd>,
272}
273
274#[derive(Subcommand, Debug)]
275pub enum ConfigProviderCmd {
276    #[command(about = "Manage the OpenRouter API key used by AI-enabled commands")]
277    Openrouter(ConfigSecretCmd),
278    #[command(about = "Manage the GitHub OAuth2 token used for release automation")]
279    Github(ConfigSecretCmd),
280    #[command(about = "Manage Cloudflare API credentials used by secrets, DNS, and domains")]
281    Cloudflare(CloudflareConfigCmd),
282    #[command(
283        about = "Manage the Linear API key used for release-note issue linking and initiative publishing"
284    )]
285    Linear(LinearConfigCmd),
286    #[command(about = "Manage npm registry auth and guided npm publish config")]
287    Npm(RegistryConfigCmd),
288    #[command(about = "Manage crates.io auth and guided crate publish config")]
289    Crates(RegistryConfigCmd),
290}
291
292#[derive(Args, Debug)]
293pub struct ConfigSecretCmd {
294    #[command(subcommand)]
295    pub action: ConfigSecretAction,
296}
297
298#[derive(Subcommand, Debug)]
299pub enum ConfigSecretAction {
300    #[command(about = "Set provider key (omit value to enter it securely)")]
301    SetKey {
302        #[arg(help = "Provider key/token value")]
303        key: Option<String>,
304    },
305    #[command(about = "Delete the stored provider key")]
306    DeleteKey,
307    #[command(about = "Show whether a key is configured (masked by default)")]
308    Show {
309        #[arg(long, help = "Print full key/token value (not masked)")]
310        raw: bool,
311    },
312}
313
314#[derive(Args, Debug)]
315pub struct CloudflareConfigCmd {
316    #[command(subcommand)]
317    pub action: CloudflareConfigAction,
318}
319
320#[derive(Subcommand, Debug)]
321pub enum CloudflareConfigAction {
322    #[command(about = "Set Cloudflare API token (omit value to enter it securely)")]
323    SetKey {
324        #[arg(help = "Cloudflare API token")]
325        key: Option<String>,
326    },
327    #[command(about = "Delete the stored Cloudflare API token")]
328    DeleteKey,
329    #[command(about = "Show whether a Cloudflare API token is configured")]
330    ShowKey {
331        #[arg(long, help = "Print full token value (not masked)")]
332        raw: bool,
333    },
334    #[command(about = "Set the default Cloudflare account ID")]
335    SetAccountId {
336        #[arg(help = "Cloudflare account ID")]
337        account_id: Option<String>,
338    },
339    #[command(about = "Delete the stored default Cloudflare account ID")]
340    DeleteAccountId,
341    #[command(about = "Show whether a Cloudflare account ID is configured")]
342    ShowAccountId {
343        #[arg(long, help = "Print full account ID value (not masked)")]
344        raw: bool,
345    },
346}
347
348#[derive(Args, Debug)]
349pub struct LinearConfigCmd {
350    #[command(subcommand)]
351    pub action: LinearConfigAction,
352}
353
354#[derive(Subcommand, Debug)]
355pub enum LinearConfigAction {
356    #[command(about = "Set Linear API key (omit value to enter it securely)")]
357    SetKey {
358        #[arg(help = "Linear API key/token value")]
359        key: Option<String>,
360    },
361    #[command(about = "Delete the stored Linear API key")]
362    DeleteKey,
363    #[command(about = "Show whether a Linear API key is configured (masked by default)")]
364    Show {
365        #[arg(long, help = "Print full key/token value (not masked)")]
366        raw: bool,
367    },
368    #[command(
369        name = "select-initiative",
370        about = "Pick a Linear initiative for the current repo and save it to .xbp/xbp.yaml"
371    )]
372    SelectInitiative,
373}
374
375#[derive(Args, Debug)]
376pub struct RegistryConfigCmd {
377    #[command(subcommand)]
378    pub action: RegistryConfigAction,
379}
380
381#[derive(Subcommand, Debug)]
382pub enum RegistryConfigAction {
383    #[command(about = "Set registry token/key (omit value to enter it securely)")]
384    SetKey {
385        #[arg(help = "Registry token value")]
386        key: Option<String>,
387    },
388    #[command(about = "Delete the stored registry token")]
389    DeleteKey,
390    #[command(about = "Show whether a registry token is configured (masked by default)")]
391    Show {
392        #[arg(long, help = "Print full token value (not masked)")]
393        raw: bool,
394    },
395    #[command(
396        name = "setup-release",
397        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
398    )]
399    SetupRelease,
400}
401
402#[derive(Args, Debug)]
403pub struct CurlCmd {
404    #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
405    pub url: Option<String>,
406    #[arg(long, help = "Disable the default 15 second timeout")]
407    pub no_timeout: bool,
408}
409
410#[derive(Args, Debug)]
411#[command(subcommand_precedence_over_arg = true)]
412pub struct VersionCmd {
413    #[arg(
414        help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
415    )]
416    pub target: Option<String>,
417    #[arg(
418        short = 'v',
419        long = "version",
420        help = "Explicit version target; equivalent to the positional version value and overrides it when both are provided"
421    )]
422    pub explicit_version: Option<String>,
423    #[arg(long, help = "Show normalized git tags from `git tag --list`")]
424    pub git: bool,
425    #[command(subcommand)]
426    pub command: Option<VersionSubCommand>,
427}
428
429#[derive(Subcommand, Debug)]
430pub enum VersionSubCommand {
431    #[command(
432        about = "Create and push a git tag for this version, then create a GitHub release",
433        visible_alias = "r"
434    )]
435    Release(VersionReleaseCmd),
436    #[command(
437        about = "Manage Rust workspace release/version drift, sync, validation, and publish flow",
438        arg_required_else_help = true
439    )]
440    Workspace(VersionWorkspaceCmd),
441}
442
443#[derive(Args, Debug)]
444pub struct VersionReleaseCmd {
445    #[arg(
446        long,
447        help = "Release this version instead of auto-detecting from tracked files"
448    )]
449    pub version: Option<String>,
450    #[arg(
451        long,
452        help = "Allow releasing with uncommitted changes in the working tree"
453    )]
454    pub allow_dirty: bool,
455    #[arg(long, help = "Release title (defaults to <version> - <repo>)")]
456    pub title: Option<String>,
457    #[arg(long, help = "Release notes body (Markdown)")]
458    pub notes: Option<String>,
459    #[arg(long, help = "Read release notes body from a file")]
460    pub notes_file: Option<PathBuf>,
461    #[arg(long, help = "Create as draft release")]
462    pub draft: bool,
463    #[arg(long, help = "Mark release as pre-release")]
464    pub prerelease: bool,
465    #[arg(
466        long,
467        help = "Run configured npm/crates publish workflows before creating the GitHub release"
468    )]
469    pub publish: bool,
470    #[arg(
471        long,
472        value_enum,
473        default_value_t = VersionReleaseLatest::Legacy,
474        help = "Control GitHub latest flag: true, false, or legacy"
475    )]
476    pub make_latest: VersionReleaseLatest,
477}
478
479#[derive(Copy, Clone, Debug, ValueEnum)]
480pub enum VersionReleaseLatest {
481    True,
482    False,
483    Legacy,
484}
485
486#[derive(Args, Debug)]
487pub struct PublishCmd {
488    #[arg(
489        long,
490        help = "Validate and print what would publish without uploading packages"
491    )]
492    pub dry_run: bool,
493    #[arg(
494        long,
495        help = "Allow publish workflows to run with a dirty working tree"
496    )]
497    pub allow_dirty: bool,
498    #[arg(long, help = "Limit publishing to one target: npm or crates")]
499    pub target: Option<String>,
500}
501
502#[derive(Args, Debug)]
503#[command(
504    after_help = "Examples:\n  xbp version workspace check --repo C:/Users/floris/Documents/GitHub/athena\n  xbp version workspace sync --version 3.16.5\n  xbp version workspace sync --version 3.16.5 --write\n  xbp version workspace validate --cargo-check --package-dry-run\n  xbp version workspace publish plan\n  xbp version workspace publish run --dry-run\n  xbp version workspace publish run --from athena-s3"
505)]
506pub struct VersionWorkspaceCmd {
507    #[command(subcommand)]
508    pub command: VersionWorkspaceSubCommand,
509}
510
511#[derive(Args, Debug, Clone, Default)]
512pub struct VersionWorkspaceTargetArgs {
513    #[arg(
514        long,
515        help = "Workspace repo root to inspect (defaults to current project root)"
516    )]
517    pub repo: Option<PathBuf>,
518    #[arg(long, help = "Emit machine-readable JSON output")]
519    pub json: bool,
520}
521
522#[derive(Subcommand, Debug)]
523pub enum VersionWorkspaceSubCommand {
524    #[command(about = "Detect workspace release drift and exit non-zero when mismatches exist")]
525    Check(VersionWorkspaceCheckCmd),
526    #[command(about = "Preview or apply workspace-wide version alignment")]
527    Sync(VersionWorkspaceSyncCmd),
528    #[command(about = "Run structural and optional cargo validation for workspace publishability")]
529    Validate(VersionWorkspaceValidateCmd),
530    #[command(about = "Plan or execute crates.io publishing for workspace packages")]
531    Publish(VersionWorkspacePublishCmd),
532}
533
534#[derive(Args, Debug)]
535pub struct VersionWorkspaceCheckCmd {
536    #[command(flatten)]
537    pub target: VersionWorkspaceTargetArgs,
538    #[arg(
539        long,
540        help = "Expected release version (defaults to the root package version)"
541    )]
542    pub version: Option<String>,
543}
544
545#[derive(Args, Debug)]
546pub struct VersionWorkspaceSyncCmd {
547    #[command(flatten)]
548    pub target: VersionWorkspaceTargetArgs,
549    #[arg(
550        long,
551        help = "Target release version (defaults to the root package version)"
552    )]
553    pub version: Option<String>,
554    #[arg(
555        long,
556        help = "Write changes to disk instead of previewing the sync plan"
557    )]
558    pub write: bool,
559}
560
561#[derive(Args, Debug)]
562pub struct VersionWorkspaceValidateCmd {
563    #[command(flatten)]
564    pub target: VersionWorkspaceTargetArgs,
565    #[arg(long, help = "Limit cargo validation to a single package name")]
566    pub package: Option<String>,
567    #[arg(long, help = "Run `cargo check -q` as part of validation")]
568    pub cargo_check: bool,
569    #[arg(
570        long,
571        help = "Run `cargo publish --dry-run --locked` for publishable packages"
572    )]
573    pub package_dry_run: bool,
574}
575
576#[derive(Args, Debug)]
577#[command(arg_required_else_help = true)]
578pub struct VersionWorkspacePublishCmd {
579    #[command(subcommand)]
580    pub command: VersionWorkspacePublishSubCommand,
581}
582
583#[derive(Subcommand, Debug)]
584pub enum VersionWorkspacePublishSubCommand {
585    #[command(about = "Show publish order, crates.io visibility, and blockers without publishing")]
586    Plan(VersionWorkspacePublishPlanCmd),
587    #[command(about = "Publish workspace packages in dependency order")]
588    Run(VersionWorkspacePublishRunCmd),
589}
590
591#[derive(Args, Debug)]
592pub struct VersionWorkspacePublishPlanCmd {
593    #[command(flatten)]
594    pub target: VersionWorkspaceTargetArgs,
595}
596
597#[derive(Args, Debug)]
598pub struct VersionWorkspacePublishRunCmd {
599    #[command(flatten)]
600    pub target: VersionWorkspaceTargetArgs,
601    #[arg(long, help = "Preview publish actions without calling cargo publish")]
602    pub dry_run: bool,
603    #[arg(
604        long,
605        help = "Start publishing from this package in the computed order"
606    )]
607    pub from: Option<String>,
608    #[arg(long, help = "Publish only this package")]
609    pub only: Option<String>,
610    #[arg(long, help = "Continue publishing remaining packages after a failure")]
611    pub continue_on_error: bool,
612    #[arg(long, help = "Allow publishing from a dirty worktree")]
613    pub allow_dirty: bool,
614    #[arg(
615        long,
616        default_value_t = 180.0,
617        help = "How long to wait for each published version to become visible on crates.io"
618    )]
619    pub timeout_seconds: f64,
620    #[arg(
621        long,
622        default_value_t = 5.0,
623        help = "How often to poll crates.io for the just-published version"
624    )]
625    pub poll_interval_seconds: f64,
626}
627
628#[derive(Args, Debug)]
629pub struct RedeployV2Cmd {
630    #[arg(short = 'p', long = "password")]
631    pub password: Option<String>,
632    #[arg(short = 'u', long = "username")]
633    pub username: Option<String>,
634    #[arg(short = 'h', long = "host")]
635    pub host: Option<String>,
636    #[arg(short = 'd', long = "project-dir")]
637    pub project_dir: Option<String>,
638}
639
640#[derive(Args, Debug)]
641pub struct LogsCmd {
642    #[arg()]
643    pub project: Option<String>,
644    #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
645    pub ssh_host: Option<String>,
646    #[arg(long = "ssh-username", help = "SSH username for remote host")]
647    pub ssh_username: Option<String>,
648    #[arg(long = "ssh-password", help = "SSH password for remote host")]
649    pub ssh_password: Option<String>,
650}
651
652#[derive(Args, Debug)]
653pub struct SshCmd {
654    #[arg(long = "host", alias = "ssh-host", help = "SSH host or IP address")]
655    pub ssh_host: Option<String>,
656    #[arg(
657        long = "port",
658        default_value_t = 22,
659        help = "SSH port for direct connections"
660    )]
661    pub ssh_port: u16,
662    #[arg(
663        long = "username",
664        alias = "ssh-username",
665        help = "SSH username for the remote host"
666    )]
667    pub ssh_username: Option<String>,
668    #[arg(
669        long = "password",
670        alias = "ssh-password",
671        help = "SSH password (omit to use stored config or a secure prompt)"
672    )]
673    pub ssh_password: Option<String>,
674    #[arg(
675        long,
676        help = "Path to a private key file to use instead of password auth"
677    )]
678    pub private_key: Option<PathBuf>,
679    #[arg(long, help = "Passphrase for --private-key when required")]
680    pub private_key_passphrase: Option<String>,
681    #[arg(
682        long,
683        help = "Run this remote command in a PTY instead of opening the default login shell"
684    )]
685    pub command: Option<String>,
686    #[arg(
687        long,
688        help = "TERM value sent to the server (default: TERM env var or xterm-256color)"
689    )]
690    pub term: Option<String>,
691    #[arg(long, help = "Disable SSH host key verification")]
692    pub no_host_key_check: bool,
693    #[arg(
694        long,
695        help = "Pin the SSH host key as a base64 blob when using tunnels or first-connect flows"
696    )]
697    pub host_key: Option<String>,
698    #[arg(
699        long,
700        help = "Path to a known_hosts file used for SSH host verification"
701    )]
702    pub known_hosts_file: Option<PathBuf>,
703    #[arg(
704        long,
705        help = "Cloudflare Access hostname used to open a local cloudflared TCP forwarder"
706    )]
707    pub cloudflared_hostname: Option<String>,
708    #[arg(long, help = "Override the cloudflared binary path")]
709    pub cloudflared_binary: Option<PathBuf>,
710    #[arg(
711        long,
712        help = "Optional destination host:port passed to cloudflared access tcp"
713    )]
714    pub cloudflared_destination: Option<String>,
715}
716
717#[derive(Args, Debug)]
718#[command(arg_required_else_help = true)]
719pub struct CloudflaredCmd {
720    #[command(subcommand)]
721    pub command: CloudflaredSubCommand,
722}
723
724#[derive(Subcommand, Debug)]
725pub enum CloudflaredSubCommand {
726    #[command(about = "Start a local cloudflared Access TCP forwarder")]
727    Tcp(CloudflaredTcpCmd),
728}
729
730#[derive(Args, Debug)]
731#[command(
732    after_help = "Examples:\n  xbp cloudflared tcp --hostname bastion.example.com\n  xbp cloudflared tcp --hostname bastion.example.com --listener 127.0.0.1:2222\n  xbp cloudflared tcp --hostname bastion.example.com --destination ssh.internal:22"
733)]
734pub struct CloudflaredTcpCmd {
735    #[arg(long, help = "Protected Cloudflare Access hostname")]
736    pub hostname: Option<String>,
737    #[arg(
738        long,
739        help = "Local listener address for the forwarder (default: auto-allocate 127.0.0.1:<port>)"
740    )]
741    pub listener: Option<String>,
742    #[arg(
743        long,
744        help = "Optional destination host:port passed to cloudflared access tcp"
745    )]
746    pub destination: Option<String>,
747    #[arg(long, help = "Override the cloudflared binary path")]
748    pub binary: Option<PathBuf>,
749}
750
751#[derive(Args, Debug)]
752pub struct NginxCmd {
753    #[command(subcommand)]
754    pub command: NginxSubCommand,
755}
756
757#[derive(Args, Debug)]
758pub struct NetworkCmd {
759    #[command(subcommand)]
760    pub command: NetworkSubCommand,
761}
762
763#[derive(Subcommand, Debug)]
764pub enum NetworkSubCommand {
765    #[command(about = "Manage persistent floating IP configuration")]
766    FloatingIp(NetworkFloatingIpCmd),
767    #[command(about = "Inspect discovered network configuration sources")]
768    Config(NetworkConfigCmd),
769    #[command(about = "Manage Hetzner-specific Linux network configuration")]
770    Hetzner(NetworkHetznerCmd),
771}
772
773#[derive(Args, Debug)]
774pub struct NetworkFloatingIpCmd {
775    #[command(subcommand)]
776    pub command: NetworkFloatingIpSubCommand,
777}
778
779#[derive(Subcommand, Debug)]
780pub enum NetworkFloatingIpSubCommand {
781    #[command(about = "Add a persistent floating IP entry to detected network backend")]
782    Add {
783        #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
784        ip: String,
785        #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
786        cidr: Option<u8>,
787        #[arg(long, help = "Network interface override (auto-detected when omitted)")]
788        interface: Option<String>,
789        #[arg(long, help = "Optional label for backend metadata/file naming")]
790        label: Option<String>,
791        #[arg(long, help = "Apply network changes after writing config")]
792        apply: bool,
793        #[arg(long, help = "Preview computed changes without writing files")]
794        dry_run: bool,
795    },
796    #[command(about = "List floating IPs from runtime and persisted network config")]
797    List {
798        #[arg(long, help = "Emit JSON output")]
799        json: bool,
800    },
801}
802
803#[derive(Args, Debug)]
804pub struct NetworkConfigCmd {
805    #[command(subcommand)]
806    pub command: NetworkConfigSubCommand,
807}
808
809#[derive(Subcommand, Debug)]
810pub enum NetworkConfigSubCommand {
811    #[command(about = "List detected backend and configuration source files")]
812    List {
813        #[arg(long, help = "Emit JSON output")]
814        json: bool,
815    },
816}
817
818#[derive(Args, Debug)]
819pub struct NetworkHetznerCmd {
820    #[command(subcommand)]
821    pub command: NetworkHetznerSubCommand,
822}
823
824#[derive(Subcommand, Debug)]
825pub enum NetworkHetznerSubCommand {
826    #[command(about = "Configure a Hetzner vSwitch VLAN interface persistently")]
827    Vswitch(NetworkHetznerVswitchCmd),
828}
829
830#[derive(Args, Debug)]
831pub struct NetworkHetznerVswitchCmd {
832    #[command(subcommand)]
833    pub command: NetworkHetznerVswitchSubCommand,
834}
835
836#[derive(Subcommand, Debug)]
837pub enum NetworkHetznerVswitchSubCommand {
838    #[command(about = "Write persistent Linux config for a Hetzner vSwitch VLAN interface")]
839    Setup {
840        #[arg(
841            long,
842            help = "Private IPv4 address to assign on the vSwitch VLAN interface"
843        )]
844        ip: String,
845        #[arg(
846            long,
847            default_value_t = 24,
848            help = "CIDR prefix for --ip (default: 24)"
849        )]
850        cidr: u8,
851        #[arg(long, help = "Physical parent interface (auto-detected when omitted)")]
852        interface: Option<String>,
853        #[arg(long, help = "Hetzner vSwitch VLAN ID")]
854        vlan_id: u16,
855        #[arg(long, default_value_t = 1400, help = "Interface MTU (default: 1400)")]
856        mtu: u16,
857        #[arg(
858            long,
859            default_value = "10.0.3.1",
860            help = "Gateway for the routed Hetzner cloud network"
861        )]
862        gateway: String,
863        #[arg(
864            long,
865            default_value = "10.0.0.0/16",
866            help = "Destination CIDR routed through the Hetzner vSwitch gateway"
867        )]
868        route_cidr: String,
869        #[arg(long, help = "Apply or activate the new config immediately")]
870        apply: bool,
871        #[arg(long, help = "Preview file changes without writing them")]
872        dry_run: bool,
873    },
874}
875
876#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
877pub enum NginxDnsMode {
878    Manual,
879    Plugin,
880}
881
882#[derive(Subcommand, Debug)]
883pub enum NginxSubCommand {
884    #[command(
885        about = "Provision an HTTPS NGINX reverse proxy with Certbot",
886        long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
887and write final HTTP->HTTPS redirect + TLS proxy config.\n\
888\n\
889Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
890Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
891with --dns-plugin and --dns-creds for non-interactive provider automation."
892    )]
893    Setup {
894        #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
895        domain: String,
896        #[arg(short, long, help = "Port to proxy to")]
897        port: u16,
898        #[arg(
899            short,
900            long,
901            help = "Email used for Let's Encrypt account registration"
902        )]
903        email: String,
904        #[arg(
905            long,
906            value_enum,
907            default_value_t = NginxDnsMode::Manual,
908            help = "DNS challenge mode for wildcard certificates: manual or plugin"
909        )]
910        dns_mode: NginxDnsMode,
911        #[arg(
912            long,
913            help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
914        )]
915        dns_plugin: Option<String>,
916        #[arg(
917            long,
918            help = "Path to DNS plugin credentials file for --dns-mode plugin"
919        )]
920        dns_creds: Option<PathBuf>,
921        #[arg(
922            long,
923            default_value_t = true,
924            action = clap::ArgAction::Set,
925            value_parser = clap::builder::BoolishValueParser::new(),
926            help = "For wildcard domains, also request the base domain certificate (true|false)"
927        )]
928        include_base: bool,
929    },
930    #[command(about = "List discovered NGINX sites with listen/upstream ports")]
931    List,
932    #[command(about = "Show full NGINX config for one domain or all domains")]
933    Show {
934        #[arg(help = "Optional domain name to inspect")]
935        domain: Option<String>,
936    },
937    #[command(about = "Open an NGINX site config in your configured editor")]
938    Edit {
939        #[arg(help = "Domain name to edit")]
940        domain: String,
941    },
942    #[command(about = "Update upstream port for an existing NGINX site")]
943    Update {
944        #[arg(short, long, help = "Domain name to update")]
945        domain: String,
946        #[arg(short, long, help = "New port to proxy to")]
947        port: u16,
948    },
949}
950
951#[derive(Args, Debug)]
952pub struct DiagCmd {
953    #[arg(long, help = "Check Nginx configuration")]
954    pub nginx: bool,
955    #[arg(long, help = "Check specific ports (comma-separated)")]
956    pub ports: Option<String>,
957    #[arg(long, help = "Skip internet speed test")]
958    pub no_speed_test: bool,
959    #[arg(
960        long,
961        help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
962    )]
963    pub compose_file: Option<String>,
964}
965
966#[derive(Args, Debug)]
967pub struct MonitorCmd {
968    #[command(subcommand)]
969    pub command: Option<MonitorSubCommand>,
970}
971
972#[derive(Subcommand, Debug)]
973pub enum MonitorSubCommand {
974    Check,
975    Start,
976}
977
978#[cfg(feature = "monitoring")]
979#[derive(Args, Debug)]
980pub struct MonitoringCmd {
981    #[command(subcommand)]
982    pub command: MonitoringSubCommand,
983}
984
985#[cfg(feature = "monitoring")]
986#[derive(Subcommand, Debug)]
987pub enum MonitoringSubCommand {
988    Serve {
989        #[arg(
990            short,
991            long,
992            default_value = "prodzilla.yml",
993            help = "Monitoring config file"
994        )]
995        file: String,
996    },
997    RunOnce {
998        #[arg(
999            short,
1000            long,
1001            default_value = "prodzilla.yml",
1002            help = "Monitoring config file"
1003        )]
1004        file: String,
1005        #[arg(long, help = "Run probes only")]
1006        probes_only: bool,
1007        #[arg(long, help = "Run stories only")]
1008        stories_only: bool,
1009    },
1010    List {
1011        #[arg(
1012            short,
1013            long,
1014            default_value = "prodzilla.yml",
1015            help = "Monitoring config file"
1016        )]
1017        file: String,
1018    },
1019}
1020
1021#[derive(Args, Debug)]
1022#[command(
1023    arg_required_else_help = true,
1024    after_help = "Examples:\n  xbp api install --port 8080\n  xbp api health\n  xbp api projects list\n  xbp api daemons list\n  xbp api jobs list --status queued\n  xbp api routes list --base-url http://127.0.0.1:8080\n  xbp api request /api/registry/installers/python-pip --web\n\nUse `--web` to target the hosted xbp.app origin instead of API_XBP_URL."
1025)]
1026pub struct ApiCmd {
1027    #[command(subcommand)]
1028    pub command: ApiSubCommand,
1029}
1030
1031#[derive(Args, Debug, Clone, Default)]
1032pub struct ApiTargetOptions {
1033    #[arg(long, help = "Override the request base URL for this command")]
1034    pub base_url: Option<String>,
1035    #[arg(
1036        long,
1037        help = "Target the hosted web origin (xbp.app) instead of the configured API_XBP_URL base"
1038    )]
1039    pub web: bool,
1040    #[arg(
1041        long,
1042        help = "Skip bearer token auth even when XBP_API_TOKEN is configured"
1043    )]
1044    pub no_auth: bool,
1045    #[arg(
1046        long,
1047        help = "Extra header in 'Name: Value' format",
1048        value_name = "HEADER"
1049    )]
1050    pub header: Vec<String>,
1051    #[arg(long, help = "Print response headers")]
1052    pub include_headers: bool,
1053    #[arg(
1054        long,
1055        help = "Print the response body as-is without JSON pretty formatting"
1056    )]
1057    pub raw: bool,
1058}
1059
1060#[cfg(feature = "docker")]
1061#[derive(Args, Debug)]
1062pub struct DockerCmd {
1063    #[arg(
1064        trailing_var_arg = true,
1065        allow_hyphen_values = true,
1066        help = "Arguments to pass directly to the Docker CLI (default: --help)"
1067    )]
1068    pub args: Vec<String>,
1069}
1070
1071#[derive(Subcommand, Debug)]
1072pub enum ApiSubCommand {
1073    #[command(about = "Install and enable the local xbp-api.service on Linux/systemd")]
1074    Install {
1075        #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
1076        port: u16,
1077    },
1078    #[command(about = "Call the XBP API health endpoint")]
1079    Health(ApiHealthCmd),
1080    #[command(about = "Manage XBP control-plane projects")]
1081    Projects(ApiProjectsCmd),
1082    #[command(about = "Manage XBP daemon registrations and heartbeats")]
1083    Daemons(ApiDaemonsCmd),
1084    #[command(about = "Manage XBP deployment jobs")]
1085    Jobs(ApiJobsCmd),
1086    #[command(about = "Manage XBP proxy routes on the local API server")]
1087    Routes(ApiRoutesCmd),
1088    #[command(about = "Send an authenticated HTTP request to the configured XBP API surface")]
1089    Request(ApiRequestCmd),
1090}
1091
1092#[derive(Args, Debug)]
1093pub struct ApiHealthCmd {
1094    #[command(flatten)]
1095    pub target: ApiTargetOptions,
1096}
1097
1098#[derive(Args, Debug)]
1099pub struct ApiProjectsCmd {
1100    #[command(subcommand)]
1101    pub command: ApiProjectsSubCommand,
1102}
1103
1104#[derive(Subcommand, Debug)]
1105pub enum ApiProjectsSubCommand {
1106    #[command(about = "List projects from the XBP control-plane API")]
1107    List(ApiProjectsListCmd),
1108    #[command(about = "Create or upsert a control-plane project")]
1109    Create(Box<ApiProjectsCreateCmd>),
1110}
1111
1112#[derive(Args, Debug)]
1113pub struct ApiProjectsListCmd {
1114    #[arg(long, help = "Optional organization ID filter")]
1115    pub organization_id: Option<String>,
1116    #[command(flatten)]
1117    pub target: ApiTargetOptions,
1118}
1119
1120#[derive(Args, Debug)]
1121pub struct ApiProjectsCreateCmd {
1122    #[arg(long, help = "Project name")]
1123    pub name: String,
1124    #[arg(long, help = "Project path or repo path key")]
1125    pub path: String,
1126    #[arg(long, help = "Optional organization ID")]
1127    pub organization_id: Option<String>,
1128    #[arg(long, help = "Optional project slug")]
1129    pub slug: Option<String>,
1130    #[arg(long, help = "Optional project version")]
1131    pub version: Option<String>,
1132    #[arg(long, help = "Optional build directory")]
1133    pub build_dir: Option<String>,
1134    #[arg(long, help = "Optional runtime enum value")]
1135    pub runtime: Option<String>,
1136    #[arg(long, help = "Optional default branch")]
1137    pub default_branch: Option<String>,
1138    #[arg(long, help = "Optional repository root directory")]
1139    pub root_directory: Option<String>,
1140    #[arg(long, help = "Optional build command")]
1141    pub build_command: Option<String>,
1142    #[arg(long, help = "Optional install command")]
1143    pub install_command: Option<String>,
1144    #[arg(long, help = "Optional start command")]
1145    pub start_command: Option<String>,
1146    #[arg(long, help = "Optional output directory")]
1147    pub output_directory: Option<String>,
1148    #[arg(long, help = "Repository JSON payload matching GitRepositoryRef")]
1149    pub repository_json: Option<String>,
1150    #[arg(long, help = "Runtime policy JSON payload")]
1151    pub runtime_policy_json: Option<String>,
1152    #[arg(long, help = "Metadata JSON object")]
1153    pub metadata_json: Option<String>,
1154    #[command(flatten)]
1155    pub target: ApiTargetOptions,
1156}
1157
1158#[derive(Args, Debug)]
1159pub struct ApiDaemonsCmd {
1160    #[command(subcommand)]
1161    pub command: ApiDaemonsSubCommand,
1162}
1163
1164#[derive(Subcommand, Debug)]
1165pub enum ApiDaemonsSubCommand {
1166    #[command(about = "List registered daemons")]
1167    List(ApiDaemonsListCmd),
1168    #[command(about = "Register or upsert a daemon record")]
1169    Register(ApiDaemonsRegisterCmd),
1170    #[command(about = "Post a heartbeat update for a daemon")]
1171    Heartbeat(ApiDaemonsHeartbeatCmd),
1172    #[command(about = "Update daemon status only")]
1173    UpdateStatus(ApiDaemonsUpdateStatusCmd),
1174}
1175
1176#[derive(Args, Debug)]
1177pub struct ApiDaemonsListCmd {
1178    #[command(flatten)]
1179    pub target: ApiTargetOptions,
1180}
1181
1182#[derive(Args, Debug)]
1183pub struct ApiDaemonsRegisterCmd {
1184    #[arg(long, help = "Daemon node name")]
1185    pub node_name: String,
1186    #[arg(long, help = "Daemon hostname")]
1187    pub hostname: String,
1188    #[arg(long, help = "Daemon binary version")]
1189    pub version: String,
1190    #[arg(long, help = "Optional region")]
1191    pub region: Option<String>,
1192    #[arg(long, help = "Optional public IP")]
1193    pub public_ip: Option<String>,
1194    #[arg(long, help = "Optional internal IP")]
1195    pub internal_ip: Option<String>,
1196    #[arg(long, help = "Optional status enum value")]
1197    pub status: Option<String>,
1198    #[arg(long, help = "Optional CPU core count")]
1199    pub cpu_cores: Option<i32>,
1200    #[arg(long, help = "Optional total memory in MB")]
1201    pub memory_total_mb: Option<i32>,
1202    #[arg(long, help = "Optional total disk in GB")]
1203    pub disk_total_gb: Option<i32>,
1204    #[arg(long, help = "Labels JSON object")]
1205    pub labels_json: Option<String>,
1206    #[arg(long, help = "Metadata JSON object")]
1207    pub metadata_json: Option<String>,
1208    #[command(flatten)]
1209    pub target: ApiTargetOptions,
1210}
1211
1212#[derive(Args, Debug)]
1213pub struct ApiDaemonsHeartbeatCmd {
1214    #[arg(help = "Daemon ID")]
1215    pub daemon_id: String,
1216    #[arg(long, help = "Optional status enum value")]
1217    pub status: Option<String>,
1218    #[arg(long, help = "Optional daemon version")]
1219    pub version: Option<String>,
1220    #[arg(long, help = "Optional public IP")]
1221    pub public_ip: Option<String>,
1222    #[arg(long, help = "Optional internal IP")]
1223    pub internal_ip: Option<String>,
1224    #[arg(long, help = "Optional CPU core count")]
1225    pub cpu_cores: Option<i32>,
1226    #[arg(long, help = "Optional total memory in MB")]
1227    pub memory_total_mb: Option<i32>,
1228    #[arg(long, help = "Optional total disk in GB")]
1229    pub disk_total_gb: Option<i32>,
1230    #[arg(long, help = "Labels JSON object")]
1231    pub labels_json: Option<String>,
1232    #[command(flatten)]
1233    pub target: ApiTargetOptions,
1234}
1235
1236#[derive(Args, Debug)]
1237pub struct ApiDaemonsUpdateStatusCmd {
1238    #[arg(help = "Daemon ID")]
1239    pub daemon_id: String,
1240    #[arg(long, help = "Daemon status enum value")]
1241    pub status: String,
1242    #[command(flatten)]
1243    pub target: ApiTargetOptions,
1244}
1245
1246#[derive(Args, Debug)]
1247pub struct ApiJobsCmd {
1248    #[command(subcommand)]
1249    pub command: ApiJobsSubCommand,
1250}
1251
1252#[derive(Subcommand, Debug)]
1253pub enum ApiJobsSubCommand {
1254    #[command(about = "List deployment jobs")]
1255    List(ApiJobsListCmd),
1256    #[command(about = "Create a deployment job for a project")]
1257    Create(ApiJobsCreateCmd),
1258    #[command(about = "Claim the next deployment job for a daemon")]
1259    Claim(ApiJobsClaimCmd),
1260    #[command(about = "Update deployment job status")]
1261    Update(ApiJobsUpdateCmd),
1262}
1263
1264#[derive(Args, Debug)]
1265pub struct ApiJobsListCmd {
1266    #[arg(long, help = "Optional project ID filter")]
1267    pub project_id: Option<String>,
1268    #[arg(long, help = "Optional deployment ID filter")]
1269    pub deployment_id: Option<String>,
1270    #[arg(long, help = "Optional daemon ID filter")]
1271    pub daemon_id: Option<String>,
1272    #[arg(long, help = "Optional status filter")]
1273    pub status: Option<String>,
1274    #[arg(long, help = "Optional result limit")]
1275    pub limit: Option<usize>,
1276    #[command(flatten)]
1277    pub target: ApiTargetOptions,
1278}
1279
1280#[derive(Args, Debug)]
1281pub struct ApiJobsCreateCmd {
1282    #[arg(long, help = "Project ID")]
1283    pub project_id: String,
1284    #[arg(long, help = "Deployment ID")]
1285    pub deployment_id: String,
1286    #[arg(long, help = "Optional daemon ID assignment")]
1287    pub daemon_id: Option<String>,
1288    #[arg(long, help = "Optional priority")]
1289    pub priority: Option<i32>,
1290    #[arg(long, help = "Optional max attempts")]
1291    pub max_attempts: Option<i32>,
1292    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
1293    pub run_after: Option<String>,
1294    #[arg(long, help = "Optional payload JSON object")]
1295    pub payload_json: Option<String>,
1296    #[command(flatten)]
1297    pub target: ApiTargetOptions,
1298}
1299
1300#[derive(Args, Debug)]
1301pub struct ApiJobsClaimCmd {
1302    #[arg(long, help = "Daemon ID claiming work")]
1303    pub daemon_id: String,
1304    #[arg(long, help = "Optional lock owner")]
1305    pub locked_by: Option<String>,
1306    #[command(flatten)]
1307    pub target: ApiTargetOptions,
1308}
1309
1310#[derive(Args, Debug)]
1311pub struct ApiJobsUpdateCmd {
1312    #[arg(help = "Deployment job ID")]
1313    pub job_id: String,
1314    #[arg(long, help = "Deployment job status enum value")]
1315    pub status: String,
1316    #[arg(long, help = "Optional error text")]
1317    pub error_text: Option<String>,
1318    #[command(flatten)]
1319    pub target: ApiTargetOptions,
1320}
1321
1322#[derive(Args, Debug)]
1323pub struct ApiRoutesCmd {
1324    #[command(subcommand)]
1325    pub command: ApiRoutesSubCommand,
1326}
1327
1328#[derive(Subcommand, Debug)]
1329pub enum ApiRoutesSubCommand {
1330    #[command(about = "List configured proxy routes")]
1331    List(ApiRoutesListCmd),
1332    #[command(about = "Create or replace a proxy route")]
1333    Create(ApiRoutesCreateCmd),
1334    #[command(about = "Delete a proxy route by domain")]
1335    Delete(ApiRoutesDeleteCmd),
1336}
1337
1338#[derive(Args, Debug)]
1339pub struct ApiRoutesListCmd {
1340    #[command(flatten)]
1341    pub target: ApiTargetOptions,
1342}
1343
1344#[derive(Args, Debug)]
1345pub struct ApiRoutesCreateCmd {
1346    #[arg(long, help = "Domain name for the route")]
1347    pub domain: String,
1348    #[arg(long, help = "Upstream target URL", required = true)]
1349    pub target: Vec<String>,
1350    #[arg(
1351        long,
1352        help = "Weighted upstream target in url=weight form",
1353        value_name = "URL=WEIGHT"
1354    )]
1355    pub weighted_target: Vec<String>,
1356    #[arg(long, help = "Optional header condition")]
1357    pub header_condition: Option<String>,
1358    #[arg(long, help = "Optional path prefix condition")]
1359    pub path_prefix: Option<String>,
1360    #[command(flatten)]
1361    pub target_options: ApiTargetOptions,
1362}
1363
1364#[derive(Args, Debug)]
1365pub struct ApiRoutesDeleteCmd {
1366    #[arg(help = "Domain name for the route")]
1367    pub domain: String,
1368    #[command(flatten)]
1369    pub target: ApiTargetOptions,
1370}
1371
1372#[derive(Args, Debug)]
1373pub struct ApiRequestCmd {
1374    #[arg(help = "Request path like /projects or a full https:// URL")]
1375    pub path: String,
1376    #[arg(
1377        short = 'X',
1378        long,
1379        help = "HTTP method to use (default: GET, or POST when a body is provided)"
1380    )]
1381    pub method: Option<String>,
1382    #[arg(short = 'd', long, help = "Inline request body string, typically JSON")]
1383    pub body: Option<String>,
1384    #[arg(long, help = "Read the request body from a file")]
1385    pub body_file: Option<PathBuf>,
1386    #[command(flatten)]
1387    pub target: ApiTargetOptions,
1388}
1389#[derive(Args, Debug)]
1390pub struct TailCmd {
1391    #[arg(long, help = "Tail Kafka topic instead of log files")]
1392    pub kafka: bool,
1393    #[arg(long, help = "Ship logs to Kafka")]
1394    pub ship: bool,
1395}
1396
1397#[derive(Args, Debug)]
1398pub struct GenerateCmd {
1399    #[command(subcommand)]
1400    pub command: GenerateSubCommand,
1401}
1402
1403#[derive(Subcommand, Debug)]
1404pub enum GenerateSubCommand {
1405    #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
1406    Config(GenerateConfigCmd),
1407    Systemd(GenerateSystemdCmd),
1408}
1409
1410#[derive(Args, Debug)]
1411pub struct GenerateConfigCmd {
1412    #[arg(
1413        long,
1414        help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
1415    )]
1416    pub force: bool,
1417    #[arg(
1418        long,
1419        help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
1420    )]
1421    pub update: bool,
1422    #[arg(
1423        long,
1424        help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
1425    )]
1426    pub from_json: Option<PathBuf>,
1427}
1428
1429#[cfg(feature = "secrets")]
1430#[derive(Args, Debug)]
1431pub struct SecretsCmd {
1432    #[arg(long, value_enum, default_value_t = SecretsProviderKind::Github, help = "Secrets provider to use")]
1433    pub provider: SecretsProviderKind,
1434    #[arg(long, help = "GitHub repository override (owner/repo)")]
1435    pub repo: Option<String>,
1436    #[arg(
1437        long,
1438        help = "Provider token override (GitHub token or Cloudflare API token)"
1439    )]
1440    pub token: Option<String>,
1441    #[arg(long, help = "Cloudflare account ID override")]
1442    pub account_id: Option<String>,
1443    #[arg(
1444        long = "environment",
1445        alias = "env",
1446        value_enum,
1447        default_value_t = SecretsEnvironment::XbpDev,
1448        help = "GitHub Actions environment to sync (default: xbp-dev)"
1449    )]
1450    pub environment: SecretsEnvironment,
1451    #[command(subcommand)]
1452    pub command: Option<SecretsSubCommand>,
1453}
1454
1455#[cfg(feature = "secrets")]
1456#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
1457pub enum SecretsProviderKind {
1458    Github,
1459    Cloudflare,
1460}
1461
1462#[cfg(feature = "secrets")]
1463#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
1464pub enum SecretsEnvironment {
1465    #[value(name = "xbp-dev")]
1466    XbpDev,
1467    #[value(name = "xbp-preview")]
1468    XbpPreview,
1469    #[value(name = "xbp-prod")]
1470    XbpProd,
1471}
1472
1473#[cfg(feature = "secrets")]
1474impl SecretsEnvironment {
1475    pub fn as_str(self) -> &'static str {
1476        match self {
1477            Self::XbpDev => "xbp-dev",
1478            Self::XbpPreview => "xbp-preview",
1479            Self::XbpProd => "xbp-prod",
1480        }
1481    }
1482}
1483
1484#[cfg(feature = "secrets")]
1485#[derive(Subcommand, Debug)]
1486pub enum SecretsSubCommand {
1487    /// List available secrets providers
1488    #[command(alias = "ls", alias = "list-providers")]
1489    Providers,
1490    /// List local env vars from the preferred env file
1491    List(ListCmd),
1492    /// Push local env vars to the secrets provider (GitHub)
1493    Push(PushCmd),
1494    /// Pull secrets from the provider into .env.local
1495    Pull(PullCmd),
1496    /// Generate .env.default from source code inspection
1497    GenerateDefault(GenerateDefaultCmd),
1498    /// Generate .env.example with categories and defaults
1499    GenerateExample(GenerateExampleCmd),
1500    /// Compare local env with remote (GitHub) variables
1501    Diff,
1502    /// Verify that all required env vars are available locally
1503    Verify,
1504    /// Check connectivity, token scope, and repo access for secrets
1505    #[command(name = "diag", alias = "doctor")]
1506    Diag,
1507    /// Manage Cloudflare secrets stores
1508    Stores(SecretsStoresCmd),
1509    /// Manage Cloudflare secrets in a store
1510    Secrets(CloudflareSecretsCmd),
1511    /// Inspect Cloudflare quota usage
1512    Quota(SecretsQuotaCmd),
1513    /// Show secrets command usage
1514    #[command(name = "usage")]
1515    Usage,
1516}
1517
1518#[cfg(feature = "secrets")]
1519#[derive(Args, Debug)]
1520pub struct ListCmd {
1521    #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
1522    pub file: Option<String>,
1523    #[arg(long, help = "Output format: plain (default) or json")]
1524    pub format: Option<String>,
1525}
1526
1527#[cfg(feature = "secrets")]
1528#[derive(Args, Debug)]
1529pub struct PushCmd {
1530    #[arg(long, help = "Path to env file (default: .env.local/.env)")]
1531    pub file: Option<String>,
1532    #[arg(
1533        long,
1534        help = "Force overwrite existing GitHub Actions environment variables"
1535    )]
1536    pub force: bool,
1537    #[arg(long, help = "Show what would be pushed without making changes")]
1538    pub dry_run: bool,
1539}
1540
1541#[cfg(feature = "secrets")]
1542#[derive(Args, Debug)]
1543pub struct PullCmd {
1544    #[arg(long, help = "Output file path (default: .env.local)")]
1545    pub output: Option<String>,
1546}
1547
1548#[cfg(feature = "secrets")]
1549#[derive(Args, Debug)]
1550pub struct GenerateDefaultCmd {
1551    #[arg(long, help = "Output file path (default: .env.default)")]
1552    pub output: Option<String>,
1553}
1554
1555#[cfg(feature = "secrets")]
1556#[derive(Args, Debug)]
1557pub struct GenerateExampleCmd {
1558    #[arg(long, help = "Output file path (default: .env.example)")]
1559    pub output: Option<String>,
1560    #[arg(long, help = "Remove keys from .env.local not in .env.example")]
1561    pub clean: bool,
1562    #[arg(long, help = "Only include vars matching prefix (repeatable)")]
1563    pub include_prefix: Vec<String>,
1564    #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
1565    pub exclude_prefix: Vec<String>,
1566}
1567
1568#[cfg(feature = "secrets")]
1569#[derive(Args, Debug)]
1570pub struct SecretsStoresCmd {
1571    #[command(subcommand)]
1572    pub command: SecretsStoresSubCommand,
1573}
1574
1575#[cfg(feature = "secrets")]
1576#[derive(Subcommand, Debug)]
1577pub enum SecretsStoresSubCommand {
1578    List(CloudflareSecretsStoreListCmd),
1579    Get(CloudflareSecretsStoreGetCmd),
1580    Create(CloudflareSecretsStoreCreateCmd),
1581    Delete(CloudflareSecretsStoreDeleteCmd),
1582}
1583
1584#[cfg(feature = "secrets")]
1585#[derive(Args, Debug)]
1586pub struct CloudflareSecretsStoreListCmd {}
1587
1588#[cfg(feature = "secrets")]
1589#[derive(Args, Debug)]
1590pub struct CloudflareSecretsStoreGetCmd {
1591    #[arg(long)]
1592    pub store_id: String,
1593}
1594
1595#[cfg(feature = "secrets")]
1596#[derive(Args, Debug)]
1597pub struct CloudflareSecretsStoreCreateCmd {
1598    #[arg(long)]
1599    pub name: String,
1600}
1601
1602#[cfg(feature = "secrets")]
1603#[derive(Args, Debug)]
1604pub struct CloudflareSecretsStoreDeleteCmd {
1605    #[arg(long)]
1606    pub store_id: String,
1607}
1608
1609#[cfg(feature = "secrets")]
1610#[derive(Args, Debug)]
1611pub struct CloudflareSecretsCmd {
1612    #[command(subcommand)]
1613    pub command: CloudflareSecretsSubCommand,
1614}
1615
1616#[cfg(feature = "secrets")]
1617#[derive(Subcommand, Debug)]
1618pub enum CloudflareSecretsSubCommand {
1619    List(CloudflareSecretsListCmd),
1620    Get(CloudflareSecretsGetCmd),
1621    Create(CloudflareSecretsCreateCmd),
1622    Edit(CloudflareSecretsEditCmd),
1623    Delete(CloudflareSecretsDeleteCmd),
1624    #[command(name = "delete-bulk")]
1625    DeleteBulk(CloudflareSecretsBulkDeleteCmd),
1626    Duplicate(CloudflareSecretsDuplicateCmd),
1627}
1628
1629#[cfg(feature = "secrets")]
1630#[derive(Args, Debug)]
1631pub struct CloudflareSecretsListCmd {
1632    #[arg(long)]
1633    pub store_id: String,
1634}
1635
1636#[cfg(feature = "secrets")]
1637#[derive(Args, Debug)]
1638pub struct CloudflareSecretsGetCmd {
1639    #[arg(long)]
1640    pub store_id: String,
1641    #[arg(long)]
1642    pub secret_id: String,
1643}
1644
1645#[cfg(feature = "secrets")]
1646#[derive(Args, Debug)]
1647pub struct CloudflareSecretsCreateCmd {
1648    #[arg(long)]
1649    pub store_id: String,
1650    #[arg(long)]
1651    pub name: String,
1652    #[arg(long)]
1653    pub value: String,
1654    #[arg(long, value_delimiter = ',')]
1655    pub scopes: Vec<String>,
1656    #[arg(long)]
1657    pub comment: Option<String>,
1658}
1659
1660#[cfg(feature = "secrets")]
1661#[derive(Args, Debug)]
1662pub struct CloudflareSecretsEditCmd {
1663    #[arg(long)]
1664    pub store_id: String,
1665    #[arg(long)]
1666    pub secret_id: String,
1667    #[arg(long)]
1668    pub name: Option<String>,
1669    #[arg(long)]
1670    pub value: Option<String>,
1671    #[arg(long, value_delimiter = ',')]
1672    pub scopes: Vec<String>,
1673    #[arg(long)]
1674    pub comment: Option<String>,
1675}
1676
1677#[cfg(feature = "secrets")]
1678#[derive(Args, Debug)]
1679pub struct CloudflareSecretsDeleteCmd {
1680    #[arg(long)]
1681    pub store_id: String,
1682    #[arg(long)]
1683    pub secret_id: String,
1684}
1685
1686#[cfg(feature = "secrets")]
1687#[derive(Args, Debug)]
1688pub struct CloudflareSecretsBulkDeleteCmd {
1689    #[arg(long)]
1690    pub store_id: String,
1691    #[arg(long = "secret-id", required = true)]
1692    pub secret_ids: Vec<String>,
1693}
1694
1695#[cfg(feature = "secrets")]
1696#[derive(Args, Debug)]
1697pub struct CloudflareSecretsDuplicateCmd {
1698    #[arg(long)]
1699    pub store_id: String,
1700    #[arg(long)]
1701    pub secret_id: String,
1702    #[arg(long)]
1703    pub name: String,
1704    #[arg(long, value_delimiter = ',')]
1705    pub scopes: Vec<String>,
1706    #[arg(long)]
1707    pub comment: Option<String>,
1708}
1709
1710#[cfg(feature = "secrets")]
1711#[derive(Args, Debug)]
1712pub struct SecretsQuotaCmd {
1713    #[command(subcommand)]
1714    pub command: SecretsQuotaSubCommand,
1715}
1716
1717#[cfg(feature = "secrets")]
1718#[derive(Subcommand, Debug)]
1719pub enum SecretsQuotaSubCommand {
1720    Get(SecretsQuotaGetCmd),
1721}
1722
1723#[cfg(feature = "secrets")]
1724#[derive(Args, Debug)]
1725pub struct SecretsQuotaGetCmd {}
1726
1727#[derive(Args, Debug)]
1728pub struct DnsCmd {
1729    #[command(subcommand)]
1730    pub command: DnsSubCommand,
1731}
1732
1733#[derive(Subcommand, Debug)]
1734pub enum DnsSubCommand {
1735    #[command(alias = "ls", alias = "list")]
1736    Providers,
1737    Zones(DnsZonesCmd),
1738    Records(DnsRecordsCmd),
1739    Dnssec(DnssecCmd),
1740    Settings(DnsSettingsCmd),
1741}
1742
1743#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
1744pub enum DnsProviderKind {
1745    Cloudflare,
1746    Hetzner,
1747    Vercel,
1748    Custom,
1749}
1750
1751#[derive(Args, Debug)]
1752pub struct DnsZonesCmd {
1753    #[command(subcommand)]
1754    pub command: DnsZonesSubCommand,
1755}
1756
1757#[derive(Subcommand, Debug)]
1758pub enum DnsZonesSubCommand {
1759    List(DnsZoneListCmd),
1760    Get(DnsZoneGetCmd),
1761    Create(DnsZoneCreateCmd),
1762    Edit(DnsZoneEditCmd),
1763    Delete(DnsZoneDeleteCmd),
1764}
1765
1766#[derive(Args, Debug)]
1767pub struct DnsZoneListCmd {
1768    #[arg(long, value_enum)]
1769    pub provider: DnsProviderKind,
1770    #[arg(long)]
1771    pub account_id: Option<String>,
1772    #[arg(long)]
1773    pub account_name: Option<String>,
1774    #[arg(long = "account-name-op")]
1775    pub account_name_op: Option<String>,
1776    #[arg(long)]
1777    pub name: Option<String>,
1778    #[arg(long = "name-op")]
1779    pub name_op: Option<String>,
1780    #[arg(long)]
1781    pub status: Option<String>,
1782    #[arg(long = "type", value_delimiter = ',')]
1783    pub zone_types: Vec<String>,
1784    #[arg(long)]
1785    pub r#match: Option<String>,
1786    #[arg(long)]
1787    pub order: Option<String>,
1788    #[arg(long)]
1789    pub direction: Option<String>,
1790    #[arg(long)]
1791    pub page: Option<u64>,
1792    #[arg(long = "per-page")]
1793    pub per_page: Option<u64>,
1794    #[arg(long)]
1795    pub token: Option<String>,
1796}
1797
1798#[derive(Args, Debug)]
1799pub struct DnsZoneGetCmd {
1800    #[arg(long, value_enum)]
1801    pub provider: DnsProviderKind,
1802    #[arg(long)]
1803    pub zone_id: String,
1804    #[arg(long)]
1805    pub token: Option<String>,
1806}
1807
1808#[derive(Args, Debug)]
1809pub struct DnsZoneCreateCmd {
1810    #[arg(long, value_enum)]
1811    pub provider: DnsProviderKind,
1812    #[arg(long)]
1813    pub name: String,
1814    #[arg(long)]
1815    pub account_id: Option<String>,
1816    #[arg(long)]
1817    pub jump_start: bool,
1818    #[arg(long = "type")]
1819    pub zone_type: Option<String>,
1820    #[arg(long)]
1821    pub token: Option<String>,
1822}
1823
1824#[derive(Args, Debug)]
1825pub struct DnsZoneEditCmd {
1826    #[arg(long, value_enum)]
1827    pub provider: DnsProviderKind,
1828    #[arg(long)]
1829    pub zone_id: String,
1830    #[arg(long)]
1831    pub paused: Option<bool>,
1832    #[arg(long = "type")]
1833    pub zone_type: Option<String>,
1834    #[arg(long = "vanity-name-server")]
1835    pub vanity_name_servers: Vec<String>,
1836    #[arg(long)]
1837    pub token: Option<String>,
1838}
1839
1840#[derive(Args, Debug)]
1841pub struct DnsZoneDeleteCmd {
1842    #[arg(long, value_enum)]
1843    pub provider: DnsProviderKind,
1844    #[arg(long)]
1845    pub zone_id: String,
1846    #[arg(long)]
1847    pub token: Option<String>,
1848}
1849
1850#[derive(Args, Debug)]
1851pub struct DnsRecordsCmd {
1852    #[command(subcommand)]
1853    pub command: DnsRecordsSubCommand,
1854}
1855
1856#[derive(Subcommand, Debug)]
1857pub enum DnsRecordsSubCommand {
1858    List(DnsRecordListCmd),
1859    Get(DnsRecordGetCmd),
1860    Create(DnsRecordCreateCmd),
1861    Replace(DnsRecordReplaceCmd),
1862    Edit(DnsRecordEditCmd),
1863    Delete(DnsRecordDeleteCmd),
1864    Batch(DnsRecordBatchCmd),
1865    Import(DnsRecordImportCmd),
1866    Export(DnsRecordExportCmd),
1867}
1868
1869#[derive(Args, Debug)]
1870pub struct DnsRecordListCmd {
1871    #[arg(long, value_enum)]
1872    pub provider: DnsProviderKind,
1873    #[arg(long)]
1874    pub zone_id: String,
1875    #[arg(long = "type")]
1876    pub record_type: Option<String>,
1877    #[arg(long)]
1878    pub name: Option<String>,
1879    #[arg(long)]
1880    pub page: Option<u64>,
1881    #[arg(long = "per-page")]
1882    pub per_page: Option<u64>,
1883    #[arg(long)]
1884    pub token: Option<String>,
1885}
1886
1887#[derive(Args, Debug)]
1888pub struct DnsRecordGetCmd {
1889    #[arg(long, value_enum)]
1890    pub provider: DnsProviderKind,
1891    #[arg(long)]
1892    pub zone_id: String,
1893    #[arg(long)]
1894    pub record_id: String,
1895    #[arg(long)]
1896    pub token: Option<String>,
1897}
1898
1899#[derive(Args, Debug)]
1900pub struct DnsRecordCreateCmd {
1901    #[arg(long, value_enum)]
1902    pub provider: DnsProviderKind,
1903    #[arg(long)]
1904    pub zone_id: String,
1905    #[arg(long = "type")]
1906    pub record_type: String,
1907    #[arg(long)]
1908    pub name: String,
1909    #[arg(long)]
1910    pub content: String,
1911    #[arg(long)]
1912    pub ttl: Option<u32>,
1913    #[arg(long)]
1914    pub proxied: Option<bool>,
1915    #[arg(long)]
1916    pub priority: Option<u32>,
1917    #[arg(long)]
1918    pub comment: Option<String>,
1919    #[arg(long = "tag")]
1920    pub tags: Vec<String>,
1921    #[arg(long = "data-json")]
1922    pub data_json: Option<String>,
1923    #[arg(long = "settings-json")]
1924    pub settings_json: Option<String>,
1925    #[arg(long)]
1926    pub token: Option<String>,
1927}
1928
1929#[derive(Args, Debug)]
1930pub struct DnsRecordReplaceCmd {
1931    #[command(flatten)]
1932    pub common: DnsRecordCreateCmd,
1933    #[arg(long)]
1934    pub record_id: String,
1935}
1936
1937#[derive(Args, Debug)]
1938pub struct DnsRecordEditCmd {
1939    #[arg(long, value_enum)]
1940    pub provider: DnsProviderKind,
1941    #[arg(long)]
1942    pub zone_id: String,
1943    #[arg(long)]
1944    pub record_id: String,
1945    #[arg(long = "type")]
1946    pub record_type: Option<String>,
1947    #[arg(long)]
1948    pub name: Option<String>,
1949    #[arg(long)]
1950    pub content: Option<String>,
1951    #[arg(long)]
1952    pub ttl: Option<u32>,
1953    #[arg(long)]
1954    pub proxied: Option<bool>,
1955    #[arg(long)]
1956    pub priority: Option<u32>,
1957    #[arg(long)]
1958    pub comment: Option<String>,
1959    #[arg(long = "tag")]
1960    pub tags: Vec<String>,
1961    #[arg(long = "data-json")]
1962    pub data_json: Option<String>,
1963    #[arg(long = "settings-json")]
1964    pub settings_json: Option<String>,
1965    #[arg(long)]
1966    pub token: Option<String>,
1967}
1968
1969#[derive(Args, Debug)]
1970pub struct DnsRecordDeleteCmd {
1971    #[arg(long, value_enum)]
1972    pub provider: DnsProviderKind,
1973    #[arg(long)]
1974    pub zone_id: String,
1975    #[arg(long)]
1976    pub record_id: String,
1977    #[arg(long)]
1978    pub token: Option<String>,
1979}
1980
1981#[derive(Args, Debug)]
1982pub struct DnsRecordBatchCmd {
1983    #[arg(long, value_enum)]
1984    pub provider: DnsProviderKind,
1985    #[arg(long)]
1986    pub zone_id: String,
1987    #[arg(long)]
1988    pub input: PathBuf,
1989    #[arg(long)]
1990    pub token: Option<String>,
1991}
1992
1993#[derive(Args, Debug)]
1994pub struct DnsRecordImportCmd {
1995    #[arg(long, value_enum)]
1996    pub provider: DnsProviderKind,
1997    #[arg(long)]
1998    pub zone_id: String,
1999    #[arg(long)]
2000    pub file: PathBuf,
2001    #[arg(long)]
2002    pub token: Option<String>,
2003}
2004
2005#[derive(Args, Debug)]
2006pub struct DnsRecordExportCmd {
2007    #[arg(long, value_enum)]
2008    pub provider: DnsProviderKind,
2009    #[arg(long)]
2010    pub zone_id: String,
2011    #[arg(long)]
2012    pub output: Option<PathBuf>,
2013    #[arg(long)]
2014    pub token: Option<String>,
2015}
2016
2017#[derive(Args, Debug)]
2018pub struct DnssecCmd {
2019    #[command(subcommand)]
2020    pub command: DnssecSubCommand,
2021}
2022
2023#[derive(Subcommand, Debug)]
2024pub enum DnssecSubCommand {
2025    Get(DnssecGetCmd),
2026    Edit(DnssecEditCmd),
2027}
2028
2029#[derive(Args, Debug)]
2030pub struct DnssecGetCmd {
2031    #[arg(long, value_enum)]
2032    pub provider: DnsProviderKind,
2033    #[arg(long)]
2034    pub zone_id: String,
2035    #[arg(long)]
2036    pub token: Option<String>,
2037}
2038
2039#[derive(Args, Debug)]
2040pub struct DnssecEditCmd {
2041    #[arg(long, value_enum)]
2042    pub provider: DnsProviderKind,
2043    #[arg(long)]
2044    pub zone_id: String,
2045    #[arg(long)]
2046    pub status: Option<String>,
2047    #[arg(long = "dnssec-multi-signer")]
2048    pub dnssec_multi_signer: Option<bool>,
2049    #[arg(long = "dnssec-presigned")]
2050    pub dnssec_presigned: Option<bool>,
2051    #[arg(long = "dnssec-use-nsec3")]
2052    pub dnssec_use_nsec3: Option<bool>,
2053    #[arg(long)]
2054    pub token: Option<String>,
2055}
2056
2057#[derive(Args, Debug)]
2058pub struct DnsSettingsCmd {
2059    #[command(subcommand)]
2060    pub command: DnsSettingsSubCommand,
2061}
2062
2063#[derive(Subcommand, Debug)]
2064pub enum DnsSettingsSubCommand {
2065    Get(DnsSettingsGetCmd),
2066    Edit(DnsSettingsEditCmd),
2067}
2068
2069#[derive(Args, Debug)]
2070pub struct DnsSettingsGetCmd {
2071    #[arg(long, value_enum)]
2072    pub provider: DnsProviderKind,
2073    #[arg(long)]
2074    pub zone_id: String,
2075    #[arg(long)]
2076    pub token: Option<String>,
2077}
2078
2079#[derive(Args, Debug)]
2080pub struct DnsSettingsEditCmd {
2081    #[arg(long, value_enum)]
2082    pub provider: DnsProviderKind,
2083    #[arg(long)]
2084    pub zone_id: String,
2085    #[arg(long)]
2086    pub flatten_all_cnames: Option<bool>,
2087    #[arg(long)]
2088    pub foundation_dns: Option<bool>,
2089    #[arg(long)]
2090    pub multi_provider: Option<bool>,
2091    #[arg(long)]
2092    pub ns_ttl: Option<u32>,
2093    #[arg(long)]
2094    pub secondary_overrides: Option<bool>,
2095    #[arg(long)]
2096    pub zone_mode: Option<String>,
2097    #[arg(long = "reference-zone-id")]
2098    pub reference_zone_id: Option<String>,
2099    #[arg(long = "nameservers-type")]
2100    pub nameservers_type: Option<String>,
2101    #[arg(long = "nameservers-ns-set")]
2102    pub nameservers_ns_set: Option<u32>,
2103    #[arg(long = "soa-json")]
2104    pub soa_json: Option<String>,
2105    #[arg(long)]
2106    pub token: Option<String>,
2107}
2108
2109#[derive(Args, Debug)]
2110pub struct DomainsCmd {
2111    #[arg(long, value_enum)]
2112    pub provider: DomainsProviderKind,
2113    #[arg(long)]
2114    pub account_id: Option<String>,
2115    #[arg(long)]
2116    pub token: Option<String>,
2117    #[command(subcommand)]
2118    pub command: DomainsSubCommand,
2119}
2120
2121#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
2122pub enum DomainsProviderKind {
2123    Cloudflare,
2124}
2125
2126#[derive(Subcommand, Debug)]
2127pub enum DomainsSubCommand {
2128    Search(DomainsSearchCmd),
2129    Check(DomainsCheckCmd),
2130    List(DomainsListCmd),
2131}
2132
2133#[derive(Args, Debug)]
2134pub struct DomainsSearchCmd {
2135    #[arg(long)]
2136    pub query: String,
2137    #[arg(long = "extension")]
2138    pub extensions: Vec<String>,
2139    #[arg(long)]
2140    pub limit: Option<usize>,
2141}
2142
2143#[derive(Args, Debug)]
2144pub struct DomainsCheckCmd {
2145    #[arg(long = "domain", required = true)]
2146    pub domains: Vec<String>,
2147}
2148
2149#[derive(Args, Debug)]
2150pub struct DomainsListCmd {}
2151
2152#[derive(Args, Debug)]
2153pub struct GenerateSystemdCmd {
2154    #[arg(
2155        long,
2156        default_value = "/etc/systemd/system",
2157        help = "Directory where the systemd units are written"
2158    )]
2159    pub output_dir: PathBuf,
2160    #[arg(long, help = "Only generate the unit for this service name")]
2161    pub service: Option<String>,
2162    #[arg(
2163        long,
2164        default_value_t = true,
2165        help = "Also generate the xbp-api systemd unit alongside project/services"
2166    )]
2167    pub api: bool,
2168}
2169
2170#[derive(Args, Debug)]
2171pub struct DoneCmd {
2172    #[arg(long, help = "Root directory under which to discover git repos")]
2173    pub root: Option<std::path::PathBuf>,
2174    #[arg(
2175        long,
2176        default_value = "24 hours ago",
2177        help = "Git --since value (e.g. '7 days ago')"
2178    )]
2179    pub since: String,
2180    #[arg(short, long, help = "Output Markdown file path")]
2181    pub output: Option<std::path::PathBuf>,
2182    #[arg(long, help = "Skip AI summarization (OpenRouter)")]
2183    pub no_ai: bool,
2184    #[arg(short, long, help = "Discover repos recursively")]
2185    pub recursive: bool,
2186    #[arg(long, help = "Exclude repo by name (repeatable)")]
2187    pub exclude: Vec<String>,
2188}
2189
2190#[cfg(feature = "nordvpn")]
2191#[derive(Args, Debug)]
2192pub struct NordvpnCmd {
2193    #[arg(
2194        trailing_var_arg = true,
2195        allow_hyphen_values = true,
2196        help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
2197    )]
2198    pub args: Vec<String>,
2199}
2200
2201#[cfg(feature = "kubernetes")]
2202#[derive(Args, Debug)]
2203pub struct KubernetesCmd {
2204    #[command(subcommand)]
2205    pub command: KubernetesSubCommand,
2206}
2207
2208#[cfg(feature = "kubernetes")]
2209#[derive(Args, Debug)]
2210pub struct KubernetesAddonCmd {
2211    #[command(subcommand)]
2212    pub command: KubernetesAddonSubCommand,
2213}
2214
2215#[cfg(feature = "kubernetes")]
2216#[derive(Subcommand, Debug)]
2217pub enum KubernetesAddonSubCommand {
2218    /// Show complete addon status (enabled/disabled) from `microk8s status`
2219    List,
2220    /// Enable a MicroK8s addon
2221    Enable {
2222        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
2223        name: String,
2224    },
2225    /// Disable a MicroK8s addon
2226    Disable {
2227        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
2228        name: String,
2229    },
2230}
2231
2232#[cfg(feature = "kubernetes")]
2233#[derive(Subcommand, Debug)]
2234pub enum KubernetesSubCommand {
2235    /// Validate kubectl, current context, and node readiness
2236    Check {
2237        #[arg(long, help = "Kubeconfig context to target")]
2238        context: Option<String>,
2239        #[arg(
2240            long,
2241            default_value = "default",
2242            help = "Namespace to probe for workload readiness"
2243        )]
2244        namespace: String,
2245        #[arg(long, help = "Skip live cluster calls (tooling check only)")]
2246        offline: bool,
2247    },
2248    /// Generate Deployment/Service/NetworkPolicy YAML
2249    Generate {
2250        #[arg(long, help = "Logical app name (used for resource names)")]
2251        name: String,
2252        #[arg(long, help = "Container image reference")]
2253        image: String,
2254        #[arg(long, default_value_t = 80, help = "Container port for the service")]
2255        port: u16,
2256        #[arg(long, default_value_t = 1, help = "Replica count")]
2257        replicas: u16,
2258        #[arg(
2259            long,
2260            default_value = "default",
2261            help = "Namespace for generated resources"
2262        )]
2263        namespace: String,
2264        #[arg(
2265            long,
2266            default_value = "k8s/xbp-manifest.yaml",
2267            help = "Path to write the manifest bundle"
2268        )]
2269        output: String,
2270        #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
2271        host: Option<String>,
2272    },
2273    /// Apply a manifest bundle with kubectl apply -f
2274    Apply {
2275        #[arg(long, help = "Path to manifest file")]
2276        file: String,
2277        #[arg(long, help = "Override kube context")]
2278        context: Option<String>,
2279        #[arg(long, help = "Override namespace")]
2280        namespace: Option<String>,
2281        #[arg(long, help = "Use --dry-run=server")]
2282        dry_run: bool,
2283    },
2284    /// Summarize deployments/services/pods in a namespace
2285    Status {
2286        #[arg(long, default_value = "default", help = "Namespace to summarize")]
2287        namespace: String,
2288        #[arg(long, help = "Override kube context")]
2289        context: Option<String>,
2290    },
2291    /// Manage MicroK8s addons (list, enable, disable)
2292    Addons(KubernetesAddonCmd),
2293    /// Extract Kubernetes Dashboard login token from secret describe output
2294    DashboardToken {
2295        #[arg(
2296            long,
2297            default_value = "kube-system",
2298            help = "Namespace containing the dashboard token secret"
2299        )]
2300        namespace: String,
2301        #[arg(
2302            long,
2303            default_value = "microk8s-dashboard-token",
2304            help = "Secret name containing the dashboard login token"
2305        )]
2306        secret: String,
2307        #[arg(long, help = "Override kube context")]
2308        context: Option<String>,
2309    },
2310    /// Print decoded Grafana admin credentials from observability secret
2311    ObservabilityCreds {
2312        #[arg(
2313            long,
2314            default_value = "observability",
2315            help = "Namespace containing Grafana secret"
2316        )]
2317        namespace: String,
2318        #[arg(
2319            long,
2320            default_value = "kube-prom-stack-grafana",
2321            help = "Grafana secret name"
2322        )]
2323        secret: String,
2324        #[arg(long, help = "Override kube context")]
2325        context: Option<String>,
2326    },
2327    /// Create or update a cert-manager Issuer for Let's Encrypt
2328    Issuer {
2329        #[arg(
2330            long,
2331            help = "Email used for Let's Encrypt account registration (required)"
2332        )]
2333        email: String,
2334        #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
2335        name: String,
2336        #[arg(
2337            long,
2338            default_value = "default",
2339            help = "Namespace for the Issuer resource"
2340        )]
2341        namespace: String,
2342        #[arg(
2343            long,
2344            default_value = "https://acme-v02.api.letsencrypt.org/directory",
2345            help = "ACME server URL (production by default)"
2346        )]
2347        server: String,
2348        #[arg(
2349            long,
2350            default_value = "letsencrypt-account-key",
2351            help = "Secret used to store the ACME account private key"
2352        )]
2353        private_key_secret: String,
2354        #[arg(
2355            long,
2356            default_value = "nginx",
2357            help = "Ingress class name used for HTTP01 solving"
2358        )]
2359        ingress_class_name: String,
2360        #[arg(long, help = "Override kube context")]
2361        context: Option<String>,
2362        #[arg(long, help = "Use --dry-run=server")]
2363        dry_run: bool,
2364    },
2365}
2366
2367#[cfg(test)]
2368mod tests {
2369    use super::{
2370        Cli, CloudflareConfigAction, CloudflaredSubCommand, Commands, DnsProviderKind,
2371        DnsSubCommand, DnsZonesSubCommand, DomainsProviderKind, DomainsSubCommand,
2372        GenerateSubCommand, LinearConfigAction, NetworkFloatingIpSubCommand,
2373        NetworkHetznerSubCommand, NetworkHetznerVswitchSubCommand, NetworkSubCommand, SshCmd,
2374    };
2375    #[cfg(feature = "secrets")]
2376    use super::{
2377        CloudflareSecretsSubCommand, SecretsEnvironment, SecretsProviderKind,
2378        SecretsStoresSubCommand, SecretsSubCommand,
2379    };
2380    use clap::Parser;
2381    use std::path::PathBuf;
2382
2383    #[test]
2384    fn parses_network_floating_ip_add() {
2385        let cli = Cli::parse_from([
2386            "xbp",
2387            "network",
2388            "floating-ip",
2389            "add",
2390            "--ip",
2391            "1.2.3.4",
2392            "--apply",
2393        ]);
2394
2395        match cli.command {
2396            Some(Commands::Network(network)) => match network.command {
2397                NetworkSubCommand::FloatingIp(fip) => match fip.command {
2398                    NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
2399                        assert_eq!(ip, "1.2.3.4");
2400                        assert!(apply);
2401                    }
2402                    _ => panic!("expected add subcommand"),
2403                },
2404                _ => panic!("expected floating-ip subcommand"),
2405            },
2406            _ => panic!("expected network command"),
2407        }
2408    }
2409
2410    #[test]
2411    fn parses_generate_config_update() {
2412        let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
2413
2414        match cli.command {
2415            Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
2416                GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
2417                _ => panic!("expected generate config command"),
2418            },
2419            _ => panic!("expected generate command"),
2420        }
2421    }
2422
2423    #[test]
2424    fn parses_commit_command_with_dry_run() {
2425        let cli = Cli::parse_from(["xbp", "commit", "--dry-run", "--scope", "cli"]);
2426
2427        match cli.command {
2428            Some(Commands::Commit(commit_cmd)) => {
2429                assert!(commit_cmd.dry_run);
2430                assert_eq!(commit_cmd.scope.as_deref(), Some("cli"));
2431                assert_eq!(commit_cmd.model, "openai/gpt-4o-mini");
2432            }
2433            _ => panic!("expected commit command"),
2434        }
2435    }
2436
2437    #[test]
2438    fn parses_linear_select_initiative_config_command() {
2439        let cli = Cli::parse_from(["xbp", "config", "linear", "select-initiative"]);
2440
2441        match cli.command {
2442            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
2443                Some(super::ConfigProviderCmd::Linear(linear_cmd)) => {
2444                    assert!(matches!(
2445                        linear_cmd.action,
2446                        LinearConfigAction::SelectInitiative
2447                    ));
2448                }
2449                _ => panic!("expected linear config provider"),
2450            },
2451            _ => panic!("expected config command"),
2452        }
2453    }
2454
2455    #[test]
2456    fn parses_ssh_command_with_cloudflared_and_key_auth() {
2457        let cli = Cli::parse_from([
2458            "xbp",
2459            "ssh",
2460            "--host",
2461            "ssh.internal",
2462            "--username",
2463            "deploy",
2464            "--private-key",
2465            "C:/Users/floris/.ssh/id_ed25519",
2466            "--cloudflared-hostname",
2467            "bastion.example.com",
2468            "--command",
2469            "htop",
2470        ]);
2471
2472        let Some(Commands::Ssh(SshCmd {
2473            ssh_host,
2474            ssh_username,
2475            private_key,
2476            cloudflared_hostname,
2477            command,
2478            ..
2479        })) = cli.command
2480        else {
2481            panic!("expected shell command");
2482        };
2483
2484        assert_eq!(ssh_host.as_deref(), Some("ssh.internal"));
2485        assert_eq!(ssh_username.as_deref(), Some("deploy"));
2486        assert_eq!(
2487            private_key,
2488            Some(PathBuf::from("C:/Users/floris/.ssh/id_ed25519"))
2489        );
2490        assert_eq!(cloudflared_hostname.as_deref(), Some("bastion.example.com"));
2491        assert_eq!(command.as_deref(), Some("htop"));
2492    }
2493
2494    #[test]
2495    fn parses_cloudflared_tcp_command() {
2496        let cli = Cli::parse_from([
2497            "xbp",
2498            "cloudflared",
2499            "tcp",
2500            "--hostname",
2501            "bastion.example.com",
2502            "--listener",
2503            "127.0.0.1:2222",
2504        ]);
2505
2506        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
2507            panic!("expected cloudflared command");
2508        };
2509
2510        match cloudflared_cmd.command {
2511            CloudflaredSubCommand::Tcp(tcp_cmd) => {
2512                assert_eq!(tcp_cmd.hostname.as_deref(), Some("bastion.example.com"));
2513                assert_eq!(tcp_cmd.listener.as_deref(), Some("127.0.0.1:2222"));
2514            }
2515        }
2516    }
2517
2518    #[test]
2519    fn parses_cloudflared_tcp_without_hostname_for_handler_validation() {
2520        let cli = Cli::try_parse_from(["xbp", "cloudflared", "tcp"]).expect("parse");
2521
2522        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
2523            panic!("expected cloudflared command");
2524        };
2525
2526        match cloudflared_cmd.command {
2527            CloudflaredSubCommand::Tcp(tcp_cmd) => {
2528                assert_eq!(tcp_cmd.hostname, None);
2529                assert_eq!(tcp_cmd.listener, None);
2530            }
2531        }
2532    }
2533
2534    #[test]
2535    fn parses_version_workspace_publish_run_command() {
2536        let cli = Cli::parse_from([
2537            "xbp",
2538            "version",
2539            "workspace",
2540            "publish",
2541            "run",
2542            "--repo",
2543            "C:/Users/floris/Documents/GitHub/athena",
2544            "--dry-run",
2545            "--from",
2546            "athena-s3",
2547        ]);
2548
2549        let Some(Commands::Version(version_cmd)) = cli.command else {
2550            panic!("expected version command");
2551        };
2552
2553        match version_cmd.command {
2554            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
2555                match workspace_cmd.command {
2556                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
2557                        match publish_cmd.command {
2558                            super::VersionWorkspacePublishSubCommand::Run(run_cmd) => {
2559                                assert_eq!(
2560                                    run_cmd.target.repo,
2561                                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub/athena"))
2562                                );
2563                                assert!(!run_cmd.target.json);
2564                                assert!(run_cmd.dry_run);
2565                                assert_eq!(run_cmd.from.as_deref(), Some("athena-s3"));
2566                            }
2567                            _ => panic!("expected publish run"),
2568                        }
2569                    }
2570                    _ => panic!("expected workspace publish"),
2571                }
2572            }
2573            _ => panic!("expected version workspace command"),
2574        }
2575    }
2576
2577    #[test]
2578    fn parses_commit_alias_with_push_flag() {
2579        let cli = Cli::parse_from(["xbp", "c", "-p"]);
2580
2581        let Some(Commands::Commit(commit_cmd)) = cli.command else {
2582            panic!("expected commit command");
2583        };
2584
2585        assert!(commit_cmd.push);
2586        assert!(!commit_cmd.dry_run);
2587    }
2588
2589    #[test]
2590    fn parses_version_alias_release_alias() {
2591        let cli = Cli::parse_from(["xbp", "v", "r", "--draft"]);
2592
2593        let Some(Commands::Version(version_cmd)) = cli.command else {
2594            panic!("expected version command");
2595        };
2596
2597        let Some(super::VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2598            panic!("expected release subcommand");
2599        };
2600
2601        assert!(release_cmd.draft);
2602    }
2603
2604    #[test]
2605    fn parses_publish_command_target_filter() {
2606        let cli = Cli::parse_from(["xbp", "publish", "--allow-dirty", "--target", "npm"]);
2607
2608        let Some(Commands::Publish(publish_cmd)) = cli.command else {
2609            panic!("expected publish command");
2610        };
2611
2612        assert!(publish_cmd.allow_dirty);
2613        assert_eq!(publish_cmd.target.as_deref(), Some("npm"));
2614    }
2615
2616    #[test]
2617    fn parses_npm_setup_release_config_command() {
2618        let cli = Cli::parse_from(["xbp", "config", "npm", "setup-release"]);
2619
2620        let Some(Commands::Config(config_cmd)) = cli.command else {
2621            panic!("expected config command");
2622        };
2623        let Some(super::ConfigProviderCmd::Npm(registry_cmd)) = config_cmd.provider else {
2624            panic!("expected npm config command");
2625        };
2626
2627        assert!(matches!(
2628            registry_cmd.action,
2629            super::RegistryConfigAction::SetupRelease
2630        ));
2631    }
2632
2633    #[test]
2634    fn parses_shell_alias_as_ssh_command() {
2635        let cli = Cli::parse_from(["xbp", "shell", "--host", "ssh.internal"]);
2636
2637        let Some(Commands::Ssh(ssh_cmd)) = cli.command else {
2638            panic!("expected ssh command through shell alias");
2639        };
2640
2641        assert_eq!(ssh_cmd.ssh_host.as_deref(), Some("ssh.internal"));
2642    }
2643
2644    #[test]
2645    fn parses_api_request_command() {
2646        let cli = Cli::parse_from([
2647            "xbp",
2648            "api",
2649            "request",
2650            "/api/registry/installers/python-pip",
2651            "--web",
2652            "--method",
2653            "GET",
2654            "--header",
2655            "accept: application/json",
2656        ]);
2657
2658        let Some(Commands::Api(api_cmd)) = cli.command else {
2659            panic!("expected api command");
2660        };
2661
2662        match api_cmd.command {
2663            super::ApiSubCommand::Request(request_cmd) => {
2664                assert_eq!(request_cmd.path, "/api/registry/installers/python-pip");
2665                assert!(request_cmd.target.web);
2666                assert_eq!(request_cmd.method.as_deref(), Some("GET"));
2667                assert_eq!(
2668                    request_cmd.target.header,
2669                    vec!["accept: application/json".to_string()]
2670                );
2671            }
2672            _ => panic!("expected api request subcommand"),
2673        }
2674    }
2675
2676    #[test]
2677    fn parses_api_projects_list_command() {
2678        let cli = Cli::parse_from([
2679            "xbp",
2680            "api",
2681            "projects",
2682            "list",
2683            "--organization-id",
2684            "org_123",
2685        ]);
2686
2687        let Some(Commands::Api(api_cmd)) = cli.command else {
2688            panic!("expected api command");
2689        };
2690
2691        match api_cmd.command {
2692            super::ApiSubCommand::Projects(projects_cmd) => match projects_cmd.command {
2693                super::ApiProjectsSubCommand::List(list_cmd) => {
2694                    assert_eq!(list_cmd.organization_id.as_deref(), Some("org_123"));
2695                }
2696                _ => panic!("expected projects list subcommand"),
2697            },
2698            _ => panic!("expected projects subcommand"),
2699        }
2700    }
2701
2702    #[test]
2703    fn parses_api_routes_create_command() {
2704        let cli = Cli::parse_from([
2705            "xbp",
2706            "api",
2707            "routes",
2708            "create",
2709            "--domain",
2710            "demo.local",
2711            "--target",
2712            "http://127.0.0.1:3000",
2713            "--weighted-target",
2714            "http://127.0.0.1:3001=3",
2715            "--base-url",
2716            "http://127.0.0.1:8080",
2717        ]);
2718
2719        let Some(Commands::Api(api_cmd)) = cli.command else {
2720            panic!("expected api command");
2721        };
2722
2723        match api_cmd.command {
2724            super::ApiSubCommand::Routes(routes_cmd) => match routes_cmd.command {
2725                super::ApiRoutesSubCommand::Create(create_cmd) => {
2726                    assert_eq!(create_cmd.domain, "demo.local");
2727                    assert_eq!(create_cmd.target, vec!["http://127.0.0.1:3000".to_string()]);
2728                    assert_eq!(
2729                        create_cmd.weighted_target,
2730                        vec!["http://127.0.0.1:3001=3".to_string()]
2731                    );
2732                    assert_eq!(
2733                        create_cmd.target_options.base_url.as_deref(),
2734                        Some("http://127.0.0.1:8080")
2735                    );
2736                }
2737                _ => panic!("expected routes create subcommand"),
2738            },
2739            _ => panic!("expected routes subcommand"),
2740        }
2741    }
2742
2743    #[test]
2744    fn parses_hetzner_vswitch_setup_command() {
2745        let cli = Cli::parse_from([
2746            "xbp",
2747            "network",
2748            "hetzner",
2749            "vswitch",
2750            "setup",
2751            "--ip",
2752            "10.0.3.2",
2753            "--vlan-id",
2754            "4000",
2755            "--interface",
2756            "enp0s31f6",
2757            "--apply",
2758        ]);
2759
2760        let Some(Commands::Network(network_cmd)) = cli.command else {
2761            panic!("expected network command");
2762        };
2763
2764        match network_cmd.command {
2765            NetworkSubCommand::Hetzner(hetzner_cmd) => match hetzner_cmd.command {
2766                NetworkHetznerSubCommand::Vswitch(vswitch_cmd) => match vswitch_cmd.command {
2767                    NetworkHetznerVswitchSubCommand::Setup {
2768                        ip,
2769                        cidr,
2770                        interface,
2771                        vlan_id,
2772                        apply,
2773                        ..
2774                    } => {
2775                        assert_eq!(ip, "10.0.3.2");
2776                        assert_eq!(cidr, 24);
2777                        assert_eq!(interface.as_deref(), Some("enp0s31f6"));
2778                        assert_eq!(vlan_id, 4000);
2779                        assert!(apply);
2780                    }
2781                },
2782            },
2783            _ => panic!("expected hetzner subcommand"),
2784        }
2785    }
2786
2787    #[cfg(feature = "secrets")]
2788    #[test]
2789    fn parses_secrets_diag_command() {
2790        let cli = Cli::parse_from(["xbp", "secrets", "diag"]);
2791
2792        match cli.command {
2793            Some(Commands::Secrets(secrets_cmd)) => {
2794                assert!(matches!(secrets_cmd.command, Some(SecretsSubCommand::Diag)));
2795                assert!(matches!(
2796                    secrets_cmd.environment,
2797                    SecretsEnvironment::XbpDev
2798                ));
2799            }
2800            _ => panic!("expected secrets command"),
2801        }
2802    }
2803
2804    #[cfg(feature = "secrets")]
2805    #[test]
2806    fn parses_secrets_environment_override() {
2807        let cli = Cli::parse_from(["xbp", "secrets", "--environment", "xbp-prod", "push"]);
2808
2809        match cli.command {
2810            Some(Commands::Secrets(secrets_cmd)) => {
2811                assert!(matches!(
2812                    secrets_cmd.environment,
2813                    SecretsEnvironment::XbpProd
2814                ));
2815                assert!(matches!(
2816                    secrets_cmd.command,
2817                    Some(SecretsSubCommand::Push(_))
2818                ));
2819            }
2820            _ => panic!("expected secrets command"),
2821        }
2822    }
2823
2824    #[cfg(feature = "secrets")]
2825    #[test]
2826    fn parses_secrets_providers_command() {
2827        let cli = Cli::parse_from(["xbp", "secrets", "providers"]);
2828
2829        match cli.command {
2830            Some(Commands::Secrets(secrets_cmd)) => {
2831                assert!(matches!(
2832                    secrets_cmd.command,
2833                    Some(SecretsSubCommand::Providers)
2834                ));
2835                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Github);
2836            }
2837            _ => panic!("expected secrets command"),
2838        }
2839    }
2840
2841    #[cfg(feature = "secrets")]
2842    #[test]
2843    fn parses_cloudflare_secret_store_create() {
2844        let cli = Cli::parse_from([
2845            "xbp",
2846            "secrets",
2847            "--provider",
2848            "cloudflare",
2849            "stores",
2850            "create",
2851            "--name",
2852            "prod",
2853        ]);
2854
2855        match cli.command {
2856            Some(Commands::Secrets(secrets_cmd)) => {
2857                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Cloudflare);
2858                match secrets_cmd.command {
2859                    Some(SecretsSubCommand::Stores(stores_cmd)) => {
2860                        assert!(matches!(
2861                            stores_cmd.command,
2862                            SecretsStoresSubCommand::Create(_)
2863                        ));
2864                    }
2865                    _ => panic!("expected stores subcommand"),
2866                }
2867            }
2868            _ => panic!("expected secrets command"),
2869        }
2870    }
2871
2872    #[cfg(feature = "secrets")]
2873    #[test]
2874    fn parses_cloudflare_secret_duplicate() {
2875        let cli = Cli::parse_from([
2876            "xbp",
2877            "secrets",
2878            "--provider",
2879            "cloudflare",
2880            "secrets",
2881            "duplicate",
2882            "--store-id",
2883            "store_1",
2884            "--secret-id",
2885            "secret_1",
2886            "--name",
2887            "COPY",
2888        ]);
2889
2890        match cli.command {
2891            Some(Commands::Secrets(secrets_cmd)) => match secrets_cmd.command {
2892                Some(SecretsSubCommand::Secrets(secrets_cmd)) => {
2893                    assert!(matches!(
2894                        secrets_cmd.command,
2895                        CloudflareSecretsSubCommand::Duplicate(_)
2896                    ));
2897                }
2898                _ => panic!("expected cloudflare secrets subcommand"),
2899            },
2900            _ => panic!("expected secrets command"),
2901        }
2902    }
2903
2904    #[test]
2905    fn parses_dns_providers_command() {
2906        let cli = Cli::parse_from(["xbp", "dns", "providers"]);
2907
2908        match cli.command {
2909            Some(Commands::Dns(dns_cmd)) => {
2910                assert!(matches!(dns_cmd.command, DnsSubCommand::Providers));
2911            }
2912            _ => panic!("expected dns command"),
2913        }
2914    }
2915
2916    #[test]
2917    fn parses_dns_zone_list_command() {
2918        let cli = Cli::parse_from([
2919            "xbp",
2920            "dns",
2921            "zones",
2922            "list",
2923            "--provider",
2924            "cloudflare",
2925            "--account-name-op",
2926            "contains",
2927            "--type",
2928            "full,partial",
2929        ]);
2930
2931        match cli.command {
2932            Some(Commands::Dns(dns_cmd)) => match dns_cmd.command {
2933                DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
2934                    DnsZonesSubCommand::List(list_cmd) => {
2935                        assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
2936                        assert_eq!(list_cmd.account_name_op.as_deref(), Some("contains"));
2937                        assert_eq!(list_cmd.zone_types, vec!["full", "partial"]);
2938                    }
2939                    _ => panic!("expected dns zones list"),
2940                },
2941                _ => panic!("expected dns zones"),
2942            },
2943            _ => panic!("expected dns command"),
2944        }
2945    }
2946
2947    #[test]
2948    fn parses_domains_search_command() {
2949        let cli = Cli::parse_from([
2950            "xbp",
2951            "domains",
2952            "--provider",
2953            "cloudflare",
2954            "search",
2955            "--query",
2956            "xbp",
2957            "--extension",
2958            "com",
2959        ]);
2960
2961        match cli.command {
2962            Some(Commands::Domains(domains_cmd)) => {
2963                assert_eq!(domains_cmd.provider, DomainsProviderKind::Cloudflare);
2964                assert!(matches!(domains_cmd.command, DomainsSubCommand::Search(_)));
2965            }
2966            _ => panic!("expected domains command"),
2967        }
2968    }
2969
2970    #[test]
2971    fn parses_cloudflare_config_account_id_command() {
2972        let cli = Cli::parse_from(["xbp", "config", "cloudflare", "set-account-id", "acc_123"]);
2973
2974        match cli.command {
2975            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
2976                Some(super::ConfigProviderCmd::Cloudflare(cloudflare_cmd)) => {
2977                    assert!(matches!(
2978                        cloudflare_cmd.action,
2979                        CloudflareConfigAction::SetAccountId { .. }
2980                    ));
2981                }
2982                _ => panic!("expected cloudflare config provider"),
2983            },
2984            _ => panic!("expected config command"),
2985        }
2986    }
2987}