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