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