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 or inspect the CLI login flow against the XBP dashboard")]
111    Login(LoginCmd),
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(
137        about = "Manage Cloudflare Workers secrets, Wrangler config helpers, D1 migrations, and deploy flows",
138        visible_alias = "worker"
139    )]
140    Workers(WorkersCmd),
141    #[command(about = "Manage DNS providers, zones, records, DNSSEC, and settings")]
142    Dns(DnsCmd),
143    #[command(about = "Discover and inspect registered domains")]
144    Domains(DomainsCmd),
145    #[command(
146        about = "Generate 'what did I get done' Markdown report from git commits across repos"
147    )]
148    Done(DoneCmd),
149    #[command(
150        about = "Repair malformed Cursor process-monitor JSON exports",
151        visible_alias = "fix-pm-json"
152    )]
153    FixProcessMonitorJson(FixProcessMonitorJsonCmd),
154    #[command(about = "Upload Cursor local file history to the XBP dashboard")]
155    Cursor(CursorCmd),
156    #[cfg(feature = "kubernetes")]
157    #[command(about = "Experimental Kubernetes cluster manager (feature-gated)")]
158    Kubernetes(KubernetesCmd),
159    #[cfg(feature = "nordvpn")]
160    #[command(about = "NordVPN meshnet setup and passthrough (feature-gated)")]
161    Nordvpn(NordvpnCmd),
162    #[cfg(feature = "monitoring")]
163    Monitoring(MonitoringCmd),
164    #[command(about = "Manage the XBP API server")]
165    Api(ApiCmd),
166    #[cfg(feature = "docker")]
167    #[command(about = "Pass-through wrapper around the Docker CLI")]
168    Docker(DockerCmd),
169}
170
171pub fn command_label(command: &Commands) -> &'static str {
172    match command {
173        Commands::Ports(_) => "ports",
174        Commands::Commit(_) => "commit",
175        Commands::Init => "init",
176        Commands::Setup => "setup",
177        Commands::Redeploy { .. } => "redeploy",
178        Commands::RedeployV2(_) => "redeploy-v2",
179        Commands::Config(_) => "config",
180        Commands::Install { .. } => "install",
181        Commands::Logs(_) => "logs",
182        Commands::Ssh(_) => "ssh",
183        Commands::Cloudflared(_) => "cloudflared",
184        Commands::List => "list",
185        Commands::Curl(_) => "curl",
186        Commands::Services => "services",
187        Commands::Service { .. } => "service",
188        Commands::Nginx(_) => "nginx",
189        Commands::Network(_) => "network",
190        Commands::Diag(_) => "diag",
191        Commands::Monitor(_) => "monitor",
192        Commands::Snapshot => "snapshot",
193        Commands::Resurrect => "resurrect",
194        Commands::Stop { .. } => "stop",
195        Commands::Flush { .. } => "flush",
196        Commands::Login(_) => "login",
197        Commands::Version(_) => "version",
198        Commands::Publish(_) => "publish",
199        Commands::Env { .. } => "env",
200        Commands::Tail(_) => "tail",
201        Commands::Start { .. } => "start",
202        Commands::Generate(_) => "generate",
203        #[cfg(feature = "secrets")]
204        Commands::Secrets(_) => "secrets",
205        Commands::Workers(_) => "workers",
206        Commands::Dns(_) => "dns",
207        Commands::Domains(_) => "domains",
208        Commands::Done(_) => "done",
209        Commands::FixProcessMonitorJson(_) => "fix-process-monitor-json",
210        Commands::Cursor(_) => "cursor",
211        #[cfg(feature = "kubernetes")]
212        Commands::Kubernetes(_) => "kubernetes",
213        #[cfg(feature = "nordvpn")]
214        Commands::Nordvpn(_) => "nordvpn",
215        #[cfg(feature = "monitoring")]
216        Commands::Monitoring(_) => "monitoring",
217        Commands::Api(_) => "api",
218        #[cfg(feature = "docker")]
219        Commands::Docker(_) => "docker",
220    }
221}
222
223#[derive(Args, Debug)]
224pub struct CommitCmd {
225    #[arg(
226        long,
227        help = "Generate and print the conventional commit message without creating a git commit"
228    )]
229    pub dry_run: bool,
230    #[arg(
231        short = 'p',
232        long,
233        help = "Push after committing, or push pending local commits when nothing new needs committing"
234    )]
235    pub push: bool,
236    #[arg(long, help = "Skip OpenRouter and use local heuristics only")]
237    pub no_ai: bool,
238    #[arg(
239        long,
240        help = "OpenRouter model override used for commit generation; otherwise XBP uses the global config default"
241    )]
242    pub model: Option<String>,
243    #[arg(
244        long,
245        help = "Force the conventional commit scope (for example: cli, api, docs)"
246    )]
247    pub scope: Option<String>,
248}
249
250#[derive(Args, Debug)]
251pub struct PortsCmd {
252    #[arg(short = 'p', long = "port")]
253    pub port: Option<u16>,
254    #[arg(long = "kill")]
255    pub kill: bool,
256    #[arg(short = 'n', long = "nginx")]
257    pub nginx: bool,
258    #[arg(
259        long = "full",
260        help = "Show one unified ports view (reconciled listeners + exposure + security flags)"
261    )]
262    pub full: bool,
263    #[arg(
264        long = "no-local",
265        help = "Exclude connections where LocalAddr equals RemoteAddr"
266    )]
267    pub no_local: bool,
268    #[arg(
269        long = "exposure",
270        help = "Diagnose external exposure per port (binding + firewall layer)"
271    )]
272    pub exposure: bool,
273}
274
275#[derive(Args, Debug)]
276pub struct ConfigCmd {
277    #[arg(
278        long,
279        help = "Show the current project config instead of opening global XBP paths"
280    )]
281    pub project: bool,
282    #[arg(long, help = "Print global XBP paths without opening them")]
283    pub no_open: bool,
284    #[command(subcommand)]
285    pub provider: Option<ConfigProviderCmd>,
286}
287
288#[derive(Subcommand, Debug)]
289pub enum ConfigProviderCmd {
290    #[command(about = "Manage the OpenRouter API key used by AI-enabled commands")]
291    Openrouter(ConfigSecretCmd),
292    #[command(about = "Manage the GitHub OAuth2 token used for release automation")]
293    Github(ConfigSecretCmd),
294    #[command(
295        about = "Manage Cloudflare API credentials used by secrets, DNS, and domains (run without a subcommand for the interactive setup wizard)"
296    )]
297    Cloudflare(CloudflareConfigCmd),
298    #[command(
299        about = "Manage the Linear API key used for release-note issue linking and initiative publishing"
300    )]
301    Linear(LinearConfigCmd),
302    #[command(about = "Manage npm registry auth and guided npm publish config")]
303    Npm(RegistryConfigCmd),
304    #[command(about = "Manage crates.io auth and guided crate publish config")]
305    Crates(CratesConfigCmd),
306}
307
308#[derive(Args, Debug)]
309pub struct ConfigSecretCmd {
310    #[command(subcommand)]
311    pub action: ConfigSecretAction,
312}
313
314#[derive(Subcommand, Debug)]
315pub enum ConfigSecretAction {
316    #[command(about = "Set provider key (omit value to enter it securely)")]
317    SetKey {
318        #[arg(help = "Provider key/token value")]
319        key: Option<String>,
320    },
321    #[command(about = "Delete the stored provider key")]
322    DeleteKey,
323    #[command(about = "Show whether a key is configured (masked by default)")]
324    Show {
325        #[arg(long, help = "Print full key/token value (not masked)")]
326        raw: bool,
327    },
328}
329
330#[derive(Args, Debug)]
331pub struct CloudflareConfigCmd {
332    #[command(subcommand)]
333    pub action: Option<CloudflareConfigAction>,
334}
335
336#[derive(Subcommand, Debug)]
337pub enum CloudflareConfigAction {
338    #[command(about = "Set Cloudflare API token (omit value to enter it securely)")]
339    SetKey {
340        #[arg(help = "Cloudflare API token")]
341        key: Option<String>,
342    },
343    #[command(about = "Delete the stored Cloudflare API token")]
344    DeleteKey,
345    #[command(about = "Show whether a Cloudflare API token is configured")]
346    ShowKey {
347        #[arg(long, help = "Print full token value (not masked)")]
348        raw: bool,
349    },
350    #[command(about = "Set the default Cloudflare account ID")]
351    SetAccountId {
352        #[arg(help = "Cloudflare account ID")]
353        account_id: Option<String>,
354    },
355    #[command(about = "Delete the stored default Cloudflare account ID")]
356    DeleteAccountId,
357    #[command(about = "Show whether a Cloudflare account ID is configured")]
358    ShowAccountId {
359        #[arg(long, help = "Print full account ID value (not masked)")]
360        raw: bool,
361    },
362    #[command(
363        about = "Interactive dashboard OAuth linking flow for Cloudflare credentials"
364    )]
365    Login,
366    #[command(about = "Show Cloudflare credential sources and readiness")]
367    Status,
368    #[command(about = "Run the interactive Cloudflare credential setup wizard")]
369    Setup,
370}
371
372#[derive(Args, Debug)]
373pub struct LinearConfigCmd {
374    #[command(subcommand)]
375    pub action: LinearConfigAction,
376}
377
378#[derive(Subcommand, Debug)]
379pub enum LinearConfigAction {
380    #[command(about = "Set Linear API key (omit value to enter it securely)")]
381    SetKey {
382        #[arg(help = "Linear API key/token value")]
383        key: Option<String>,
384    },
385    #[command(about = "Delete the stored Linear API key")]
386    DeleteKey,
387    #[command(about = "Show whether a Linear API key is configured (masked by default)")]
388    Show {
389        #[arg(long, help = "Print full key/token value (not masked)")]
390        raw: bool,
391    },
392    #[command(
393        name = "select-initiative",
394        about = "Pick a Linear initiative for the current repo and save it to .xbp/xbp.yaml"
395    )]
396    SelectInitiative,
397}
398
399#[derive(Args, Debug)]
400pub struct RegistryConfigCmd {
401    #[command(subcommand)]
402    pub action: RegistryConfigAction,
403}
404
405#[derive(Args, Debug)]
406pub struct CratesConfigCmd {
407    #[command(subcommand)]
408    pub action: CratesConfigAction,
409}
410
411#[derive(Subcommand, Debug)]
412pub enum RegistryConfigAction {
413    #[command(about = "Set registry token/key (omit value to enter it securely)")]
414    SetKey {
415        #[arg(help = "Registry token value")]
416        key: Option<String>,
417    },
418    #[command(about = "Delete the stored registry token")]
419    DeleteKey,
420    #[command(about = "Show whether a registry token is configured (masked by default)")]
421    Show {
422        #[arg(long, help = "Print full token value (not masked)")]
423        raw: bool,
424    },
425    #[command(
426        name = "setup-release",
427        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
428    )]
429    SetupRelease,
430}
431
432#[derive(Subcommand, Debug)]
433pub enum CratesConfigAction {
434    #[command(about = "Set crates.io token (omit value to enter it securely)")]
435    SetKey {
436        #[arg(help = "crates.io token value")]
437        key: Option<String>,
438    },
439    #[command(about = "Delete the stored crates.io token from global XBP config")]
440    DeleteKey,
441    #[command(about = "Show whether a crates.io token is configured (masked by default)")]
442    Show {
443        #[arg(long, help = "Print full token value (not masked)")]
444        raw: bool,
445    },
446    #[command(
447        name = "setup-release",
448        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
449    )]
450    SetupRelease,
451    #[command(
452        about = "Run `cargo login` using the stored crates token and sync Cargo's local credentials file"
453    )]
454    Login {
455        #[arg(help = "Optional crates.io token value to save before logging in")]
456        key: Option<String>,
457    },
458    #[command(
459        about = "Run `cargo logout` to remove Cargo's local crates.io credentials while keeping XBP's stored token"
460    )]
461    Logout,
462}
463
464#[derive(Args, Debug)]
465pub struct CurlCmd {
466    #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
467    pub url: Option<String>,
468    #[arg(long, help = "Disable the default 15 second timeout")]
469    pub no_timeout: bool,
470}
471
472#[derive(Args, Debug)]
473pub struct LoginCmd {
474    #[command(subcommand)]
475    pub action: Option<LoginSubCommand>,
476}
477
478#[derive(Subcommand, Debug)]
479pub enum LoginSubCommand {
480    #[command(about = "Show whether the current CLI session is still valid")]
481    Status,
482    #[command(about = "Revoke the current CLI token and clear local login state")]
483    Logout,
484}
485
486#[derive(Args, Debug)]
487#[command(subcommand_precedence_over_arg = true)]
488pub struct VersionCmd {
489    #[arg(
490        help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
491    )]
492    pub target: Option<String>,
493    #[arg(
494        short = 'v',
495        long = "version",
496        help = "Explicit version target; equivalent to the positional version value and overrides it when both are provided"
497    )]
498    pub explicit_version: Option<String>,
499    #[arg(long, help = "Show normalized git tags from `git tag --list`")]
500    pub git: bool,
501    #[command(subcommand)]
502    pub command: Option<VersionSubCommand>,
503}
504
505#[derive(Subcommand, Debug)]
506pub enum VersionSubCommand {
507    #[command(
508        about = "Create and push a git tag for this version, then create a GitHub release",
509        visible_alias = "r"
510    )]
511    Release(VersionReleaseCmd),
512    #[command(
513        about = "Manage Rust workspace release/version drift, sync, validation, and publish flow",
514        arg_required_else_help = true
515    )]
516    Workspace(VersionWorkspaceCmd),
517    /// Discover package roots (Cargo.toml, package.json, etc.) and register them as services
518    #[command(name = "discover", alias = "register", alias = "register-services")]
519    Discover(VersionDiscoverServicesCmd),
520    #[command(
521        about = "Bump versions only for packages with uncommitted changes in the working tree"
522    )]
523    Bump(VersionBumpCmd),
524}
525
526#[derive(Args, Debug)]
527pub struct VersionBumpCmd {
528    #[arg(long, help = "Preview bump selections without writing version files")]
529    pub dry_run: bool,
530    #[arg(
531        long,
532        group = "bump_kind",
533        help = "Default bump kind for --all and interactive default selection"
534    )]
535    pub major: bool,
536    #[arg(long, group = "bump_kind", help = "Default bump kind for --all and interactive default")]
537    pub minor: bool,
538    #[arg(long, group = "bump_kind", help = "Default bump kind for --all and interactive default")]
539    pub patch: bool,
540    #[arg(
541        long,
542        help = "Bump every mutated package with the selected default kind without prompting"
543    )]
544    pub all: bool,
545}
546
547#[derive(Args, Debug)]
548pub struct VersionDiscoverServicesCmd {
549    #[arg(
550        long,
551        help = "Preview discovered nested XBP services without writing .xbp/xbp.yaml"
552    )]
553    pub dry_run: bool,
554    #[arg(
555        long,
556        help = "Discover nested services only; do not register them in the root config (opt out)"
557    )]
558    pub no_register: bool,
559}
560
561#[derive(Args, Debug)]
562pub struct VersionReleaseCmd {
563    #[arg(
564        long,
565        help = "Release this version instead of auto-detecting from tracked files"
566    )]
567    pub version: Option<String>,
568    #[arg(
569        long,
570        help = "Allow releasing with uncommitted changes in the working tree"
571    )]
572    pub allow_dirty: bool,
573    #[arg(long, help = "Release title (defaults to <version> - <repo>)")]
574    pub title: Option<String>,
575    #[arg(long, help = "Release notes body (Markdown)")]
576    pub notes: Option<String>,
577    #[arg(long, help = "Read release notes body from a file")]
578    pub notes_file: Option<PathBuf>,
579    #[arg(long, help = "Create as draft release")]
580    pub draft: bool,
581    #[arg(long, help = "Mark release as pre-release")]
582    pub prerelease: bool,
583    #[arg(
584        long,
585        help = "Run configured npm/crates publish workflows before creating the GitHub release"
586    )]
587    pub publish: bool,
588    #[arg(
589        long,
590        requires = "publish",
591        help = "Skip configured publish preflight commands when `--publish` is enabled and allow a dirty working tree"
592    )]
593    pub force: bool,
594    #[arg(
595        long,
596        value_enum,
597        default_value_t = VersionReleaseLatest::Legacy,
598        help = "Control GitHub latest flag: true, false, or legacy"
599    )]
600    pub make_latest: VersionReleaseLatest,
601}
602
603#[derive(Copy, Clone, Debug, ValueEnum)]
604pub enum VersionReleaseLatest {
605    True,
606    False,
607    Legacy,
608}
609
610#[derive(Args, Debug)]
611pub struct PublishCmd {
612    #[arg(
613        long,
614        help = "Validate and print what would publish without uploading packages"
615    )]
616    pub dry_run: bool,
617    #[arg(
618        long,
619        help = "Allow publish workflows to run with a dirty working tree"
620    )]
621    pub allow_dirty: bool,
622    #[arg(long, help = "Skip configured preflight commands and publish anyway")]
623    pub force: bool,
624    #[arg(
625        long,
626        help = "When publishing a crate workspace member, also publish missing internal prerequisites in dependency order"
627    )]
628    pub include_prereqs: bool,
629    #[arg(long, help = "Limit publishing to one target: npm or crates")]
630    pub target: Option<String>,
631    #[arg(
632        long,
633        help = "Limit publishing to the workflow whose manifest matches this path"
634    )]
635    pub manifest_path: Option<PathBuf>,
636}
637
638#[derive(Args, Debug)]
639#[command(
640    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 plan --only athena-auth --include-prereqs\n  xbp version workspace publish run --dry-run\n  xbp version workspace publish run --from athena-s3\n  xbp version workspace publish run --only athena-auth --include-prereqs"
641)]
642pub struct VersionWorkspaceCmd {
643    #[command(subcommand)]
644    pub command: VersionWorkspaceSubCommand,
645}
646
647#[derive(Args, Debug, Clone, Default)]
648pub struct VersionWorkspaceTargetArgs {
649    #[arg(
650        long,
651        help = "Workspace repo root to inspect (defaults to current project root)"
652    )]
653    pub repo: Option<PathBuf>,
654    #[arg(long, help = "Emit machine-readable JSON output")]
655    pub json: bool,
656}
657
658#[derive(Subcommand, Debug)]
659pub enum VersionWorkspaceSubCommand {
660    #[command(about = "Detect workspace release drift and exit non-zero when mismatches exist")]
661    Check(VersionWorkspaceCheckCmd),
662    #[command(about = "Preview or apply workspace-wide version alignment")]
663    Sync(VersionWorkspaceSyncCmd),
664    #[command(about = "Run structural and optional cargo validation for workspace publishability")]
665    Validate(VersionWorkspaceValidateCmd),
666    #[command(about = "Plan or execute crates.io publishing for workspace packages")]
667    Publish(VersionWorkspacePublishCmd),
668}
669
670#[derive(Args, Debug)]
671pub struct VersionWorkspaceCheckCmd {
672    #[command(flatten)]
673    pub target: VersionWorkspaceTargetArgs,
674    #[arg(
675        long,
676        help = "Expected release version (defaults to the root package version)"
677    )]
678    pub version: Option<String>,
679}
680
681#[derive(Args, Debug)]
682pub struct VersionWorkspaceSyncCmd {
683    #[command(flatten)]
684    pub target: VersionWorkspaceTargetArgs,
685    #[arg(
686        long,
687        help = "Target release version (defaults to the root package version)"
688    )]
689    pub version: Option<String>,
690    #[arg(
691        long,
692        help = "Write changes to disk instead of previewing the sync plan"
693    )]
694    pub write: bool,
695}
696
697#[derive(Args, Debug)]
698pub struct VersionWorkspaceValidateCmd {
699    #[command(flatten)]
700    pub target: VersionWorkspaceTargetArgs,
701    #[arg(long, help = "Limit cargo validation to a single package name")]
702    pub package: Option<String>,
703    #[arg(long, help = "Run `cargo check -q` as part of validation")]
704    pub cargo_check: bool,
705    #[arg(
706        long,
707        help = "Run `cargo publish --dry-run --locked` for publishable packages"
708    )]
709    pub package_dry_run: bool,
710}
711
712#[derive(Args, Debug)]
713#[command(arg_required_else_help = true)]
714pub struct VersionWorkspacePublishCmd {
715    #[command(subcommand)]
716    pub command: VersionWorkspacePublishSubCommand,
717}
718
719#[derive(Subcommand, Debug)]
720pub enum VersionWorkspacePublishSubCommand {
721    #[command(about = "Show publish order, crates.io visibility, and blockers without publishing")]
722    Plan(VersionWorkspacePublishPlanCmd),
723    #[command(about = "Publish workspace packages in dependency order")]
724    Run(VersionWorkspacePublishRunCmd),
725}
726
727#[derive(Args, Debug)]
728pub struct VersionWorkspacePublishPlanCmd {
729    #[command(flatten)]
730    pub target: VersionWorkspaceTargetArgs,
731    #[arg(long, help = "Limit the plan to one package")]
732    pub only: Option<String>,
733    #[arg(
734        long,
735        help = "When planning a single package, also include missing internal prerequisites"
736    )]
737    pub include_prereqs: bool,
738}
739
740#[derive(Args, Debug)]
741pub struct VersionWorkspacePublishRunCmd {
742    #[command(flatten)]
743    pub target: VersionWorkspaceTargetArgs,
744    #[arg(long, help = "Preview publish actions without calling cargo publish")]
745    pub dry_run: bool,
746    #[arg(
747        long,
748        help = "Start publishing from this package in the computed order"
749    )]
750    pub from: Option<String>,
751    #[arg(long, help = "Publish only this package")]
752    pub only: Option<String>,
753    #[arg(
754        long,
755        help = "When publishing one package, also publish missing internal prerequisites in dependency order"
756    )]
757    pub include_prereqs: bool,
758    #[arg(long, help = "Continue publishing remaining packages after a failure")]
759    pub continue_on_error: bool,
760    #[arg(long, help = "Allow publishing from a dirty worktree")]
761    pub allow_dirty: bool,
762    #[arg(
763        long,
764        default_value_t = 180.0,
765        help = "How long to wait for each published version to become visible on crates.io"
766    )]
767    pub timeout_seconds: f64,
768    #[arg(
769        long,
770        default_value_t = 5.0,
771        help = "How often to poll crates.io for the just-published version"
772    )]
773    pub poll_interval_seconds: f64,
774}
775
776#[derive(Args, Debug)]
777pub struct RedeployV2Cmd {
778    #[arg(short = 'p', long = "password")]
779    pub password: Option<String>,
780    #[arg(short = 'u', long = "username")]
781    pub username: Option<String>,
782    #[arg(short = 'h', long = "host")]
783    pub host: Option<String>,
784    #[arg(short = 'd', long = "project-dir")]
785    pub project_dir: Option<String>,
786}
787
788#[derive(Args, Debug)]
789pub struct LogsCmd {
790    #[arg()]
791    pub project: Option<String>,
792    #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
793    pub ssh_host: Option<String>,
794    #[arg(long = "ssh-username", help = "SSH username for remote host")]
795    pub ssh_username: Option<String>,
796    #[arg(long = "ssh-password", help = "SSH password for remote host")]
797    pub ssh_password: Option<String>,
798}
799
800#[derive(Args, Debug)]
801pub struct SshCmd {
802    #[arg(long = "host", alias = "ssh-host", help = "SSH host or IP address")]
803    pub ssh_host: Option<String>,
804    #[arg(
805        long = "port",
806        default_value_t = 22,
807        help = "SSH port for direct connections"
808    )]
809    pub ssh_port: u16,
810    #[arg(
811        long = "username",
812        alias = "ssh-username",
813        help = "SSH username for the remote host"
814    )]
815    pub ssh_username: Option<String>,
816    #[arg(
817        long = "password",
818        alias = "ssh-password",
819        help = "SSH password (omit to use stored config or a secure prompt)"
820    )]
821    pub ssh_password: Option<String>,
822    #[arg(
823        long,
824        help = "Path to a private key file to use instead of password auth"
825    )]
826    pub private_key: Option<PathBuf>,
827    #[arg(long, help = "Passphrase for --private-key when required")]
828    pub private_key_passphrase: Option<String>,
829    #[arg(
830        long,
831        help = "Run this remote command in a PTY instead of opening the default login shell"
832    )]
833    pub command: Option<String>,
834    #[arg(
835        long,
836        help = "TERM value sent to the server (default: TERM env var or xterm-256color)"
837    )]
838    pub term: Option<String>,
839    #[arg(long, help = "Disable SSH host key verification")]
840    pub no_host_key_check: bool,
841    #[arg(
842        long,
843        help = "Pin the SSH host key as a base64 blob when using tunnels or first-connect flows"
844    )]
845    pub host_key: Option<String>,
846    #[arg(
847        long,
848        help = "Path to a known_hosts file used for SSH host verification"
849    )]
850    pub known_hosts_file: Option<PathBuf>,
851    #[arg(
852        long,
853        help = "Cloudflare Access hostname used to open a local cloudflared TCP forwarder"
854    )]
855    pub cloudflared_hostname: Option<String>,
856    #[arg(long, help = "Override the cloudflared binary path")]
857    pub cloudflared_binary: Option<PathBuf>,
858    #[arg(
859        long,
860        help = "Optional destination host:port passed to cloudflared access tcp"
861    )]
862    pub cloudflared_destination: Option<String>,
863}
864
865#[derive(Args, Debug)]
866#[command(arg_required_else_help = true)]
867pub struct CloudflaredCmd {
868    #[command(subcommand)]
869    pub command: CloudflaredSubCommand,
870}
871
872#[derive(Subcommand, Debug)]
873pub enum CloudflaredSubCommand {
874    #[command(about = "Start a local cloudflared Access TCP forwarder")]
875    Tcp(CloudflaredTcpCmd),
876}
877
878#[derive(Args, Debug)]
879#[command(
880    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"
881)]
882pub struct CloudflaredTcpCmd {
883    #[arg(long, help = "Protected Cloudflare Access hostname")]
884    pub hostname: Option<String>,
885    #[arg(
886        long,
887        help = "Local listener address for the forwarder (default: auto-allocate 127.0.0.1:<port>)"
888    )]
889    pub listener: Option<String>,
890    #[arg(
891        long,
892        help = "Optional destination host:port passed to cloudflared access tcp"
893    )]
894    pub destination: Option<String>,
895    #[arg(long, help = "Override the cloudflared binary path")]
896    pub binary: Option<PathBuf>,
897}
898
899#[derive(Args, Debug)]
900pub struct NginxCmd {
901    #[command(subcommand)]
902    pub command: NginxSubCommand,
903}
904
905#[derive(Args, Debug)]
906pub struct NetworkCmd {
907    #[command(subcommand)]
908    pub command: NetworkSubCommand,
909}
910
911#[derive(Subcommand, Debug)]
912pub enum NetworkSubCommand {
913    #[command(about = "Manage persistent floating IP configuration")]
914    FloatingIp(NetworkFloatingIpCmd),
915    #[command(about = "Inspect discovered network configuration sources")]
916    Config(NetworkConfigCmd),
917    #[command(about = "Manage Hetzner-specific Linux network configuration")]
918    Hetzner(NetworkHetznerCmd),
919}
920
921#[derive(Args, Debug)]
922pub struct NetworkFloatingIpCmd {
923    #[command(subcommand)]
924    pub command: NetworkFloatingIpSubCommand,
925}
926
927#[derive(Subcommand, Debug)]
928pub enum NetworkFloatingIpSubCommand {
929    #[command(about = "Add a persistent floating IP entry to detected network backend")]
930    Add {
931        #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
932        ip: String,
933        #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
934        cidr: Option<u8>,
935        #[arg(long, help = "Network interface override (auto-detected when omitted)")]
936        interface: Option<String>,
937        #[arg(long, help = "Optional label for backend metadata/file naming")]
938        label: Option<String>,
939        #[arg(long, help = "Apply network changes after writing config")]
940        apply: bool,
941        #[arg(long, help = "Preview computed changes without writing files")]
942        dry_run: bool,
943    },
944    #[command(about = "List floating IPs from runtime and persisted network config")]
945    List {
946        #[arg(long, help = "Emit JSON output")]
947        json: bool,
948    },
949}
950
951#[derive(Args, Debug)]
952pub struct NetworkConfigCmd {
953    #[command(subcommand)]
954    pub command: NetworkConfigSubCommand,
955}
956
957#[derive(Subcommand, Debug)]
958pub enum NetworkConfigSubCommand {
959    #[command(about = "List detected backend and configuration source files")]
960    List {
961        #[arg(long, help = "Emit JSON output")]
962        json: bool,
963    },
964}
965
966#[derive(Args, Debug)]
967pub struct NetworkHetznerCmd {
968    #[command(subcommand)]
969    pub command: NetworkHetznerSubCommand,
970}
971
972#[derive(Subcommand, Debug)]
973pub enum NetworkHetznerSubCommand {
974    #[command(about = "Configure a Hetzner vSwitch VLAN interface persistently")]
975    Vswitch(NetworkHetznerVswitchCmd),
976}
977
978#[derive(Args, Debug)]
979pub struct NetworkHetznerVswitchCmd {
980    #[command(subcommand)]
981    pub command: NetworkHetznerVswitchSubCommand,
982}
983
984#[derive(Subcommand, Debug)]
985pub enum NetworkHetznerVswitchSubCommand {
986    #[command(about = "Write persistent Linux config for a Hetzner vSwitch VLAN interface")]
987    Setup {
988        #[arg(
989            long,
990            help = "Private IPv4 address to assign on the vSwitch VLAN interface"
991        )]
992        ip: String,
993        #[arg(
994            long,
995            default_value_t = 24,
996            help = "CIDR prefix for --ip (default: 24)"
997        )]
998        cidr: u8,
999        #[arg(long, help = "Physical parent interface (auto-detected when omitted)")]
1000        interface: Option<String>,
1001        #[arg(long, help = "Hetzner vSwitch VLAN ID")]
1002        vlan_id: u16,
1003        #[arg(long, default_value_t = 1400, help = "Interface MTU (default: 1400)")]
1004        mtu: u16,
1005        #[arg(
1006            long,
1007            default_value = "10.0.3.1",
1008            help = "Gateway for the routed Hetzner cloud network"
1009        )]
1010        gateway: String,
1011        #[arg(
1012            long,
1013            default_value = "10.0.0.0/16",
1014            help = "Destination CIDR routed through the Hetzner vSwitch gateway"
1015        )]
1016        route_cidr: String,
1017        #[arg(long, help = "Apply or activate the new config immediately")]
1018        apply: bool,
1019        #[arg(long, help = "Preview file changes without writing them")]
1020        dry_run: bool,
1021    },
1022}
1023
1024#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1025pub enum NginxDnsMode {
1026    Manual,
1027    Plugin,
1028}
1029
1030#[derive(Subcommand, Debug)]
1031pub enum NginxSubCommand {
1032    #[command(
1033        about = "Provision an HTTPS NGINX reverse proxy with Certbot",
1034        long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
1035and write final HTTP->HTTPS redirect + TLS proxy config.\n\
1036\n\
1037Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
1038Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
1039with --dns-plugin and --dns-creds for non-interactive provider automation."
1040    )]
1041    Setup {
1042        #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
1043        domain: String,
1044        #[arg(short, long, help = "Port to proxy to")]
1045        port: u16,
1046        #[arg(
1047            short,
1048            long,
1049            help = "Email used for Let's Encrypt account registration"
1050        )]
1051        email: String,
1052        #[arg(
1053            long,
1054            value_enum,
1055            default_value_t = NginxDnsMode::Manual,
1056            help = "DNS challenge mode for wildcard certificates: manual or plugin"
1057        )]
1058        dns_mode: NginxDnsMode,
1059        #[arg(
1060            long,
1061            help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
1062        )]
1063        dns_plugin: Option<String>,
1064        #[arg(
1065            long,
1066            help = "Path to DNS plugin credentials file for --dns-mode plugin"
1067        )]
1068        dns_creds: Option<PathBuf>,
1069        #[arg(
1070            long,
1071            default_value_t = true,
1072            action = clap::ArgAction::Set,
1073            value_parser = clap::builder::BoolishValueParser::new(),
1074            help = "For wildcard domains, also request the base domain certificate (true|false)"
1075        )]
1076        include_base: bool,
1077    },
1078    #[command(about = "List discovered NGINX sites with listen/upstream ports")]
1079    List,
1080    #[command(about = "Show full NGINX config for one domain or all domains")]
1081    Show {
1082        #[arg(help = "Optional domain name to inspect")]
1083        domain: Option<String>,
1084    },
1085    #[command(about = "Open an NGINX site config in your configured editor")]
1086    Edit {
1087        #[arg(help = "Domain name to edit")]
1088        domain: String,
1089    },
1090    #[command(about = "Update upstream port for an existing NGINX site")]
1091    Update {
1092        #[arg(short, long, help = "Domain name to update")]
1093        domain: String,
1094        #[arg(short, long, help = "New port to proxy to")]
1095        port: u16,
1096    },
1097}
1098
1099#[derive(Args, Debug)]
1100pub struct DiagCmd {
1101    #[arg(long, help = "Check Nginx configuration")]
1102    pub nginx: bool,
1103    #[arg(long, hide = true)]
1104    pub refresh_system_inventory: bool,
1105    #[arg(
1106        long,
1107        help = "Refresh and print persisted machine inventory in the global XBP config"
1108    )]
1109    pub codetime: bool,
1110    #[arg(
1111        long,
1112        help = "Inspect Cursor roaming data on Windows; implies --codetime"
1113    )]
1114    pub cursor: bool,
1115    #[arg(long, help = "Check specific ports (comma-separated)")]
1116    pub ports: Option<String>,
1117    #[arg(long, help = "Skip internet speed test")]
1118    pub no_speed_test: bool,
1119    #[arg(
1120        long,
1121        help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
1122    )]
1123    pub compose_file: Option<String>,
1124}
1125
1126#[derive(Args, Debug)]
1127pub struct MonitorCmd {
1128    #[command(subcommand)]
1129    pub command: Option<MonitorSubCommand>,
1130}
1131
1132#[derive(Subcommand, Debug)]
1133pub enum MonitorSubCommand {
1134    Check,
1135    Start,
1136}
1137
1138#[cfg(feature = "monitoring")]
1139#[derive(Args, Debug)]
1140pub struct MonitoringCmd {
1141    #[command(subcommand)]
1142    pub command: MonitoringSubCommand,
1143}
1144
1145#[cfg(feature = "monitoring")]
1146#[derive(Subcommand, Debug)]
1147pub enum MonitoringSubCommand {
1148    Serve {
1149        #[arg(
1150            short,
1151            long,
1152            default_value = "prodzilla.yml",
1153            help = "Monitoring config file"
1154        )]
1155        file: String,
1156    },
1157    RunOnce {
1158        #[arg(
1159            short,
1160            long,
1161            default_value = "prodzilla.yml",
1162            help = "Monitoring config file"
1163        )]
1164        file: String,
1165        #[arg(long, help = "Run probes only")]
1166        probes_only: bool,
1167        #[arg(long, help = "Run stories only")]
1168        stories_only: bool,
1169    },
1170    List {
1171        #[arg(
1172            short,
1173            long,
1174            default_value = "prodzilla.yml",
1175            help = "Monitoring config file"
1176        )]
1177        file: String,
1178    },
1179}
1180
1181#[derive(Args, Debug)]
1182#[command(
1183    arg_required_else_help = true,
1184    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."
1185)]
1186pub struct ApiCmd {
1187    #[command(subcommand)]
1188    pub command: ApiSubCommand,
1189}
1190
1191#[derive(Args, Debug, Clone, Default)]
1192pub struct ApiTargetOptions {
1193    #[arg(long, help = "Override the request base URL for this command")]
1194    pub base_url: Option<String>,
1195    #[arg(
1196        long,
1197        help = "Target the hosted web origin (xbp.app) instead of the configured API_XBP_URL base"
1198    )]
1199    pub web: bool,
1200    #[arg(
1201        long,
1202        help = "Skip bearer token auth even when XBP_API_TOKEN is configured"
1203    )]
1204    pub no_auth: bool,
1205    #[arg(
1206        long,
1207        help = "Extra header in 'Name: Value' format",
1208        value_name = "HEADER"
1209    )]
1210    pub header: Vec<String>,
1211    #[arg(long, help = "Print response headers")]
1212    pub include_headers: bool,
1213    #[arg(
1214        long,
1215        help = "Print the response body as-is without JSON pretty formatting"
1216    )]
1217    pub raw: bool,
1218}
1219
1220#[cfg(feature = "docker")]
1221#[derive(Args, Debug)]
1222pub struct DockerCmd {
1223    #[arg(
1224        trailing_var_arg = true,
1225        allow_hyphen_values = true,
1226        help = "Arguments to pass directly to the Docker CLI (default: --help)"
1227    )]
1228    pub args: Vec<String>,
1229}
1230
1231#[derive(Subcommand, Debug)]
1232pub enum ApiSubCommand {
1233    #[command(about = "Install and enable the local xbp-api.service on Linux/systemd")]
1234    Install {
1235        #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
1236        port: u16,
1237    },
1238    #[command(about = "Call the XBP API health endpoint")]
1239    Health(ApiHealthCmd),
1240    #[command(about = "Manage XBP control-plane projects")]
1241    Projects(ApiProjectsCmd),
1242    #[command(about = "Manage XBP daemon registrations and heartbeats")]
1243    Daemons(ApiDaemonsCmd),
1244    #[command(about = "Manage XBP deployment jobs")]
1245    Jobs(ApiJobsCmd),
1246    #[command(about = "Manage XBP proxy routes on the local API server")]
1247    Routes(ApiRoutesCmd),
1248    #[command(about = "Send an authenticated HTTP request to the configured XBP API surface")]
1249    Request(ApiRequestCmd),
1250}
1251
1252#[derive(Args, Debug)]
1253pub struct ApiHealthCmd {
1254    #[command(flatten)]
1255    pub target: ApiTargetOptions,
1256}
1257
1258#[derive(Args, Debug)]
1259pub struct ApiProjectsCmd {
1260    #[command(subcommand)]
1261    pub command: ApiProjectsSubCommand,
1262}
1263
1264#[derive(Subcommand, Debug)]
1265pub enum ApiProjectsSubCommand {
1266    #[command(about = "List projects from the XBP control-plane API")]
1267    List(ApiProjectsListCmd),
1268    #[command(about = "Create or upsert a control-plane project")]
1269    Create(Box<ApiProjectsCreateCmd>),
1270}
1271
1272#[derive(Args, Debug)]
1273pub struct ApiProjectsListCmd {
1274    #[arg(long, help = "Optional organization ID filter")]
1275    pub organization_id: Option<String>,
1276    #[command(flatten)]
1277    pub target: ApiTargetOptions,
1278}
1279
1280#[derive(Args, Debug)]
1281pub struct ApiProjectsCreateCmd {
1282    #[arg(long, help = "Project name")]
1283    pub name: String,
1284    #[arg(long, help = "Project path or repo path key")]
1285    pub path: String,
1286    #[arg(long, help = "Optional organization ID")]
1287    pub organization_id: Option<String>,
1288    #[arg(long, help = "Optional project slug")]
1289    pub slug: Option<String>,
1290    #[arg(long, help = "Optional project version")]
1291    pub version: Option<String>,
1292    #[arg(long, help = "Optional build directory")]
1293    pub build_dir: Option<String>,
1294    #[arg(long, help = "Optional runtime enum value")]
1295    pub runtime: Option<String>,
1296    #[arg(long, help = "Optional default branch")]
1297    pub default_branch: Option<String>,
1298    #[arg(long, help = "Optional repository root directory")]
1299    pub root_directory: Option<String>,
1300    #[arg(long, help = "Optional build command")]
1301    pub build_command: Option<String>,
1302    #[arg(long, help = "Optional install command")]
1303    pub install_command: Option<String>,
1304    #[arg(long, help = "Optional start command")]
1305    pub start_command: Option<String>,
1306    #[arg(long, help = "Optional output directory")]
1307    pub output_directory: Option<String>,
1308    #[arg(long, help = "Repository JSON payload matching GitRepositoryRef")]
1309    pub repository_json: Option<String>,
1310    #[arg(long, help = "Runtime policy JSON payload")]
1311    pub runtime_policy_json: Option<String>,
1312    #[arg(long, help = "Metadata JSON object")]
1313    pub metadata_json: Option<String>,
1314    #[command(flatten)]
1315    pub target: ApiTargetOptions,
1316}
1317
1318#[derive(Args, Debug)]
1319pub struct ApiDaemonsCmd {
1320    #[command(subcommand)]
1321    pub command: ApiDaemonsSubCommand,
1322}
1323
1324#[derive(Subcommand, Debug)]
1325pub enum ApiDaemonsSubCommand {
1326    #[command(about = "List registered daemons")]
1327    List(ApiDaemonsListCmd),
1328    #[command(about = "Register or upsert a daemon record")]
1329    Register(ApiDaemonsRegisterCmd),
1330    #[command(about = "Post a heartbeat update for a daemon")]
1331    Heartbeat(ApiDaemonsHeartbeatCmd),
1332    #[command(about = "Update daemon status only")]
1333    UpdateStatus(ApiDaemonsUpdateStatusCmd),
1334}
1335
1336#[derive(Args, Debug)]
1337pub struct ApiDaemonsListCmd {
1338    #[command(flatten)]
1339    pub target: ApiTargetOptions,
1340}
1341
1342#[derive(Args, Debug)]
1343pub struct ApiDaemonsRegisterCmd {
1344    #[arg(long, help = "Daemon node name")]
1345    pub node_name: String,
1346    #[arg(long, help = "Daemon hostname")]
1347    pub hostname: String,
1348    #[arg(long, help = "Daemon binary version")]
1349    pub version: String,
1350    #[arg(long, help = "Optional region")]
1351    pub region: Option<String>,
1352    #[arg(long, help = "Optional public IP")]
1353    pub public_ip: Option<String>,
1354    #[arg(long, help = "Optional internal IP")]
1355    pub internal_ip: Option<String>,
1356    #[arg(long, help = "Optional status enum value")]
1357    pub status: Option<String>,
1358    #[arg(long, help = "Optional CPU core count")]
1359    pub cpu_cores: Option<i32>,
1360    #[arg(long, help = "Optional total memory in MB")]
1361    pub memory_total_mb: Option<i32>,
1362    #[arg(long, help = "Optional total disk in GB")]
1363    pub disk_total_gb: Option<i32>,
1364    #[arg(long, help = "Labels JSON object")]
1365    pub labels_json: Option<String>,
1366    #[arg(long, help = "Metadata JSON object")]
1367    pub metadata_json: Option<String>,
1368    #[command(flatten)]
1369    pub target: ApiTargetOptions,
1370}
1371
1372#[derive(Args, Debug)]
1373pub struct ApiDaemonsHeartbeatCmd {
1374    #[arg(help = "Daemon ID")]
1375    pub daemon_id: String,
1376    #[arg(long, help = "Optional status enum value")]
1377    pub status: Option<String>,
1378    #[arg(long, help = "Optional daemon version")]
1379    pub version: Option<String>,
1380    #[arg(long, help = "Optional public IP")]
1381    pub public_ip: Option<String>,
1382    #[arg(long, help = "Optional internal IP")]
1383    pub internal_ip: Option<String>,
1384    #[arg(long, help = "Optional CPU core count")]
1385    pub cpu_cores: Option<i32>,
1386    #[arg(long, help = "Optional total memory in MB")]
1387    pub memory_total_mb: Option<i32>,
1388    #[arg(long, help = "Optional total disk in GB")]
1389    pub disk_total_gb: Option<i32>,
1390    #[arg(long, help = "Labels JSON object")]
1391    pub labels_json: Option<String>,
1392    #[command(flatten)]
1393    pub target: ApiTargetOptions,
1394}
1395
1396#[derive(Args, Debug)]
1397pub struct ApiDaemonsUpdateStatusCmd {
1398    #[arg(help = "Daemon ID")]
1399    pub daemon_id: String,
1400    #[arg(long, help = "Daemon status enum value")]
1401    pub status: String,
1402    #[command(flatten)]
1403    pub target: ApiTargetOptions,
1404}
1405
1406#[derive(Args, Debug)]
1407pub struct ApiJobsCmd {
1408    #[command(subcommand)]
1409    pub command: ApiJobsSubCommand,
1410}
1411
1412#[derive(Subcommand, Debug)]
1413pub enum ApiJobsSubCommand {
1414    #[command(about = "List deployment jobs")]
1415    List(ApiJobsListCmd),
1416    #[command(about = "Create a deployment job for a project")]
1417    Create(ApiJobsCreateCmd),
1418    #[command(about = "Claim the next deployment job for a daemon")]
1419    Claim(ApiJobsClaimCmd),
1420    #[command(about = "Update deployment job status")]
1421    Update(ApiJobsUpdateCmd),
1422}
1423
1424#[derive(Args, Debug)]
1425pub struct ApiJobsListCmd {
1426    #[arg(long, help = "Optional project ID filter")]
1427    pub project_id: Option<String>,
1428    #[arg(long, help = "Optional deployment ID filter")]
1429    pub deployment_id: Option<String>,
1430    #[arg(long, help = "Optional daemon ID filter")]
1431    pub daemon_id: Option<String>,
1432    #[arg(long, help = "Optional status filter")]
1433    pub status: Option<String>,
1434    #[arg(long, help = "Optional result limit")]
1435    pub limit: Option<usize>,
1436    #[command(flatten)]
1437    pub target: ApiTargetOptions,
1438}
1439
1440#[derive(Args, Debug)]
1441pub struct ApiJobsCreateCmd {
1442    #[arg(long, help = "Project ID")]
1443    pub project_id: String,
1444    #[arg(long, help = "Deployment ID")]
1445    pub deployment_id: String,
1446    #[arg(long, help = "Optional daemon ID assignment")]
1447    pub daemon_id: Option<String>,
1448    #[arg(long, help = "Optional priority")]
1449    pub priority: Option<i32>,
1450    #[arg(long, help = "Optional max attempts")]
1451    pub max_attempts: Option<i32>,
1452    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
1453    pub run_after: Option<String>,
1454    #[arg(long, help = "Optional payload JSON object")]
1455    pub payload_json: Option<String>,
1456    #[command(flatten)]
1457    pub target: ApiTargetOptions,
1458}
1459
1460#[derive(Args, Debug)]
1461pub struct ApiJobsClaimCmd {
1462    #[arg(long, help = "Daemon ID claiming work")]
1463    pub daemon_id: String,
1464    #[arg(long, help = "Optional lock owner")]
1465    pub locked_by: Option<String>,
1466    #[command(flatten)]
1467    pub target: ApiTargetOptions,
1468}
1469
1470#[derive(Args, Debug)]
1471pub struct ApiJobsUpdateCmd {
1472    #[arg(help = "Deployment job ID")]
1473    pub job_id: String,
1474    #[arg(long, help = "Deployment job status enum value")]
1475    pub status: String,
1476    #[arg(long, help = "Optional error text")]
1477    pub error_text: Option<String>,
1478    #[command(flatten)]
1479    pub target: ApiTargetOptions,
1480}
1481
1482#[derive(Args, Debug)]
1483pub struct ApiRoutesCmd {
1484    #[command(subcommand)]
1485    pub command: ApiRoutesSubCommand,
1486}
1487
1488#[derive(Subcommand, Debug)]
1489pub enum ApiRoutesSubCommand {
1490    #[command(about = "List configured proxy routes")]
1491    List(ApiRoutesListCmd),
1492    #[command(about = "Create or replace a proxy route")]
1493    Create(ApiRoutesCreateCmd),
1494    #[command(about = "Delete a proxy route by domain")]
1495    Delete(ApiRoutesDeleteCmd),
1496}
1497
1498#[derive(Args, Debug)]
1499pub struct ApiRoutesListCmd {
1500    #[command(flatten)]
1501    pub target: ApiTargetOptions,
1502}
1503
1504#[derive(Args, Debug)]
1505pub struct ApiRoutesCreateCmd {
1506    #[arg(long, help = "Domain name for the route")]
1507    pub domain: String,
1508    #[arg(long, help = "Upstream target URL", required = true)]
1509    pub target: Vec<String>,
1510    #[arg(
1511        long,
1512        help = "Weighted upstream target in url=weight form",
1513        value_name = "URL=WEIGHT"
1514    )]
1515    pub weighted_target: Vec<String>,
1516    #[arg(long, help = "Optional header condition")]
1517    pub header_condition: Option<String>,
1518    #[arg(long, help = "Optional path prefix condition")]
1519    pub path_prefix: Option<String>,
1520    #[command(flatten)]
1521    pub target_options: ApiTargetOptions,
1522}
1523
1524#[derive(Args, Debug)]
1525pub struct ApiRoutesDeleteCmd {
1526    #[arg(help = "Domain name for the route")]
1527    pub domain: String,
1528    #[command(flatten)]
1529    pub target: ApiTargetOptions,
1530}
1531
1532#[derive(Args, Debug)]
1533pub struct ApiRequestCmd {
1534    #[arg(help = "Request path like /projects or a full https:// URL")]
1535    pub path: String,
1536    #[arg(
1537        short = 'X',
1538        long,
1539        help = "HTTP method to use (default: GET, or POST when a body is provided)"
1540    )]
1541    pub method: Option<String>,
1542    #[arg(short = 'd', long, help = "Inline request body string, typically JSON")]
1543    pub body: Option<String>,
1544    #[arg(long, help = "Read the request body from a file")]
1545    pub body_file: Option<PathBuf>,
1546    #[command(flatten)]
1547    pub target: ApiTargetOptions,
1548}
1549#[derive(Args, Debug)]
1550pub struct TailCmd {
1551    #[arg(long, help = "Tail Kafka topic instead of log files")]
1552    pub kafka: bool,
1553    #[arg(long, help = "Ship logs to Kafka")]
1554    pub ship: bool,
1555}
1556
1557#[derive(Args, Debug)]
1558pub struct GenerateCmd {
1559    #[command(subcommand)]
1560    pub command: GenerateSubCommand,
1561}
1562
1563#[derive(Subcommand, Debug)]
1564pub enum GenerateSubCommand {
1565    #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
1566    Config(GenerateConfigCmd),
1567    Systemd(GenerateSystemdCmd),
1568}
1569
1570#[derive(Args, Debug)]
1571pub struct GenerateConfigCmd {
1572    #[arg(
1573        long,
1574        help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
1575    )]
1576    pub force: bool,
1577    #[arg(
1578        long,
1579        help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
1580    )]
1581    pub update: bool,
1582    #[arg(
1583        long,
1584        help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
1585    )]
1586    pub from_json: Option<PathBuf>,
1587}
1588
1589#[derive(Args, Debug)]
1590#[command(
1591    arg_required_else_help = true,
1592    after_help = "Examples:\n  xbp workers secrets put --environment production --name GITHUB_APP_PRIVATE_KEY --from-stdin\n  xbp workers secrets list --environment production\n  xbp workers settings get --environment production\n  xbp workers wrangler generate-config --output wrangler.deploy.json\n  xbp workers d1 migrations apply DB --remote\n  xbp workers deploy sync-env-local\n  xbp workers deploy ci --version-upload\n  xbp workers worktree link-dev-vars"
1593)]
1594pub struct WorkersCmd {
1595    #[arg(
1596        long,
1597        help = "Workers project root (defaults to current dir, or apps/web inside the current XBP repo when present)"
1598    )]
1599    pub root: Option<PathBuf>,
1600    #[arg(long, help = "Cloudflare API token override")]
1601    pub token: Option<String>,
1602    #[arg(long, help = "Cloudflare account ID override")]
1603    pub account_id: Option<String>,
1604    #[command(subcommand)]
1605    pub command: WorkersSubCommand,
1606}
1607
1608#[derive(Subcommand, Debug)]
1609pub enum WorkersSubCommand {
1610    #[command(about = "Manage secret bindings for a Worker script or Wrangler environment")]
1611    Secrets(WorkersSecretsCmd),
1612    #[command(about = "Fetch remote Worker settings via the Cloudflare API")]
1613    Settings(WorkersSettingsCmd),
1614    #[command(about = "Inspect or generate Wrangler config helpers")]
1615    Wrangler(WorkersWranglerCmd),
1616    #[command(about = "Run Wrangler D1 migration helpers")]
1617    D1(WorkersD1Cmd),
1618    #[command(about = "Run Worker deploy and predeploy helpers")]
1619    Deploy(WorkersDeployCmd),
1620    #[command(about = "Inspect shared worktree paths or link shared dev files")]
1621    Worktree(WorkersWorktreeCmd),
1622    #[command(about = "Show resolved Worker runtime metadata from local env and config files")]
1623    Env(WorkersEnvCmd),
1624}
1625
1626#[derive(Args, Debug, Clone, Default)]
1627pub struct WorkersTargetArgs {
1628    #[arg(
1629        long,
1630        help = "Worker base name (defaults to wrangler config name or xbp)"
1631    )]
1632    pub worker: Option<String>,
1633    #[arg(
1634        long = "environment",
1635        alias = "env",
1636        help = "Wrangler environment name. The remote script resolves to <worker>-<environment>."
1637    )]
1638    pub environment: Option<String>,
1639    #[arg(
1640        long,
1641        help = "Exact remote script name override. When set, this bypasses <worker>-<environment> resolution."
1642    )]
1643    pub script: Option<String>,
1644}
1645
1646#[derive(Args, Debug)]
1647pub struct WorkersSecretsCmd {
1648    #[command(flatten)]
1649    pub target: WorkersTargetArgs,
1650    #[command(subcommand)]
1651    pub command: WorkersSecretsSubCommand,
1652}
1653
1654#[derive(Subcommand, Debug)]
1655pub enum WorkersSecretsSubCommand {
1656    #[command(about = "List secret bindings on the resolved Worker script")]
1657    List(WorkersSecretsListCmd),
1658    #[command(about = "Fetch one secret binding metadata or value")]
1659    Get(WorkersSecretsGetCmd),
1660    #[command(about = "Create or update a secret binding")]
1661    Put(WorkersSecretsPutCmd),
1662    #[command(about = "Delete a secret binding")]
1663    Delete(WorkersSecretsDeleteCmd),
1664    #[command(about = "Create, update, or delete multiple secret bindings from a file")]
1665    Bulk(WorkersSecretsBulkCmd),
1666}
1667
1668#[derive(Args, Debug, Default)]
1669pub struct WorkersSecretsListCmd {}
1670
1671#[derive(Args, Debug)]
1672pub struct WorkersSecretsGetCmd {
1673    #[arg(long, help = "Secret binding name")]
1674    pub name: String,
1675}
1676
1677#[derive(Args, Debug)]
1678pub struct WorkersSecretsPutCmd {
1679    #[arg(long, help = "Secret binding name")]
1680    pub name: String,
1681    #[arg(long, help = "Secret value")]
1682    pub value: Option<String>,
1683    #[arg(long, help = "Read the secret value from stdin instead of --value")]
1684    pub from_stdin: bool,
1685}
1686
1687#[derive(Args, Debug)]
1688pub struct WorkersSecretsDeleteCmd {
1689    #[arg(long, help = "Secret binding name")]
1690    pub name: String,
1691}
1692
1693#[derive(Args, Debug)]
1694pub struct WorkersSecretsBulkCmd {
1695    #[arg(long, help = "Path to a .env or JSON file containing secret updates")]
1696    pub file: PathBuf,
1697    #[arg(
1698        long,
1699        default_value = "env",
1700        help = "Input format: env or json. For json, pass an object mapping names to string values or null for deletes."
1701    )]
1702    pub format: String,
1703}
1704
1705#[derive(Args, Debug)]
1706pub struct WorkersSettingsCmd {
1707    #[command(flatten)]
1708    pub target: WorkersTargetArgs,
1709}
1710
1711#[derive(Args, Debug)]
1712pub struct WorkersWranglerCmd {
1713    #[command(subcommand)]
1714    pub command: WorkersWranglerSubCommand,
1715}
1716
1717#[derive(Subcommand, Debug)]
1718pub enum WorkersWranglerSubCommand {
1719    #[command(about = "Generate a Wrangler deploy config JSON file from env vars")]
1720    GenerateConfig(WorkersWranglerGenerateConfigCmd),
1721    #[command(about = "Resolve which Wrangler config file local dev should use")]
1722    ConfigPath(WorkersWranglerConfigPathCmd),
1723}
1724
1725#[derive(Args, Debug)]
1726pub struct WorkersWranglerGenerateConfigCmd {
1727    #[arg(
1728        long,
1729        default_value = "wrangler.deploy.json",
1730        help = "Output filename, relative to the worker root unless absolute"
1731    )]
1732    pub output: PathBuf,
1733}
1734
1735#[derive(Args, Debug)]
1736pub struct WorkersWranglerConfigPathCmd {
1737    #[arg(
1738        long,
1739        default_value = "serve",
1740        help = "Calling command name, for example serve"
1741    )]
1742    pub command_name: String,
1743    #[arg(
1744        long,
1745        default_value = "development",
1746        help = "Execution mode, for example development or production"
1747    )]
1748    pub mode: String,
1749}
1750
1751#[derive(Args, Debug)]
1752pub struct WorkersD1Cmd {
1753    #[command(subcommand)]
1754    pub command: WorkersD1SubCommand,
1755}
1756
1757#[derive(Subcommand, Debug)]
1758pub enum WorkersD1SubCommand {
1759    #[command(about = "Apply pending Wrangler D1 migrations")]
1760    Migrations(WorkersD1MigrationsCmd),
1761}
1762
1763#[derive(Args, Debug)]
1764pub struct WorkersD1MigrationsCmd {
1765    #[command(subcommand)]
1766    pub command: WorkersD1MigrationsSubCommand,
1767}
1768
1769#[derive(Subcommand, Debug)]
1770pub enum WorkersD1MigrationsSubCommand {
1771    #[command(about = "Apply pending migrations to a local or remote D1 database")]
1772    Apply(WorkersD1MigrationsApplyCmd),
1773}
1774
1775#[derive(Args, Debug)]
1776pub struct WorkersD1MigrationsApplyCmd {
1777    #[arg(help = "D1 database binding or name, for example DB")]
1778    pub database: String,
1779    #[arg(
1780        long,
1781        conflicts_with = "remote",
1782        help = "Apply migrations to the local Wrangler D1 database"
1783    )]
1784    pub local: bool,
1785    #[arg(
1786        long,
1787        conflicts_with = "local",
1788        help = "Apply migrations to the remote D1 database"
1789    )]
1790    pub remote: bool,
1791    #[arg(long, help = "Wrangler config path override")]
1792    pub config: Option<PathBuf>,
1793    #[command(flatten)]
1794    pub target: WorkersTargetArgs,
1795    #[arg(
1796        long,
1797        help = "Persist local D1 state to this directory. When omitted in a git worktree, xbp uses the shared .wrangler/state path automatically."
1798    )]
1799    pub persist_to: Option<PathBuf>,
1800    #[arg(
1801        long,
1802        help = "Disable the automatic shared .wrangler/state path when running local migrations from a git worktree"
1803    )]
1804    pub no_shared_worktree_state: bool,
1805}
1806
1807#[derive(Args, Debug)]
1808pub struct WorkersDeployCmd {
1809    #[command(subcommand)]
1810    pub command: WorkersDeploySubCommand,
1811}
1812
1813#[derive(Subcommand, Debug)]
1814pub enum WorkersDeploySubCommand {
1815    #[command(about = "Run the predeploy sync flow unless Workers CI mode is active")]
1816    Predeploy(WorkersDeployPredeployCmd),
1817    #[command(about = "Read .env.local or process env, then emit .dev.vars and Wrangler configs")]
1818    SyncEnvLocal(WorkersDeploySyncEnvLocalCmd),
1819    #[command(about = "Run the existing Cloudflare CI deploy workflow")]
1820    Ci(WorkersDeployCiCmd),
1821    #[command(
1822        about = "Run the existing deploy-selection flow that chooses CI or local deploy behavior"
1823    )]
1824    Select(WorkersDeploySelectCmd),
1825}
1826
1827#[derive(Args, Debug)]
1828pub struct WorkersDeployPredeployCmd {
1829    #[arg(long, help = "Force Workers CI mode and skip local sync")]
1830    pub ci: bool,
1831}
1832
1833#[derive(Args, Debug)]
1834pub struct WorkersDeploySyncEnvLocalCmd {}
1835
1836#[derive(Args, Debug)]
1837pub struct WorkersDeployCiCmd {
1838    #[arg(long, help = "Upload a new version without immediately deploying it")]
1839    pub version_upload: bool,
1840}
1841
1842#[derive(Args, Debug)]
1843pub struct WorkersDeploySelectCmd {
1844    #[arg(long, help = "Force the WORKERS_CI=1 branch of the selector")]
1845    pub ci: bool,
1846    #[arg(
1847        long,
1848        help = "Branch name to expose as WORKERS_CI_BRANCH when --ci is set"
1849    )]
1850    pub branch: Option<String>,
1851}
1852
1853#[derive(Args, Debug)]
1854pub struct WorkersWorktreeCmd {
1855    #[command(subcommand)]
1856    pub command: WorkersWorktreeSubCommand,
1857}
1858
1859#[derive(Subcommand, Debug)]
1860pub enum WorkersWorktreeSubCommand {
1861    #[command(about = "Print repo-root, primary worktree, and shared Wrangler state paths")]
1862    Paths(WorkersWorktreePathsCmd),
1863    #[command(
1864        about = "Symlink apps/web/.dev.vars and wrangler.dev.jsonc from the primary worktree when in a linked worktree"
1865    )]
1866    LinkDevVars(WorkersWorktreeLinkDevVarsCmd),
1867}
1868
1869#[derive(Args, Debug, Default)]
1870pub struct WorkersWorktreePathsCmd {}
1871
1872#[derive(Args, Debug, Default)]
1873pub struct WorkersWorktreeLinkDevVarsCmd {}
1874
1875#[derive(Args, Debug)]
1876pub struct WorkersEnvCmd {
1877    #[command(flatten)]
1878    pub target: WorkersTargetArgs,
1879    #[arg(
1880        long,
1881        help = "Show resolved plain-text binding values instead of masking them"
1882    )]
1883    pub show_values: bool,
1884}
1885
1886#[cfg(feature = "secrets")]
1887#[derive(Args, Debug)]
1888pub struct SecretsCmd {
1889    #[arg(long, value_enum, default_value_t = SecretsProviderKind::Github, help = "Secrets provider to use")]
1890    pub provider: SecretsProviderKind,
1891    #[arg(long, help = "GitHub repository override (owner/repo)")]
1892    pub repo: Option<String>,
1893    #[arg(
1894        long,
1895        help = "Provider token override (GitHub token or Cloudflare API token)"
1896    )]
1897    pub token: Option<String>,
1898    #[arg(long, help = "Cloudflare account ID override")]
1899    pub account_id: Option<String>,
1900    #[arg(
1901        long = "environment",
1902        alias = "env",
1903        default_value = "xbp-dev",
1904        help = "Environment to sync (default: xbp-dev). Nested services are scoped automatically, e.g. xbp-dev-web."
1905    )]
1906    pub environment: String,
1907    #[arg(
1908        long,
1909        help = "Service name from .xbp/xbp.yaml. If omitted, XBP resolves it from the current directory or prompts when ambiguous."
1910    )]
1911    pub service: Option<String>,
1912    #[command(subcommand)]
1913    pub command: Option<SecretsSubCommand>,
1914}
1915
1916#[cfg(feature = "secrets")]
1917#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
1918pub enum SecretsProviderKind {
1919    Github,
1920    Cloudflare,
1921    Railway,
1922    Vercel,
1923}
1924
1925#[cfg(feature = "secrets")]
1926#[derive(Subcommand, Debug)]
1927pub enum SecretsSubCommand {
1928    /// List available secrets providers
1929    #[command(alias = "ls", alias = "list-providers")]
1930    Providers,
1931    /// List local env vars from the preferred env file
1932    List(ListCmd),
1933    /// Push local env vars to the secrets provider (GitHub)
1934    Push(PushCmd),
1935    /// Pull secrets from the provider into .env.local
1936    Pull(PullCmd),
1937    /// Generate .env.default from source code inspection
1938    GenerateDefault(GenerateDefaultCmd),
1939    /// Generate .env.example with categories and defaults
1940    GenerateExample(GenerateExampleCmd),
1941    /// Compare local env with remote (GitHub) variables
1942    Diff,
1943    /// Verify that all required env vars are available locally
1944    Verify,
1945    /// Check connectivity, token scope, and repo access for secrets
1946    #[command(name = "diag", alias = "doctor")]
1947    Diag,
1948    /// Manage Cloudflare secrets stores
1949    Stores(SecretsStoresCmd),
1950    /// Manage Cloudflare secrets in a store
1951    Secrets(CloudflareSecretsCmd),
1952    /// Inspect Cloudflare quota usage
1953    Quota(SecretsQuotaCmd),
1954    /// Show secrets command usage
1955    #[command(name = "usage")]
1956    Usage,
1957}
1958
1959#[cfg(feature = "secrets")]
1960#[derive(Args, Debug)]
1961pub struct ListCmd {
1962    #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
1963    pub file: Option<String>,
1964    #[arg(long, help = "Output format: plain (default) or json")]
1965    pub format: Option<String>,
1966}
1967
1968#[cfg(feature = "secrets")]
1969#[derive(Args, Debug)]
1970pub struct PushCmd {
1971    #[arg(long, help = "Path to env file (default: .env.local/.env)")]
1972    pub file: Option<String>,
1973    #[arg(
1974        long,
1975        help = "Force overwrite existing GitHub Actions environment variables"
1976    )]
1977    pub force: bool,
1978    #[arg(long, help = "Show what would be pushed without making changes")]
1979    pub dry_run: bool,
1980}
1981
1982#[cfg(feature = "secrets")]
1983#[derive(Args, Debug)]
1984pub struct PullCmd {
1985    #[arg(long, help = "Output file path (default: .env.local)")]
1986    pub output: Option<String>,
1987}
1988
1989#[cfg(feature = "secrets")]
1990#[derive(Args, Debug)]
1991pub struct GenerateDefaultCmd {
1992    #[arg(long, help = "Output file path (default: .env.default)")]
1993    pub output: Option<String>,
1994}
1995
1996#[cfg(feature = "secrets")]
1997#[derive(Args, Debug)]
1998pub struct GenerateExampleCmd {
1999    #[arg(long, help = "Output file path (default: .env.example)")]
2000    pub output: Option<String>,
2001    #[arg(long, help = "Remove keys from .env.local not in .env.example")]
2002    pub clean: bool,
2003    #[arg(long, help = "Only include vars matching prefix (repeatable)")]
2004    pub include_prefix: Vec<String>,
2005    #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
2006    pub exclude_prefix: Vec<String>,
2007}
2008
2009#[cfg(feature = "secrets")]
2010#[derive(Args, Debug)]
2011pub struct SecretsStoresCmd {
2012    #[command(subcommand)]
2013    pub command: SecretsStoresSubCommand,
2014}
2015
2016#[cfg(feature = "secrets")]
2017#[derive(Subcommand, Debug)]
2018pub enum SecretsStoresSubCommand {
2019    List(CloudflareSecretsStoreListCmd),
2020    Get(CloudflareSecretsStoreGetCmd),
2021    Create(CloudflareSecretsStoreCreateCmd),
2022    Delete(CloudflareSecretsStoreDeleteCmd),
2023}
2024
2025#[cfg(feature = "secrets")]
2026#[derive(Args, Debug)]
2027pub struct CloudflareSecretsStoreListCmd {}
2028
2029#[cfg(feature = "secrets")]
2030#[derive(Args, Debug)]
2031pub struct CloudflareSecretsStoreGetCmd {
2032    #[arg(long)]
2033    pub store_id: String,
2034}
2035
2036#[cfg(feature = "secrets")]
2037#[derive(Args, Debug)]
2038pub struct CloudflareSecretsStoreCreateCmd {
2039    #[arg(long)]
2040    pub name: String,
2041}
2042
2043#[cfg(feature = "secrets")]
2044#[derive(Args, Debug)]
2045pub struct CloudflareSecretsStoreDeleteCmd {
2046    #[arg(long)]
2047    pub store_id: String,
2048}
2049
2050#[cfg(feature = "secrets")]
2051#[derive(Args, Debug)]
2052pub struct CloudflareSecretsCmd {
2053    #[command(subcommand)]
2054    pub command: CloudflareSecretsSubCommand,
2055}
2056
2057#[cfg(feature = "secrets")]
2058#[derive(Subcommand, Debug)]
2059pub enum CloudflareSecretsSubCommand {
2060    List(CloudflareSecretsListCmd),
2061    Get(CloudflareSecretsGetCmd),
2062    Create(CloudflareSecretsCreateCmd),
2063    Edit(CloudflareSecretsEditCmd),
2064    Delete(CloudflareSecretsDeleteCmd),
2065    #[command(name = "delete-bulk")]
2066    DeleteBulk(CloudflareSecretsBulkDeleteCmd),
2067    Duplicate(CloudflareSecretsDuplicateCmd),
2068}
2069
2070#[cfg(feature = "secrets")]
2071#[derive(Args, Debug)]
2072pub struct CloudflareSecretsListCmd {
2073    #[arg(long)]
2074    pub store_id: String,
2075}
2076
2077#[cfg(feature = "secrets")]
2078#[derive(Args, Debug)]
2079pub struct CloudflareSecretsGetCmd {
2080    #[arg(long)]
2081    pub store_id: String,
2082    #[arg(long)]
2083    pub secret_id: String,
2084}
2085
2086#[cfg(feature = "secrets")]
2087#[derive(Args, Debug)]
2088pub struct CloudflareSecretsCreateCmd {
2089    #[arg(long)]
2090    pub store_id: String,
2091    #[arg(long)]
2092    pub name: String,
2093    #[arg(long)]
2094    pub value: String,
2095    #[arg(long, value_delimiter = ',')]
2096    pub scopes: Vec<String>,
2097    #[arg(long)]
2098    pub comment: Option<String>,
2099}
2100
2101#[cfg(feature = "secrets")]
2102#[derive(Args, Debug)]
2103pub struct CloudflareSecretsEditCmd {
2104    #[arg(long)]
2105    pub store_id: String,
2106    #[arg(long)]
2107    pub secret_id: String,
2108    #[arg(long)]
2109    pub name: Option<String>,
2110    #[arg(long)]
2111    pub value: Option<String>,
2112    #[arg(long, value_delimiter = ',')]
2113    pub scopes: Vec<String>,
2114    #[arg(long)]
2115    pub comment: Option<String>,
2116}
2117
2118#[cfg(feature = "secrets")]
2119#[derive(Args, Debug)]
2120pub struct CloudflareSecretsDeleteCmd {
2121    #[arg(long)]
2122    pub store_id: String,
2123    #[arg(long)]
2124    pub secret_id: String,
2125}
2126
2127#[cfg(feature = "secrets")]
2128#[derive(Args, Debug)]
2129pub struct CloudflareSecretsBulkDeleteCmd {
2130    #[arg(long)]
2131    pub store_id: String,
2132    #[arg(long = "secret-id", required = true)]
2133    pub secret_ids: Vec<String>,
2134}
2135
2136#[cfg(feature = "secrets")]
2137#[derive(Args, Debug)]
2138pub struct CloudflareSecretsDuplicateCmd {
2139    #[arg(long)]
2140    pub store_id: String,
2141    #[arg(long)]
2142    pub secret_id: String,
2143    #[arg(long)]
2144    pub name: String,
2145    #[arg(long, value_delimiter = ',')]
2146    pub scopes: Vec<String>,
2147    #[arg(long)]
2148    pub comment: Option<String>,
2149}
2150
2151#[cfg(feature = "secrets")]
2152#[derive(Args, Debug)]
2153pub struct SecretsQuotaCmd {
2154    #[command(subcommand)]
2155    pub command: SecretsQuotaSubCommand,
2156}
2157
2158#[cfg(feature = "secrets")]
2159#[derive(Subcommand, Debug)]
2160pub enum SecretsQuotaSubCommand {
2161    Get(SecretsQuotaGetCmd),
2162}
2163
2164#[cfg(feature = "secrets")]
2165#[derive(Args, Debug)]
2166pub struct SecretsQuotaGetCmd {}
2167
2168const DNS_HELP_TEMPLATE: &str = "\
2169{about-with-newline}\
2170Usage: {usage}\n\n\
2171{all-args}\
2172{after-help}";
2173
2174const DNS_COMMAND_AFTER_HELP: &str = "\
2175Examples:
2176  xbp dns providers
2177  xbp dns zones list --provider cloudflare --account-id acc_123
2178  xbp dns records list --provider cloudflare --zone-id zone_123
2179  xbp dns records create --provider cloudflare --zone-id zone_123 --type A --name api --content 127.0.0.1
2180  xbp dns dnssec get --provider cloudflare --zone-id zone_123
2181  xbp dns settings edit --provider cloudflare --zone-id zone_123 --flatten-all-cnames true
2182
2183Notes:
2184  Start with `xbp dns providers` to see what is implemented today.
2185  Cloudflare auth comes from `--token`, `CLOUDFLARE_API_TOKEN`, `xbp config cloudflare set-key`, or a linked dashboard account after `xbp login` (`xbp config cloudflare login`).";
2186
2187const DNS_ZONES_AFTER_HELP: &str = "\
2188Examples:
2189  xbp dns zones list --provider cloudflare --account-id acc_123
2190  xbp dns zones get --provider cloudflare --zone-id zone_123
2191  xbp dns zones create --provider cloudflare --name example.com --account-id acc_123 --jump-start
2192  xbp dns zones edit --provider cloudflare --zone-id zone_123 --paused true
2193  xbp dns zones delete --provider cloudflare --zone-id zone_123";
2194
2195const DNS_RECORDS_AFTER_HELP: &str = "\
2196Examples:
2197  xbp dns records list --provider cloudflare --zone-id zone_123
2198  xbp dns records get --provider cloudflare --zone-id zone_123 --record-id rec_123
2199  xbp dns records create --provider cloudflare --zone-id zone_123 --type A --name api --content 127.0.0.1
2200  xbp dns records edit --provider cloudflare --zone-id zone_123 --record-id rec_123 --proxied true
2201  xbp dns records import --provider cloudflare --zone-id zone_123 --file zone.txt
2202  xbp dns records export --provider cloudflare --zone-id zone_123 --output zone.txt";
2203
2204const DNS_DNSSEC_AFTER_HELP: &str = "\
2205Examples:
2206  xbp dns dnssec get --provider cloudflare --zone-id zone_123
2207  xbp dns dnssec edit --provider cloudflare --zone-id zone_123 --status active";
2208
2209const DNS_SETTINGS_AFTER_HELP: &str = "\
2210Examples:
2211  xbp dns settings get --provider cloudflare --zone-id zone_123
2212  xbp dns settings edit --provider cloudflare --zone-id zone_123 --flatten-all-cnames true
2213  xbp dns settings edit --provider cloudflare --zone-id zone_123 --nameservers-type custom --nameservers-ns-set 2";
2214
2215const DNS_PROVIDERS_AFTER_HELP: &str = "\
2216Examples:
2217  xbp dns providers
2218
2219What this shows:
2220  Implemented providers are wired into `xbp dns` today.
2221  Planned providers are tracked in the CLI surface but not callable yet.";
2222
2223#[derive(Args, Debug)]
2224#[command(
2225    about = "Manage DNS providers, zones, records, DNSSEC, and provider-level settings",
2226    arg_required_else_help = true,
2227    help_template = DNS_HELP_TEMPLATE,
2228    after_help = DNS_COMMAND_AFTER_HELP
2229)]
2230pub struct DnsCmd {
2231    #[command(subcommand)]
2232    pub command: DnsSubCommand,
2233}
2234
2235#[derive(Subcommand, Debug)]
2236pub enum DnsSubCommand {
2237    #[command(
2238        alias = "ls",
2239        alias = "list",
2240        about = "List supported DNS providers and current implementation status",
2241        after_help = DNS_PROVIDERS_AFTER_HELP
2242    )]
2243    Providers,
2244    #[command(about = "Inspect and manage DNS zones")]
2245    Zones(DnsZonesCmd),
2246    #[command(about = "List, create, edit, import, export, and batch DNS records")]
2247    Records(DnsRecordsCmd),
2248    #[command(about = "Inspect or edit DNSSEC status for a zone")]
2249    Dnssec(DnssecCmd),
2250    #[command(about = "Inspect or edit provider DNS settings for a zone")]
2251    Settings(DnsSettingsCmd),
2252}
2253
2254#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
2255pub enum DnsProviderKind {
2256    Cloudflare,
2257    Hetzner,
2258    Vercel,
2259    Custom,
2260}
2261
2262#[derive(Args, Debug)]
2263#[command(
2264    about = "Inspect and manage DNS zones",
2265    arg_required_else_help = true,
2266    help_template = DNS_HELP_TEMPLATE,
2267    after_help = DNS_ZONES_AFTER_HELP
2268)]
2269pub struct DnsZonesCmd {
2270    #[command(subcommand)]
2271    pub command: DnsZonesSubCommand,
2272}
2273
2274#[derive(Subcommand, Debug)]
2275pub enum DnsZonesSubCommand {
2276    #[command(about = "List zones for a provider account")]
2277    List(DnsZoneListCmd),
2278    #[command(about = "Fetch one zone by id")]
2279    Get(DnsZoneGetCmd),
2280    #[command(about = "Create a new zone")]
2281    Create(DnsZoneCreateCmd),
2282    #[command(about = "Edit zone-level properties")]
2283    Edit(DnsZoneEditCmd),
2284    #[command(about = "Delete a zone")]
2285    Delete(DnsZoneDeleteCmd),
2286}
2287
2288#[derive(Args, Debug)]
2289pub struct DnsZoneListCmd {
2290    #[arg(long, value_enum, default_value = "cloudflare")]
2291    pub provider: DnsProviderKind,
2292    #[arg(long)]
2293    pub account_id: Option<String>,
2294    #[arg(long)]
2295    pub account_name: Option<String>,
2296    #[arg(long = "account-name-op")]
2297    pub account_name_op: Option<String>,
2298    #[arg(long)]
2299    pub name: Option<String>,
2300    #[arg(long = "name-op")]
2301    pub name_op: Option<String>,
2302    #[arg(long)]
2303    pub status: Option<String>,
2304    #[arg(long = "type", value_delimiter = ',')]
2305    pub zone_types: Vec<String>,
2306    #[arg(long)]
2307    pub r#match: Option<String>,
2308    #[arg(long)]
2309    pub order: Option<String>,
2310    #[arg(long)]
2311    pub direction: Option<String>,
2312    #[arg(long)]
2313    pub page: Option<u64>,
2314    #[arg(long = "per-page")]
2315    pub per_page: Option<u64>,
2316    #[arg(long)]
2317    pub token: Option<String>,
2318}
2319
2320#[derive(Args, Debug)]
2321pub struct DnsZoneGetCmd {
2322    #[arg(long, value_enum, default_value = "cloudflare")]
2323    pub provider: DnsProviderKind,
2324    #[arg(long)]
2325    pub zone_id: String,
2326    #[arg(long)]
2327    pub token: Option<String>,
2328}
2329
2330#[derive(Args, Debug)]
2331pub struct DnsZoneCreateCmd {
2332    #[arg(long, value_enum, default_value = "cloudflare")]
2333    pub provider: DnsProviderKind,
2334    #[arg(long)]
2335    pub name: String,
2336    #[arg(long)]
2337    pub account_id: Option<String>,
2338    #[arg(long)]
2339    pub jump_start: bool,
2340    #[arg(long = "type")]
2341    pub zone_type: Option<String>,
2342    #[arg(long)]
2343    pub token: Option<String>,
2344}
2345
2346#[derive(Args, Debug)]
2347pub struct DnsZoneEditCmd {
2348    #[arg(long, value_enum, default_value = "cloudflare")]
2349    pub provider: DnsProviderKind,
2350    #[arg(long)]
2351    pub zone_id: String,
2352    #[arg(long)]
2353    pub paused: Option<bool>,
2354    #[arg(long = "type")]
2355    pub zone_type: Option<String>,
2356    #[arg(long = "vanity-name-server")]
2357    pub vanity_name_servers: Vec<String>,
2358    #[arg(long)]
2359    pub token: Option<String>,
2360}
2361
2362#[derive(Args, Debug)]
2363pub struct DnsZoneDeleteCmd {
2364    #[arg(long, value_enum, default_value = "cloudflare")]
2365    pub provider: DnsProviderKind,
2366    #[arg(long)]
2367    pub zone_id: String,
2368    #[arg(long)]
2369    pub token: Option<String>,
2370}
2371
2372#[derive(Args, Debug)]
2373#[command(
2374    about = "List, create, edit, import, export, and batch DNS records",
2375    arg_required_else_help = true,
2376    help_template = DNS_HELP_TEMPLATE,
2377    after_help = DNS_RECORDS_AFTER_HELP
2378)]
2379pub struct DnsRecordsCmd {
2380    #[command(subcommand)]
2381    pub command: DnsRecordsSubCommand,
2382}
2383
2384#[derive(Subcommand, Debug)]
2385pub enum DnsRecordsSubCommand {
2386    #[command(about = "List records in a zone")]
2387    List(DnsRecordListCmd),
2388    #[command(about = "Fetch one record by id")]
2389    Get(DnsRecordGetCmd),
2390    #[command(about = "Create a new DNS record")]
2391    Create(DnsRecordCreateCmd),
2392    #[command(about = "Replace a record by id with a full payload")]
2393    Replace(DnsRecordReplaceCmd),
2394    #[command(about = "Patch selected fields on a record")]
2395    Edit(DnsRecordEditCmd),
2396    #[command(about = "Delete a record")]
2397    Delete(DnsRecordDeleteCmd),
2398    #[command(about = "Apply a Cloudflare batch record payload from JSON")]
2399    Batch(DnsRecordBatchCmd),
2400    #[command(about = "Import a BIND-style zone file into a zone")]
2401    Import(DnsRecordImportCmd),
2402    #[command(about = "Export a zone as a BIND-style zone file")]
2403    Export(DnsRecordExportCmd),
2404}
2405
2406#[derive(Args, Debug)]
2407pub struct DnsRecordListCmd {
2408    #[arg(long, value_enum, default_value = "cloudflare")]
2409    pub provider: DnsProviderKind,
2410    #[arg(long)]
2411    pub zone_id: String,
2412    #[arg(long = "type")]
2413    pub record_type: Option<String>,
2414    #[arg(long)]
2415    pub name: Option<String>,
2416    #[arg(long)]
2417    pub page: Option<u64>,
2418    #[arg(long = "per-page")]
2419    pub per_page: Option<u64>,
2420    #[arg(long)]
2421    pub token: Option<String>,
2422}
2423
2424#[derive(Args, Debug)]
2425pub struct DnsRecordGetCmd {
2426    #[arg(long, value_enum, default_value = "cloudflare")]
2427    pub provider: DnsProviderKind,
2428    #[arg(long)]
2429    pub zone_id: String,
2430    #[arg(long)]
2431    pub record_id: String,
2432    #[arg(long)]
2433    pub token: Option<String>,
2434}
2435
2436#[derive(Args, Debug)]
2437pub struct DnsRecordCreateCmd {
2438    #[arg(long, value_enum, default_value = "cloudflare")]
2439    pub provider: DnsProviderKind,
2440    #[arg(long)]
2441    pub zone_id: String,
2442    #[arg(long = "type")]
2443    pub record_type: String,
2444    #[arg(long)]
2445    pub name: String,
2446    #[arg(long)]
2447    pub content: String,
2448    #[arg(long)]
2449    pub ttl: Option<u32>,
2450    #[arg(long)]
2451    pub proxied: Option<bool>,
2452    #[arg(long)]
2453    pub priority: Option<u32>,
2454    #[arg(long)]
2455    pub comment: Option<String>,
2456    #[arg(long = "tag")]
2457    pub tags: Vec<String>,
2458    #[arg(long = "data-json")]
2459    pub data_json: Option<String>,
2460    #[arg(long = "settings-json")]
2461    pub settings_json: Option<String>,
2462    #[arg(long)]
2463    pub token: Option<String>,
2464}
2465
2466#[derive(Args, Debug)]
2467pub struct DnsRecordReplaceCmd {
2468    #[command(flatten)]
2469    pub common: DnsRecordCreateCmd,
2470    #[arg(long)]
2471    pub record_id: String,
2472}
2473
2474#[derive(Args, Debug)]
2475pub struct DnsRecordEditCmd {
2476    #[arg(long, value_enum, default_value = "cloudflare")]
2477    pub provider: DnsProviderKind,
2478    #[arg(long)]
2479    pub zone_id: String,
2480    #[arg(long)]
2481    pub record_id: String,
2482    #[arg(long = "type")]
2483    pub record_type: Option<String>,
2484    #[arg(long)]
2485    pub name: Option<String>,
2486    #[arg(long)]
2487    pub content: Option<String>,
2488    #[arg(long)]
2489    pub ttl: Option<u32>,
2490    #[arg(long)]
2491    pub proxied: Option<bool>,
2492    #[arg(long)]
2493    pub priority: Option<u32>,
2494    #[arg(long)]
2495    pub comment: Option<String>,
2496    #[arg(long = "tag")]
2497    pub tags: Vec<String>,
2498    #[arg(long = "data-json")]
2499    pub data_json: Option<String>,
2500    #[arg(long = "settings-json")]
2501    pub settings_json: Option<String>,
2502    #[arg(long)]
2503    pub token: Option<String>,
2504}
2505
2506#[derive(Args, Debug)]
2507pub struct DnsRecordDeleteCmd {
2508    #[arg(long, value_enum, default_value = "cloudflare")]
2509    pub provider: DnsProviderKind,
2510    #[arg(long)]
2511    pub zone_id: String,
2512    #[arg(long)]
2513    pub record_id: String,
2514    #[arg(long)]
2515    pub token: Option<String>,
2516}
2517
2518#[derive(Args, Debug)]
2519pub struct DnsRecordBatchCmd {
2520    #[arg(long, value_enum, default_value = "cloudflare")]
2521    pub provider: DnsProviderKind,
2522    #[arg(long)]
2523    pub zone_id: String,
2524    #[arg(long)]
2525    pub input: PathBuf,
2526    #[arg(long)]
2527    pub token: Option<String>,
2528}
2529
2530#[derive(Args, Debug)]
2531pub struct DnsRecordImportCmd {
2532    #[arg(long, value_enum, default_value = "cloudflare")]
2533    pub provider: DnsProviderKind,
2534    #[arg(long)]
2535    pub zone_id: String,
2536    #[arg(long)]
2537    pub file: PathBuf,
2538    #[arg(long)]
2539    pub token: Option<String>,
2540}
2541
2542#[derive(Args, Debug)]
2543pub struct DnsRecordExportCmd {
2544    #[arg(long, value_enum, default_value = "cloudflare")]
2545    pub provider: DnsProviderKind,
2546    #[arg(long)]
2547    pub zone_id: String,
2548    #[arg(long)]
2549    pub output: Option<PathBuf>,
2550    #[arg(long)]
2551    pub token: Option<String>,
2552}
2553
2554#[derive(Args, Debug)]
2555#[command(
2556    about = "Inspect or edit DNSSEC status for a zone",
2557    arg_required_else_help = true,
2558    help_template = DNS_HELP_TEMPLATE,
2559    after_help = DNS_DNSSEC_AFTER_HELP
2560)]
2561pub struct DnssecCmd {
2562    #[command(subcommand)]
2563    pub command: DnssecSubCommand,
2564}
2565
2566#[derive(Subcommand, Debug)]
2567pub enum DnssecSubCommand {
2568    #[command(about = "Fetch DNSSEC state for a zone")]
2569    Get(DnssecGetCmd),
2570    #[command(about = "Edit DNSSEC-related flags for a zone")]
2571    Edit(DnssecEditCmd),
2572}
2573
2574#[derive(Args, Debug)]
2575pub struct DnssecGetCmd {
2576    #[arg(long, value_enum, default_value = "cloudflare")]
2577    pub provider: DnsProviderKind,
2578    #[arg(long)]
2579    pub zone_id: String,
2580    #[arg(long)]
2581    pub token: Option<String>,
2582}
2583
2584#[derive(Args, Debug)]
2585pub struct DnssecEditCmd {
2586    #[arg(long, value_enum, default_value = "cloudflare")]
2587    pub provider: DnsProviderKind,
2588    #[arg(long)]
2589    pub zone_id: String,
2590    #[arg(long)]
2591    pub status: Option<String>,
2592    #[arg(long = "dnssec-multi-signer")]
2593    pub dnssec_multi_signer: Option<bool>,
2594    #[arg(long = "dnssec-presigned")]
2595    pub dnssec_presigned: Option<bool>,
2596    #[arg(long = "dnssec-use-nsec3")]
2597    pub dnssec_use_nsec3: Option<bool>,
2598    #[arg(long)]
2599    pub token: Option<String>,
2600}
2601
2602#[derive(Args, Debug)]
2603#[command(
2604    about = "Inspect or edit provider DNS settings for a zone",
2605    arg_required_else_help = true,
2606    help_template = DNS_HELP_TEMPLATE,
2607    after_help = DNS_SETTINGS_AFTER_HELP
2608)]
2609pub struct DnsSettingsCmd {
2610    #[command(subcommand)]
2611    pub command: DnsSettingsSubCommand,
2612}
2613
2614#[derive(Subcommand, Debug)]
2615pub enum DnsSettingsSubCommand {
2616    #[command(about = "Fetch provider DNS settings for a zone")]
2617    Get(DnsSettingsGetCmd),
2618    #[command(about = "Edit provider DNS settings for a zone")]
2619    Edit(DnsSettingsEditCmd),
2620}
2621
2622#[derive(Args, Debug)]
2623pub struct DnsSettingsGetCmd {
2624    #[arg(long, value_enum, default_value = "cloudflare")]
2625    pub provider: DnsProviderKind,
2626    #[arg(long)]
2627    pub zone_id: String,
2628    #[arg(long)]
2629    pub token: Option<String>,
2630}
2631
2632#[derive(Args, Debug)]
2633pub struct DnsSettingsEditCmd {
2634    #[arg(long, value_enum, default_value = "cloudflare")]
2635    pub provider: DnsProviderKind,
2636    #[arg(long)]
2637    pub zone_id: String,
2638    #[arg(long)]
2639    pub flatten_all_cnames: Option<bool>,
2640    #[arg(long)]
2641    pub foundation_dns: Option<bool>,
2642    #[arg(long)]
2643    pub multi_provider: Option<bool>,
2644    #[arg(long)]
2645    pub ns_ttl: Option<u32>,
2646    #[arg(long)]
2647    pub secondary_overrides: Option<bool>,
2648    #[arg(long)]
2649    pub zone_mode: Option<String>,
2650    #[arg(long = "reference-zone-id")]
2651    pub reference_zone_id: Option<String>,
2652    #[arg(long = "nameservers-type")]
2653    pub nameservers_type: Option<String>,
2654    #[arg(long = "nameservers-ns-set")]
2655    pub nameservers_ns_set: Option<u32>,
2656    #[arg(long = "soa-json")]
2657    pub soa_json: Option<String>,
2658    #[arg(long)]
2659    pub token: Option<String>,
2660}
2661
2662#[derive(Args, Debug)]
2663pub struct DomainsCmd {
2664    #[arg(long, value_enum, default_value = "cloudflare")]
2665    pub provider: DomainsProviderKind,
2666    #[arg(long)]
2667    pub account_id: Option<String>,
2668    #[arg(long)]
2669    pub token: Option<String>,
2670    #[command(subcommand)]
2671    pub command: DomainsSubCommand,
2672}
2673
2674#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
2675pub enum DomainsProviderKind {
2676    Cloudflare,
2677}
2678
2679#[derive(Subcommand, Debug)]
2680pub enum DomainsSubCommand {
2681    Search(DomainsSearchCmd),
2682    Check(DomainsCheckCmd),
2683    List(DomainsListCmd),
2684}
2685
2686#[derive(Args, Debug)]
2687pub struct DomainsSearchCmd {
2688    #[arg(long)]
2689    pub query: String,
2690    #[arg(long = "extension")]
2691    pub extensions: Vec<String>,
2692    #[arg(long)]
2693    pub limit: Option<usize>,
2694}
2695
2696#[derive(Args, Debug)]
2697pub struct DomainsCheckCmd {
2698    #[arg(long = "domain", required = true)]
2699    pub domains: Vec<String>,
2700}
2701
2702#[derive(Args, Debug)]
2703pub struct DomainsListCmd {}
2704
2705#[derive(Args, Debug)]
2706pub struct GenerateSystemdCmd {
2707    #[arg(
2708        long,
2709        default_value = "/etc/systemd/system",
2710        help = "Directory where the systemd units are written"
2711    )]
2712    pub output_dir: PathBuf,
2713    #[arg(long, help = "Only generate the unit for this service name")]
2714    pub service: Option<String>,
2715    #[arg(
2716        long,
2717        default_value_t = true,
2718        help = "Also generate the xbp-api systemd unit alongside project/services"
2719    )]
2720    pub api: bool,
2721}
2722
2723#[derive(Args, Debug)]
2724pub struct DoneCmd {
2725    #[arg(long, help = "Root directory under which to discover git repos")]
2726    pub root: Option<std::path::PathBuf>,
2727    #[arg(
2728        long,
2729        default_value = "24 hours ago",
2730        help = "Git --since value (e.g. '7 days ago')"
2731    )]
2732    pub since: String,
2733    #[arg(short, long, help = "Output Markdown file path")]
2734    pub output: Option<std::path::PathBuf>,
2735    #[arg(long, help = "Skip AI summarization (OpenRouter)")]
2736    pub no_ai: bool,
2737    #[arg(short, long, help = "Discover repos recursively")]
2738    pub recursive: bool,
2739    #[arg(long, help = "Exclude repo by name (repeatable)")]
2740    pub exclude: Vec<String>,
2741}
2742
2743#[derive(Args, Debug)]
2744pub struct FixProcessMonitorJsonCmd {
2745    #[arg(help = "Path to a Cursor process-monitor JSON export")]
2746    pub path: std::path::PathBuf,
2747    #[arg(
2748        long,
2749        help = "Check whether the file needs repair without writing changes"
2750    )]
2751    pub check: bool,
2752    #[arg(
2753        long,
2754        help = "Print repaired JSON to stdout instead of overwriting the file"
2755    )]
2756    pub stdout: bool,
2757}
2758
2759#[derive(Args, Debug)]
2760pub struct CursorCmd {
2761    #[command(subcommand)]
2762    pub command: CursorSubCommand,
2763}
2764
2765#[derive(Subcommand, Debug)]
2766pub enum CursorSubCommand {
2767    #[command(about = "Upload local Cursor file history to the XBP dashboard")]
2768    Ingest {
2769        #[arg(
2770            long,
2771            help = "Scan local Cursor history without uploading to the dashboard"
2772        )]
2773        dry_run: bool,
2774    },
2775}
2776
2777#[cfg(feature = "nordvpn")]
2778#[derive(Args, Debug)]
2779pub struct NordvpnCmd {
2780    #[arg(
2781        trailing_var_arg = true,
2782        allow_hyphen_values = true,
2783        help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
2784    )]
2785    pub args: Vec<String>,
2786}
2787
2788#[cfg(feature = "kubernetes")]
2789#[derive(Args, Debug)]
2790pub struct KubernetesCmd {
2791    #[command(subcommand)]
2792    pub command: KubernetesSubCommand,
2793}
2794
2795#[cfg(feature = "kubernetes")]
2796#[derive(Args, Debug)]
2797pub struct KubernetesAddonCmd {
2798    #[command(subcommand)]
2799    pub command: KubernetesAddonSubCommand,
2800}
2801
2802#[cfg(feature = "kubernetes")]
2803#[derive(Subcommand, Debug)]
2804pub enum KubernetesAddonSubCommand {
2805    /// Show complete addon status (enabled/disabled) from `microk8s status`
2806    List,
2807    /// Enable a MicroK8s addon
2808    Enable {
2809        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
2810        name: String,
2811    },
2812    /// Disable a MicroK8s addon
2813    Disable {
2814        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
2815        name: String,
2816    },
2817}
2818
2819#[cfg(feature = "kubernetes")]
2820#[derive(Subcommand, Debug)]
2821pub enum KubernetesSubCommand {
2822    /// Validate kubectl, current context, and node readiness
2823    Check {
2824        #[arg(long, help = "Kubeconfig context to target")]
2825        context: Option<String>,
2826        #[arg(
2827            long,
2828            default_value = "default",
2829            help = "Namespace to probe for workload readiness"
2830        )]
2831        namespace: String,
2832        #[arg(long, help = "Skip live cluster calls (tooling check only)")]
2833        offline: bool,
2834    },
2835    /// Generate Deployment/Service/NetworkPolicy YAML
2836    Generate {
2837        #[arg(long, help = "Logical app name (used for resource names)")]
2838        name: String,
2839        #[arg(long, help = "Container image reference")]
2840        image: String,
2841        #[arg(long, default_value_t = 80, help = "Container port for the service")]
2842        port: u16,
2843        #[arg(long, default_value_t = 1, help = "Replica count")]
2844        replicas: u16,
2845        #[arg(
2846            long,
2847            default_value = "default",
2848            help = "Namespace for generated resources"
2849        )]
2850        namespace: String,
2851        #[arg(
2852            long,
2853            default_value = "k8s/xbp-manifest.yaml",
2854            help = "Path to write the manifest bundle"
2855        )]
2856        output: String,
2857        #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
2858        host: Option<String>,
2859    },
2860    /// Apply a manifest bundle with kubectl apply -f
2861    Apply {
2862        #[arg(long, help = "Path to manifest file")]
2863        file: String,
2864        #[arg(long, help = "Override kube context")]
2865        context: Option<String>,
2866        #[arg(long, help = "Override namespace")]
2867        namespace: Option<String>,
2868        #[arg(long, help = "Use --dry-run=server")]
2869        dry_run: bool,
2870    },
2871    /// Summarize deployments/services/pods in a namespace
2872    Status {
2873        #[arg(long, default_value = "default", help = "Namespace to summarize")]
2874        namespace: String,
2875        #[arg(long, help = "Override kube context")]
2876        context: Option<String>,
2877    },
2878    /// Manage MicroK8s addons (list, enable, disable)
2879    Addons(KubernetesAddonCmd),
2880    /// Extract Kubernetes Dashboard login token from secret describe output
2881    DashboardToken {
2882        #[arg(
2883            long,
2884            default_value = "kube-system",
2885            help = "Namespace containing the dashboard token secret"
2886        )]
2887        namespace: String,
2888        #[arg(
2889            long,
2890            default_value = "microk8s-dashboard-token",
2891            help = "Secret name containing the dashboard login token"
2892        )]
2893        secret: String,
2894        #[arg(long, help = "Override kube context")]
2895        context: Option<String>,
2896    },
2897    /// Print decoded Grafana admin credentials from observability secret
2898    ObservabilityCreds {
2899        #[arg(
2900            long,
2901            default_value = "observability",
2902            help = "Namespace containing Grafana secret"
2903        )]
2904        namespace: String,
2905        #[arg(
2906            long,
2907            default_value = "kube-prom-stack-grafana",
2908            help = "Grafana secret name"
2909        )]
2910        secret: String,
2911        #[arg(long, help = "Override kube context")]
2912        context: Option<String>,
2913    },
2914    /// Create or update a cert-manager Issuer for Let's Encrypt
2915    Issuer {
2916        #[arg(
2917            long,
2918            help = "Email used for Let's Encrypt account registration (required)"
2919        )]
2920        email: String,
2921        #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
2922        name: String,
2923        #[arg(
2924            long,
2925            default_value = "default",
2926            help = "Namespace for the Issuer resource"
2927        )]
2928        namespace: String,
2929        #[arg(
2930            long,
2931            default_value = "https://acme-v02.api.letsencrypt.org/directory",
2932            help = "ACME server URL (production by default)"
2933        )]
2934        server: String,
2935        #[arg(
2936            long,
2937            default_value = "letsencrypt-account-key",
2938            help = "Secret used to store the ACME account private key"
2939        )]
2940        private_key_secret: String,
2941        #[arg(
2942            long,
2943            default_value = "nginx",
2944            help = "Ingress class name used for HTTP01 solving"
2945        )]
2946        ingress_class_name: String,
2947        #[arg(long, help = "Override kube context")]
2948        context: Option<String>,
2949        #[arg(long, help = "Use --dry-run=server")]
2950        dry_run: bool,
2951    },
2952}
2953
2954#[cfg(test)]
2955mod tests {
2956    use super::{
2957        Cli, CloudflareConfigAction, CloudflaredSubCommand, Commands, DnsProviderKind,
2958        DnsSubCommand, DnsZonesSubCommand, DomainsProviderKind, DomainsSubCommand,
2959        GenerateSubCommand, LinearConfigAction, NetworkFloatingIpSubCommand,
2960        NetworkHetznerSubCommand, NetworkHetznerVswitchSubCommand, NetworkSubCommand, SshCmd,
2961    };
2962    #[cfg(feature = "secrets")]
2963    use super::{
2964        CloudflareSecretsSubCommand, SecretsProviderKind, SecretsStoresSubCommand,
2965        SecretsSubCommand,
2966    };
2967    use clap::Parser;
2968    use std::path::PathBuf;
2969
2970    #[test]
2971    fn parses_network_floating_ip_add() {
2972        let cli = Cli::parse_from([
2973            "xbp",
2974            "network",
2975            "floating-ip",
2976            "add",
2977            "--ip",
2978            "1.2.3.4",
2979            "--apply",
2980        ]);
2981
2982        match cli.command {
2983            Some(Commands::Network(network)) => match network.command {
2984                NetworkSubCommand::FloatingIp(fip) => match fip.command {
2985                    NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
2986                        assert_eq!(ip, "1.2.3.4");
2987                        assert!(apply);
2988                    }
2989                    _ => panic!("expected add subcommand"),
2990                },
2991                _ => panic!("expected floating-ip subcommand"),
2992            },
2993            _ => panic!("expected network command"),
2994        }
2995    }
2996
2997    #[test]
2998    fn parses_generate_config_update() {
2999        let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
3000
3001        match cli.command {
3002            Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
3003                GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
3004                _ => panic!("expected generate config command"),
3005            },
3006            _ => panic!("expected generate command"),
3007        }
3008    }
3009
3010    #[test]
3011    fn parses_commit_command_with_dry_run() {
3012        let cli = Cli::parse_from(["xbp", "commit", "--dry-run", "--scope", "cli"]);
3013
3014        match cli.command {
3015            Some(Commands::Commit(commit_cmd)) => {
3016                assert!(commit_cmd.dry_run);
3017                assert_eq!(commit_cmd.scope.as_deref(), Some("cli"));
3018                assert_eq!(commit_cmd.model, None);
3019            }
3020            _ => panic!("expected commit command"),
3021        }
3022    }
3023
3024    #[test]
3025    fn parses_linear_select_initiative_config_command() {
3026        let cli = Cli::parse_from(["xbp", "config", "linear", "select-initiative"]);
3027
3028        match cli.command {
3029            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
3030                Some(super::ConfigProviderCmd::Linear(linear_cmd)) => {
3031                    assert!(matches!(
3032                        linear_cmd.action,
3033                        LinearConfigAction::SelectInitiative
3034                    ));
3035                }
3036                _ => panic!("expected linear config provider"),
3037            },
3038            _ => panic!("expected config command"),
3039        }
3040    }
3041
3042    #[test]
3043    fn parses_ssh_command_with_cloudflared_and_key_auth() {
3044        let cli = Cli::parse_from([
3045            "xbp",
3046            "ssh",
3047            "--host",
3048            "ssh.internal",
3049            "--username",
3050            "deploy",
3051            "--private-key",
3052            "C:/Users/floris/.ssh/id_ed25519",
3053            "--cloudflared-hostname",
3054            "bastion.example.com",
3055            "--command",
3056            "htop",
3057        ]);
3058
3059        let Some(Commands::Ssh(SshCmd {
3060            ssh_host,
3061            ssh_username,
3062            private_key,
3063            cloudflared_hostname,
3064            command,
3065            ..
3066        })) = cli.command
3067        else {
3068            panic!("expected shell command");
3069        };
3070
3071        assert_eq!(ssh_host.as_deref(), Some("ssh.internal"));
3072        assert_eq!(ssh_username.as_deref(), Some("deploy"));
3073        assert_eq!(
3074            private_key,
3075            Some(PathBuf::from("C:/Users/floris/.ssh/id_ed25519"))
3076        );
3077        assert_eq!(cloudflared_hostname.as_deref(), Some("bastion.example.com"));
3078        assert_eq!(command.as_deref(), Some("htop"));
3079    }
3080
3081    #[test]
3082    fn parses_cloudflared_tcp_command() {
3083        let cli = Cli::parse_from([
3084            "xbp",
3085            "cloudflared",
3086            "tcp",
3087            "--hostname",
3088            "bastion.example.com",
3089            "--listener",
3090            "127.0.0.1:2222",
3091        ]);
3092
3093        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
3094            panic!("expected cloudflared command");
3095        };
3096
3097        match cloudflared_cmd.command {
3098            CloudflaredSubCommand::Tcp(tcp_cmd) => {
3099                assert_eq!(tcp_cmd.hostname.as_deref(), Some("bastion.example.com"));
3100                assert_eq!(tcp_cmd.listener.as_deref(), Some("127.0.0.1:2222"));
3101            }
3102        }
3103    }
3104
3105    #[test]
3106    fn parses_cloudflared_tcp_without_hostname_for_handler_validation() {
3107        let cli = Cli::try_parse_from(["xbp", "cloudflared", "tcp"]).expect("parse");
3108
3109        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
3110            panic!("expected cloudflared command");
3111        };
3112
3113        match cloudflared_cmd.command {
3114            CloudflaredSubCommand::Tcp(tcp_cmd) => {
3115                assert_eq!(tcp_cmd.hostname, None);
3116                assert_eq!(tcp_cmd.listener, None);
3117            }
3118        }
3119    }
3120
3121    #[test]
3122    fn parses_version_workspace_publish_run_command() {
3123        let cli = Cli::parse_from([
3124            "xbp",
3125            "version",
3126            "workspace",
3127            "publish",
3128            "run",
3129            "--repo",
3130            "C:/Users/floris/Documents/GitHub/athena",
3131            "--dry-run",
3132            "--from",
3133            "athena-s3",
3134        ]);
3135
3136        let Some(Commands::Version(version_cmd)) = cli.command else {
3137            panic!("expected version command");
3138        };
3139
3140        match version_cmd.command {
3141            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
3142                match workspace_cmd.command {
3143                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
3144                        match publish_cmd.command {
3145                            super::VersionWorkspacePublishSubCommand::Run(run_cmd) => {
3146                                assert_eq!(
3147                                    run_cmd.target.repo,
3148                                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub/athena"))
3149                                );
3150                                assert!(!run_cmd.target.json);
3151                                assert!(run_cmd.dry_run);
3152                                assert_eq!(run_cmd.from.as_deref(), Some("athena-s3"));
3153                            }
3154                            _ => panic!("expected publish run"),
3155                        }
3156                    }
3157                    _ => panic!("expected workspace publish"),
3158                }
3159            }
3160            _ => panic!("expected version workspace command"),
3161        }
3162    }
3163
3164    #[test]
3165    fn parses_version_workspace_publish_plan_with_only_and_include_prereqs() {
3166        let cli = Cli::parse_from([
3167            "xbp",
3168            "version",
3169            "workspace",
3170            "publish",
3171            "plan",
3172            "--repo",
3173            "C:/Users/floris/Documents/GitHub/athena-auth",
3174            "--only",
3175            "athena-auth",
3176            "--include-prereqs",
3177        ]);
3178
3179        let Some(Commands::Version(version_cmd)) = cli.command else {
3180            panic!("expected version command");
3181        };
3182
3183        match version_cmd.command {
3184            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
3185                match workspace_cmd.command {
3186                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
3187                        match publish_cmd.command {
3188                            super::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => {
3189                                assert_eq!(
3190                                    plan_cmd.target.repo,
3191                                    Some(PathBuf::from(
3192                                        "C:/Users/floris/Documents/GitHub/athena-auth"
3193                                    ))
3194                                );
3195                                assert_eq!(plan_cmd.only.as_deref(), Some("athena-auth"));
3196                                assert!(plan_cmd.include_prereqs);
3197                            }
3198                            _ => panic!("expected publish plan"),
3199                        }
3200                    }
3201                    _ => panic!("expected workspace publish"),
3202                }
3203            }
3204            _ => panic!("expected version workspace command"),
3205        }
3206    }
3207
3208    #[test]
3209    fn parses_commit_alias_with_push_flag() {
3210        let cli = Cli::parse_from(["xbp", "c", "-p"]);
3211
3212        let Some(Commands::Commit(commit_cmd)) = cli.command else {
3213            panic!("expected commit command");
3214        };
3215
3216        assert!(commit_cmd.push);
3217        assert!(!commit_cmd.dry_run);
3218    }
3219
3220    #[test]
3221    fn parses_version_alias_release_alias() {
3222        let cli = Cli::parse_from(["xbp", "v", "r", "--draft", "--publish", "--force"]);
3223
3224        let Some(Commands::Version(version_cmd)) = cli.command else {
3225            panic!("expected version command");
3226        };
3227
3228        let Some(super::VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
3229            panic!("expected release subcommand");
3230        };
3231
3232        assert!(release_cmd.draft);
3233        assert!(release_cmd.publish);
3234        assert!(release_cmd.force);
3235    }
3236
3237    #[test]
3238    fn parses_publish_command_target_filter() {
3239        let cli = Cli::parse_from([
3240            "xbp",
3241            "publish",
3242            "--allow-dirty",
3243            "--force",
3244            "--include-prereqs",
3245            "--target",
3246            "npm",
3247            "--manifest-path",
3248            "apps/web/package.json",
3249        ]);
3250
3251        let Some(Commands::Publish(publish_cmd)) = cli.command else {
3252            panic!("expected publish command");
3253        };
3254
3255        assert!(publish_cmd.allow_dirty);
3256        assert!(publish_cmd.force);
3257        assert!(publish_cmd.include_prereqs);
3258        assert_eq!(publish_cmd.target.as_deref(), Some("npm"));
3259        assert_eq!(
3260            publish_cmd.manifest_path,
3261            Some(PathBuf::from("apps/web/package.json"))
3262        );
3263    }
3264
3265    #[test]
3266    fn parses_npm_setup_release_config_command() {
3267        let cli = Cli::parse_from(["xbp", "config", "npm", "setup-release"]);
3268
3269        let Some(Commands::Config(config_cmd)) = cli.command else {
3270            panic!("expected config command");
3271        };
3272        let Some(super::ConfigProviderCmd::Npm(registry_cmd)) = config_cmd.provider else {
3273            panic!("expected npm config command");
3274        };
3275
3276        assert!(matches!(
3277            registry_cmd.action,
3278            super::RegistryConfigAction::SetupRelease
3279        ));
3280    }
3281
3282    #[test]
3283    fn parses_crates_login_config_command() {
3284        let cli = Cli::parse_from(["xbp", "config", "crates", "login"]);
3285
3286        let Some(Commands::Config(config_cmd)) = cli.command else {
3287            panic!("expected config command");
3288        };
3289        let Some(super::ConfigProviderCmd::Crates(crates_cmd)) = config_cmd.provider else {
3290            panic!("expected crates config command");
3291        };
3292
3293        assert!(matches!(
3294            crates_cmd.action,
3295            super::CratesConfigAction::Login { .. }
3296        ));
3297    }
3298
3299    #[test]
3300    fn parses_crates_logout_config_command() {
3301        let cli = Cli::parse_from(["xbp", "config", "crates", "logout"]);
3302
3303        let Some(Commands::Config(config_cmd)) = cli.command else {
3304            panic!("expected config command");
3305        };
3306        let Some(super::ConfigProviderCmd::Crates(crates_cmd)) = config_cmd.provider else {
3307            panic!("expected crates config command");
3308        };
3309
3310        assert!(matches!(
3311            crates_cmd.action,
3312            super::CratesConfigAction::Logout
3313        ));
3314    }
3315
3316    #[test]
3317    fn parses_shell_alias_as_ssh_command() {
3318        let cli = Cli::parse_from(["xbp", "shell", "--host", "ssh.internal"]);
3319
3320        let Some(Commands::Ssh(ssh_cmd)) = cli.command else {
3321            panic!("expected ssh command through shell alias");
3322        };
3323
3324        assert_eq!(ssh_cmd.ssh_host.as_deref(), Some("ssh.internal"));
3325    }
3326
3327    #[test]
3328    fn parses_api_request_command() {
3329        let cli = Cli::parse_from([
3330            "xbp",
3331            "api",
3332            "request",
3333            "/api/registry/installers/python-pip",
3334            "--web",
3335            "--method",
3336            "GET",
3337            "--header",
3338            "accept: application/json",
3339        ]);
3340
3341        let Some(Commands::Api(api_cmd)) = cli.command else {
3342            panic!("expected api command");
3343        };
3344
3345        match api_cmd.command {
3346            super::ApiSubCommand::Request(request_cmd) => {
3347                assert_eq!(request_cmd.path, "/api/registry/installers/python-pip");
3348                assert!(request_cmd.target.web);
3349                assert_eq!(request_cmd.method.as_deref(), Some("GET"));
3350                assert_eq!(
3351                    request_cmd.target.header,
3352                    vec!["accept: application/json".to_string()]
3353                );
3354            }
3355            _ => panic!("expected api request subcommand"),
3356        }
3357    }
3358
3359    #[test]
3360    fn parses_api_projects_list_command() {
3361        let cli = Cli::parse_from([
3362            "xbp",
3363            "api",
3364            "projects",
3365            "list",
3366            "--organization-id",
3367            "org_123",
3368        ]);
3369
3370        let Some(Commands::Api(api_cmd)) = cli.command else {
3371            panic!("expected api command");
3372        };
3373
3374        match api_cmd.command {
3375            super::ApiSubCommand::Projects(projects_cmd) => match projects_cmd.command {
3376                super::ApiProjectsSubCommand::List(list_cmd) => {
3377                    assert_eq!(list_cmd.organization_id.as_deref(), Some("org_123"));
3378                }
3379                _ => panic!("expected projects list subcommand"),
3380            },
3381            _ => panic!("expected projects subcommand"),
3382        }
3383    }
3384
3385    #[test]
3386    fn parses_api_routes_create_command() {
3387        let cli = Cli::parse_from([
3388            "xbp",
3389            "api",
3390            "routes",
3391            "create",
3392            "--domain",
3393            "demo.local",
3394            "--target",
3395            "http://127.0.0.1:3000",
3396            "--weighted-target",
3397            "http://127.0.0.1:3001=3",
3398            "--base-url",
3399            "http://127.0.0.1:8080",
3400        ]);
3401
3402        let Some(Commands::Api(api_cmd)) = cli.command else {
3403            panic!("expected api command");
3404        };
3405
3406        match api_cmd.command {
3407            super::ApiSubCommand::Routes(routes_cmd) => match routes_cmd.command {
3408                super::ApiRoutesSubCommand::Create(create_cmd) => {
3409                    assert_eq!(create_cmd.domain, "demo.local");
3410                    assert_eq!(create_cmd.target, vec!["http://127.0.0.1:3000".to_string()]);
3411                    assert_eq!(
3412                        create_cmd.weighted_target,
3413                        vec!["http://127.0.0.1:3001=3".to_string()]
3414                    );
3415                    assert_eq!(
3416                        create_cmd.target_options.base_url.as_deref(),
3417                        Some("http://127.0.0.1:8080")
3418                    );
3419                }
3420                _ => panic!("expected routes create subcommand"),
3421            },
3422            _ => panic!("expected routes subcommand"),
3423        }
3424    }
3425
3426    #[test]
3427    fn parses_hetzner_vswitch_setup_command() {
3428        let cli = Cli::parse_from([
3429            "xbp",
3430            "network",
3431            "hetzner",
3432            "vswitch",
3433            "setup",
3434            "--ip",
3435            "10.0.3.2",
3436            "--vlan-id",
3437            "4000",
3438            "--interface",
3439            "enp0s31f6",
3440            "--apply",
3441        ]);
3442
3443        let Some(Commands::Network(network_cmd)) = cli.command else {
3444            panic!("expected network command");
3445        };
3446
3447        match network_cmd.command {
3448            NetworkSubCommand::Hetzner(hetzner_cmd) => match hetzner_cmd.command {
3449                NetworkHetznerSubCommand::Vswitch(vswitch_cmd) => match vswitch_cmd.command {
3450                    NetworkHetznerVswitchSubCommand::Setup {
3451                        ip,
3452                        cidr,
3453                        interface,
3454                        vlan_id,
3455                        apply,
3456                        ..
3457                    } => {
3458                        assert_eq!(ip, "10.0.3.2");
3459                        assert_eq!(cidr, 24);
3460                        assert_eq!(interface.as_deref(), Some("enp0s31f6"));
3461                        assert_eq!(vlan_id, 4000);
3462                        assert!(apply);
3463                    }
3464                },
3465            },
3466            _ => panic!("expected hetzner subcommand"),
3467        }
3468    }
3469
3470    #[cfg(feature = "secrets")]
3471    #[test]
3472    fn parses_secrets_diag_command() {
3473        let cli = Cli::parse_from(["xbp", "secrets", "diag"]);
3474
3475        match cli.command {
3476            Some(Commands::Secrets(secrets_cmd)) => {
3477                assert!(matches!(secrets_cmd.command, Some(SecretsSubCommand::Diag)));
3478                assert_eq!(secrets_cmd.environment, "xbp-dev");
3479            }
3480            _ => panic!("expected secrets command"),
3481        }
3482    }
3483
3484    #[cfg(feature = "secrets")]
3485    #[test]
3486    fn parses_secrets_environment_override() {
3487        let cli = Cli::parse_from(["xbp", "secrets", "--environment", "xbp-prod", "push"]);
3488
3489        match cli.command {
3490            Some(Commands::Secrets(secrets_cmd)) => {
3491                assert_eq!(secrets_cmd.environment, "xbp-prod");
3492                assert!(matches!(
3493                    secrets_cmd.command,
3494                    Some(SecretsSubCommand::Push(_))
3495                ));
3496            }
3497            _ => panic!("expected secrets command"),
3498        }
3499    }
3500
3501    #[test]
3502    fn parses_version_discover_command() {
3503        let cli = Cli::parse_from(["xbp", "version", "discover", "--dry-run"]);
3504
3505        match cli.command {
3506            Some(Commands::Version(version_cmd)) => match version_cmd.command {
3507                Some(super::VersionSubCommand::Discover(discover_cmd)) => {
3508                    assert!(discover_cmd.dry_run);
3509                    assert!(!discover_cmd.no_register);
3510                }
3511                _ => panic!("expected version discover subcommand"),
3512            },
3513            _ => panic!("expected version command"),
3514        }
3515    }
3516
3517    #[cfg(feature = "secrets")]
3518    #[test]
3519    fn parses_secrets_providers_command() {
3520        let cli = Cli::parse_from(["xbp", "secrets", "providers"]);
3521
3522        match cli.command {
3523            Some(Commands::Secrets(secrets_cmd)) => {
3524                assert!(matches!(
3525                    secrets_cmd.command,
3526                    Some(SecretsSubCommand::Providers)
3527                ));
3528                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Github);
3529            }
3530            _ => panic!("expected secrets command"),
3531        }
3532    }
3533
3534    #[cfg(feature = "secrets")]
3535    #[test]
3536    fn parses_cloudflare_secret_store_create() {
3537        let cli = Cli::parse_from([
3538            "xbp",
3539            "secrets",
3540            "--provider",
3541            "cloudflare",
3542            "stores",
3543            "create",
3544            "--name",
3545            "prod",
3546        ]);
3547
3548        match cli.command {
3549            Some(Commands::Secrets(secrets_cmd)) => {
3550                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Cloudflare);
3551                match secrets_cmd.command {
3552                    Some(SecretsSubCommand::Stores(stores_cmd)) => {
3553                        assert!(matches!(
3554                            stores_cmd.command,
3555                            SecretsStoresSubCommand::Create(_)
3556                        ));
3557                    }
3558                    _ => panic!("expected stores subcommand"),
3559                }
3560            }
3561            _ => panic!("expected secrets command"),
3562        }
3563    }
3564
3565    #[cfg(feature = "secrets")]
3566    #[test]
3567    fn parses_cloudflare_secret_duplicate() {
3568        let cli = Cli::parse_from([
3569            "xbp",
3570            "secrets",
3571            "--provider",
3572            "cloudflare",
3573            "secrets",
3574            "duplicate",
3575            "--store-id",
3576            "store_1",
3577            "--secret-id",
3578            "secret_1",
3579            "--name",
3580            "COPY",
3581        ]);
3582
3583        match cli.command {
3584            Some(Commands::Secrets(secrets_cmd)) => match secrets_cmd.command {
3585                Some(SecretsSubCommand::Secrets(secrets_cmd)) => {
3586                    assert!(matches!(
3587                        secrets_cmd.command,
3588                        CloudflareSecretsSubCommand::Duplicate(_)
3589                    ));
3590                }
3591                _ => panic!("expected cloudflare secrets subcommand"),
3592            },
3593            _ => panic!("expected secrets command"),
3594        }
3595    }
3596
3597    #[test]
3598    fn parses_workers_secret_put_from_stdin_command() {
3599        let cli = Cli::parse_from([
3600            "xbp",
3601            "workers",
3602            "secrets",
3603            "--environment",
3604            "production",
3605            "put",
3606            "--name",
3607            "API_KEY",
3608            "--from-stdin",
3609        ]);
3610
3611        let Some(Commands::Workers(workers_cmd)) = cli.command else {
3612            panic!("expected workers command");
3613        };
3614
3615        match workers_cmd.command {
3616            super::WorkersSubCommand::Secrets(secrets_cmd) => {
3617                assert_eq!(
3618                    secrets_cmd.target.environment.as_deref(),
3619                    Some("production")
3620                );
3621                match secrets_cmd.command {
3622                    super::WorkersSecretsSubCommand::Put(put_cmd) => {
3623                        assert_eq!(put_cmd.name, "API_KEY");
3624                        assert!(put_cmd.from_stdin);
3625                        assert_eq!(put_cmd.value, None);
3626                    }
3627                    _ => panic!("expected workers secret put"),
3628                }
3629            }
3630            _ => panic!("expected workers secrets command"),
3631        }
3632    }
3633
3634    #[test]
3635    fn parses_workers_d1_migrations_local_command() {
3636        let cli = Cli::parse_from([
3637            "xbp",
3638            "workers",
3639            "d1",
3640            "migrations",
3641            "apply",
3642            "DB",
3643            "--local",
3644            "--environment",
3645            "preview",
3646        ]);
3647
3648        let Some(Commands::Workers(workers_cmd)) = cli.command else {
3649            panic!("expected workers command");
3650        };
3651
3652        match workers_cmd.command {
3653            super::WorkersSubCommand::D1(d1_cmd) => match d1_cmd.command {
3654                super::WorkersD1SubCommand::Migrations(migrations_cmd) => {
3655                    match migrations_cmd.command {
3656                        super::WorkersD1MigrationsSubCommand::Apply(apply_cmd) => {
3657                            assert_eq!(apply_cmd.database, "DB");
3658                            assert!(apply_cmd.local);
3659                            assert!(!apply_cmd.remote);
3660                            assert_eq!(apply_cmd.target.environment.as_deref(), Some("preview"));
3661                        }
3662                    }
3663                }
3664            },
3665            _ => panic!("expected workers d1 command"),
3666        }
3667    }
3668
3669    #[test]
3670    fn parses_workers_deploy_ci_version_upload_command() {
3671        let cli = Cli::parse_from(["xbp", "workers", "deploy", "ci", "--version-upload"]);
3672
3673        let Some(Commands::Workers(workers_cmd)) = cli.command else {
3674            panic!("expected workers command");
3675        };
3676
3677        match workers_cmd.command {
3678            super::WorkersSubCommand::Deploy(deploy_cmd) => match deploy_cmd.command {
3679                super::WorkersDeploySubCommand::Ci(ci_cmd) => {
3680                    assert!(ci_cmd.version_upload);
3681                }
3682                _ => panic!("expected workers deploy ci command"),
3683            },
3684            _ => panic!("expected workers deploy command"),
3685        }
3686    }
3687
3688    #[test]
3689    fn parses_worker_alias_command() {
3690        let cli = Cli::parse_from(["xbp", "worker", "env", "--show-values"]);
3691
3692        let Some(Commands::Workers(workers_cmd)) = cli.command else {
3693            panic!("expected workers command through alias");
3694        };
3695
3696        match workers_cmd.command {
3697            super::WorkersSubCommand::Env(env_cmd) => {
3698                assert!(env_cmd.show_values);
3699            }
3700            _ => panic!("expected workers env command"),
3701        }
3702    }
3703
3704    #[test]
3705    fn parses_dns_providers_command() {
3706        let cli = Cli::parse_from(["xbp", "dns", "providers"]);
3707
3708        match cli.command {
3709            Some(Commands::Dns(dns_cmd)) => {
3710                assert!(matches!(dns_cmd.command, DnsSubCommand::Providers));
3711            }
3712            _ => panic!("expected dns command"),
3713        }
3714    }
3715
3716    #[test]
3717    fn dns_zone_list_defaults_provider_to_cloudflare() {
3718        let cli = Cli::parse_from(["xbp", "dns", "zones", "list"]);
3719
3720        let Some(Commands::Dns(dns_cmd)) = cli.command else {
3721            panic!("expected dns command");
3722        };
3723
3724        match dns_cmd.command {
3725            DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
3726                DnsZonesSubCommand::List(list_cmd) => {
3727                    assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
3728                }
3729                _ => panic!("expected zones list command"),
3730            },
3731            _ => panic!("expected zones command"),
3732        }
3733    }
3734
3735    #[test]
3736    fn dns_record_list_defaults_provider_to_cloudflare() {
3737        let cli = Cli::parse_from(["xbp", "dns", "records", "list", "--zone-id", "zone_123"]);
3738
3739        let Some(Commands::Dns(dns_cmd)) = cli.command else {
3740            panic!("expected dns command");
3741        };
3742
3743        match dns_cmd.command {
3744            DnsSubCommand::Records(records_cmd) => match records_cmd.command {
3745                super::DnsRecordsSubCommand::List(list_cmd) => {
3746                    assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
3747                    assert_eq!(list_cmd.zone_id, "zone_123");
3748                }
3749                _ => panic!("expected records list command"),
3750            },
3751            _ => panic!("expected records command"),
3752        }
3753    }
3754
3755    #[test]
3756    fn dns_help_includes_descriptions_and_examples() {
3757        let err = Cli::try_parse_from(["xbp", "dns", "-h"]).expect_err("help");
3758        let rendered = err.to_string();
3759
3760        assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
3761        assert!(rendered.contains("Manage DNS providers, zones, records, DNSSEC, and settings"));
3762        assert!(rendered.contains("List supported DNS providers and current implementation status"));
3763        assert!(rendered.contains("xbp dns records create"));
3764    }
3765
3766    #[test]
3767    fn dns_providers_help_includes_discovery_note() {
3768        let err = Cli::try_parse_from(["xbp", "dns", "providers", "-h"]).expect_err("help");
3769        let rendered = err.to_string();
3770
3771        assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
3772        assert!(rendered.contains("Implemented providers are wired into `xbp dns` today."));
3773    }
3774
3775    #[test]
3776    fn dns_records_without_subcommand_displays_help_screen() {
3777        let err = Cli::try_parse_from(["xbp", "dns", "records"]).expect_err("missing subcommand");
3778        let rendered = err.to_string();
3779
3780        assert!(matches!(
3781            err.kind(),
3782            clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
3783                | clap::error::ErrorKind::MissingSubcommand
3784        ));
3785        assert!(rendered.contains("List, create, edit, import, export, and batch DNS records"));
3786        assert!(rendered.contains("Create a new DNS record"));
3787        assert!(rendered.contains("xbp dns records import"));
3788    }
3789
3790    #[test]
3791    fn parses_dns_zone_list_command() {
3792        let cli = Cli::parse_from([
3793            "xbp",
3794            "dns",
3795            "zones",
3796            "list",
3797            "--provider",
3798            "cloudflare",
3799            "--account-name-op",
3800            "contains",
3801            "--type",
3802            "full,partial",
3803        ]);
3804
3805        match cli.command {
3806            Some(Commands::Dns(dns_cmd)) => match dns_cmd.command {
3807                DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
3808                    DnsZonesSubCommand::List(list_cmd) => {
3809                        assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
3810                        assert_eq!(list_cmd.account_name_op.as_deref(), Some("contains"));
3811                        assert_eq!(list_cmd.zone_types, vec!["full", "partial"]);
3812                    }
3813                    _ => panic!("expected dns zones list"),
3814                },
3815                _ => panic!("expected dns zones"),
3816            },
3817            _ => panic!("expected dns command"),
3818        }
3819    }
3820
3821    #[test]
3822    fn parses_domains_search_command() {
3823        let cli = Cli::parse_from([
3824            "xbp",
3825            "domains",
3826            "search",
3827            "--query",
3828            "xbp",
3829            "--extension",
3830            "com",
3831        ]);
3832
3833        match cli.command {
3834            Some(Commands::Domains(domains_cmd)) => {
3835                assert_eq!(domains_cmd.provider, DomainsProviderKind::Cloudflare);
3836                assert!(matches!(domains_cmd.command, DomainsSubCommand::Search(_)));
3837            }
3838            _ => panic!("expected domains command"),
3839        }
3840    }
3841
3842    #[test]
3843    fn parses_cloudflare_config_account_id_command() {
3844        let cli = Cli::parse_from(["xbp", "config", "cloudflare", "set-account-id", "acc_123"]);
3845
3846        match cli.command {
3847            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
3848                Some(super::ConfigProviderCmd::Cloudflare(cloudflare_cmd)) => {
3849                    assert!(matches!(
3850                        cloudflare_cmd.action,
3851                        Some(CloudflareConfigAction::SetAccountId { .. })
3852                    ));
3853                }
3854                _ => panic!("expected cloudflare config provider"),
3855            },
3856            _ => panic!("expected config command"),
3857        }
3858    }
3859}