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