Skip to main content

xbp_cli/cli/
commands.rs

1use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Parser, Debug)]
6#[command(
7    name = "xbp",
8    version,
9    // Custom version flag so `-v` works (clap's built-in short is only `-V`).
10    disable_version_flag = true,
11    about = "Deploy, operate, and debug services with one CLI.",
12    long_about = "XBP is an operations-first CLI for deployments, diagnostics, service orchestration,\nnetwork controls, and runtime observability.",
13    disable_help_subcommand = false,
14    next_line_help = true,
15    help_template = crate::cli::help_render::XBP_ROOT_HELP_TEMPLATE,
16    after_help = crate::cli::help_render::XBP_ROOT_AFTER_HELP
17)]
18pub struct Cli {
19    /// Print version
20    #[arg(
21        // Id must not be `version` — that collides with the `version` subcommand name.
22        short = 'v',
23        long = "version",
24        visible_short_alias = 'V',
25        action = ArgAction::Version
26    )]
27    print_version: Option<bool>,
28    #[arg(long, global = true, help = "Enable verbose debugging output")]
29    pub debug: bool,
30    #[arg(short = 'l', help = "List pm2 processes")]
31    pub list: bool,
32    #[arg(short = 'p', long = "port", help = "Filter by port number")]
33    pub port: Option<u16>,
34    #[arg(long, help = "Open logs directory")]
35    pub logs: bool,
36    #[arg(long, help = "Print the complete alphabetical command reference")]
37    pub commands: bool,
38
39    #[command(subcommand)]
40    pub command: Option<Commands>,
41}
42
43#[derive(Subcommand, Debug)]
44pub enum Commands {
45    #[command(about = "Inspect or manage listening ports")]
46    Ports(PortsCmd),
47    #[command(
48        about = "Analyze the current git worktree and create a conventional commit",
49        visible_alias = "c"
50    )]
51    Commit(CommitCmd),
52    #[command(about = "Initialize an XBP project in the current directory")]
53    Init,
54    #[command(about = "Install common dependencies for host setup")]
55    Setup,
56    #[command(about = "Redeploy one service or the entire project")]
57    Redeploy {
58        #[arg(
59            help = "Service name to redeploy (optional, uses legacy redeploy.sh if not provided)"
60        )]
61        service_name: Option<String>,
62    },
63    #[command(about = "Run the legacy remote redeploy workflow over SSH")]
64    RedeployV2(RedeployV2Cmd),
65    #[command(about = "Inspect project/global config and manage provider keys")]
66    Config(ConfigCmd),
67    #[command(
68        about = "Install supported host packages or project tooling",
69        help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
70        after_help = crate::commands::INSTALL_COMMAND_AFTER_HELP
71    )]
72    Install {
73        #[arg(short = 'l', long = "list", help = "List installable targets and exit")]
74        list: bool,
75        #[arg(
76            long = "force",
77            help = "Allow an explicitly selected install method to run even when it does not match the current OS"
78        )]
79        force: bool,
80        #[arg(help = "Install target (leave empty to show installable options)")]
81        package: Option<String>,
82    },
83    #[command(about = "Tail local or remote logs")]
84    Logs(LogsCmd),
85    #[command(
86        about = "Open an interactive remote shell over SSH",
87        visible_alias = "shell"
88    )]
89    Ssh(SshCmd),
90    #[command(about = "Open or manage cloudflared TCP forwarders")]
91    Cloudflared(CloudflaredCmd),
92    #[command(about = "List PM2 processes")]
93    List,
94    #[command(about = "Fetch an HTTP endpoint with sane defaults")]
95    Curl(CurlCmd),
96    #[command(about = "List configured services from project config")]
97    Services,
98    #[command(
99        about = "Run service commands with interactive preview/picker when no args given. Stores run history + registry under global ~/.xbp/logs/service/"
100    )]
101    Service {
102        #[arg(help = "Command (build/install/start/dev/pre). Omit to use interactive picker.")]
103        command: Option<String>,
104        #[arg(help = "Service name (omit for picker)")]
105        service_name: Option<String>,
106    },
107    #[command(about = "Manage NGINX site configs and upstream mappings")]
108    Nginx(NginxCmd),
109    #[command(about = "Manage host network configuration and floating IPs")]
110    Network(NetworkCmd),
111    #[command(about = "Run full system diagnostics and readiness checks")]
112    Diag(DiagCmd),
113    #[command(about = "Run health-check monitoring commands")]
114    Monitor(MonitorCmd),
115    #[command(about = "Capture a PM2 snapshot for later restore")]
116    Snapshot,
117    #[command(about = "Restore PM2 state from dump or latest snapshot")]
118    Resurrect,
119    #[command(about = "Stop a PM2 process by name or stop all")]
120    Stop {
121        #[arg(help = "PM2 process name or 'all' (default: all)")]
122        target: Option<String>,
123    },
124    #[command(about = "Flush PM2 logs globally or for a specific process")]
125    Flush {
126        #[arg(help = "Optional PM2 process name")]
127        target: Option<String>,
128    },
129    #[command(about = "Run or inspect the CLI login flow against the XBP dashboard")]
130    Login(LoginCmd),
131    #[command(about = "Show the current signed-in CLI identity")]
132    Whoami,
133    #[command(
134        about = "Inspect, reconcile, or bump project versions",
135        visible_alias = "v"
136    )]
137    Version(VersionCmd),
138    #[command(about = "Run configured npm/crates publish workflows for the current XBP project")]
139    Publish(PublishCmd),
140    #[command(about = "Show PM2 environment by name or numeric id")]
141    Env {
142        #[arg(help = "PM2 process name or id")]
143        target: String,
144    },
145    #[command(about = "Tail app logs or Kafka logs")]
146    Tail(TailCmd),
147    #[command(about = "Start a binary/process under PM2")]
148    Start {
149        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
150        args: Vec<String>,
151    },
152    #[command(about = "Generate helper artifacts such as systemd units")]
153    Generate(GenerateCmd),
154    #[cfg(feature = "secrets")]
155    #[command(about = "Manage env vars and GitHub Actions environment variables (feature-gated)")]
156    Secrets(SecretsCmd),
157    #[command(
158        about = "Manage Cloudflare Workers secrets, Wrangler config helpers, D1 migrations, and deploy flows",
159        visible_alias = "worker"
160    )]
161    Workers(WorkersCmd),
162    #[command(about = "Run canonical Cloudflare Worker + Container workflows for an XBP project")]
163    Cloudflare(CloudflareCmd),
164    #[command(about = "Manage DNS providers, zones, records, DNSSEC, and settings")]
165    Dns(DnsCmd),
166    #[command(about = "Discover and inspect registered domains")]
167    Domains(DomainsCmd),
168    #[command(
169        about = "Generate 'what did I get done' Markdown report from git commits across repos"
170    )]
171    Done(DoneCmd),
172    #[command(
173        about = "Repair malformed Cursor process-monitor JSON exports",
174        visible_alias = "fix-pm-json"
175    )]
176    FixProcessMonitorJson(FixProcessMonitorJsonCmd),
177    #[command(about = "Upload Cursor local file history to the XBP dashboard")]
178    Cursor(CursorCmd),
179    #[cfg(feature = "kubernetes")]
180    #[command(about = "Experimental Kubernetes cluster manager (feature-gated)")]
181    Kubernetes(KubernetesCmd),
182    #[cfg(feature = "nordvpn")]
183    #[command(about = "NordVPN meshnet setup and passthrough (feature-gated)")]
184    Nordvpn(NordvpnCmd),
185    #[cfg(feature = "monitoring")]
186    Monitoring(MonitoringCmd),
187    #[command(about = "Manage the XBP API server")]
188    Api(ApiCmd),
189    #[command(about = "Manage runner hosts, groups, inventory, and runner jobs")]
190    Runners(RunnersCmd),
191    #[command(about = "Built-in MCP server for AI agents (HTTP/SSE on port 1113)")]
192    Mcp(McpCmd),
193    #[command(
194        about = "Watch git worktree mutations, store local JSONL spools, and sync them to xbp.app",
195        visible_alias = "watch"
196    )]
197    WorktreeWatch(WorktreeWatchCmd),
198    #[cfg(feature = "docker")]
199    #[command(about = "Pass-through wrapper around the Docker CLI")]
200    Docker(DockerCmd),
201}
202
203pub fn command_label(command: &Commands) -> &'static str {
204    match command {
205        Commands::Ports(_) => "ports",
206        Commands::Commit(_) => "commit",
207        Commands::Init => "init",
208        Commands::Setup => "setup",
209        Commands::Redeploy { .. } => "redeploy",
210        Commands::RedeployV2(_) => "redeploy-v2",
211        Commands::Config(_) => "config",
212        Commands::Install { .. } => "install",
213        Commands::Logs(_) => "logs",
214        Commands::Ssh(_) => "ssh",
215        Commands::Cloudflared(_) => "cloudflared",
216        Commands::List => "list",
217        Commands::Curl(_) => "curl",
218        Commands::Services => "services",
219        Commands::Service { .. } => "service",
220        Commands::Nginx(_) => "nginx",
221        Commands::Network(_) => "network",
222        Commands::Diag(_) => "diag",
223        Commands::Monitor(_) => "monitor",
224        Commands::Snapshot => "snapshot",
225        Commands::Resurrect => "resurrect",
226        Commands::Stop { .. } => "stop",
227        Commands::Flush { .. } => "flush",
228        Commands::Login(_) => "login",
229        Commands::Whoami => "whoami",
230        Commands::Version(_) => "version",
231        Commands::Publish(_) => "publish",
232        Commands::Env { .. } => "env",
233        Commands::Tail(_) => "tail",
234        Commands::Start { .. } => "start",
235        Commands::Generate(_) => "generate",
236        #[cfg(feature = "secrets")]
237        Commands::Secrets(_) => "secrets",
238        Commands::Workers(_) => "workers",
239        Commands::Cloudflare(_) => "cloudflare",
240        Commands::Dns(_) => "dns",
241        Commands::Domains(_) => "domains",
242        Commands::Done(_) => "done",
243        Commands::FixProcessMonitorJson(_) => "fix-process-monitor-json",
244        Commands::Cursor(_) => "cursor",
245        #[cfg(feature = "kubernetes")]
246        Commands::Kubernetes(_) => "kubernetes",
247        #[cfg(feature = "nordvpn")]
248        Commands::Nordvpn(_) => "nordvpn",
249        #[cfg(feature = "monitoring")]
250        Commands::Monitoring(_) => "monitoring",
251        Commands::Api(_) => "api",
252        Commands::Runners(_) => "runners",
253        Commands::Mcp(_) => "mcp",
254        Commands::WorktreeWatch(_) => "worktree-watch",
255        #[cfg(feature = "docker")]
256        Commands::Docker(_) => "docker",
257    }
258}
259
260#[derive(Args, Debug)]
261#[command(
262    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
263    after_help = crate::cli::help_render::COMMIT_AFTER_HELP
264)]
265pub struct CommitCmd {
266    #[arg(
267        long,
268        help = "Generate and print the conventional commit message without creating a git commit"
269    )]
270    pub dry_run: bool,
271    #[arg(
272        short = 'p',
273        long,
274        help = "Push after committing, or push pending local commits when nothing new needs committing"
275    )]
276    pub push: bool,
277    #[arg(long, help = "Skip OpenRouter and use local heuristics only")]
278    pub no_ai: bool,
279    #[arg(
280        long,
281        help = "OpenRouter model override used for commit generation; otherwise XBP uses the global config default"
282    )]
283    pub model: Option<String>,
284    #[arg(
285        long,
286        help = "Force the conventional commit scope (for example: cli, api, docs)"
287    )]
288    pub scope: Option<String>,
289}
290
291#[derive(Args, Debug)]
292#[command(help_template = crate::cli::help_render::XBP_HELP_TEMPLATE)]
293pub struct PortsCmd {
294    #[arg(short = 'p', long = "port")]
295    pub port: Option<u16>,
296    #[arg(
297        long = "sort",
298        default_value = "port",
299        value_parser = ["port", "pid"],
300        help = "Sort port rows by port or pid (default: port)"
301    )]
302    pub sort: String,
303    #[arg(long = "kill")]
304    pub kill: bool,
305    #[arg(short = 'n', long = "nginx")]
306    pub nginx: bool,
307    #[arg(
308        long = "full",
309        help = "Show one unified ports view (reconciled listeners + exposure + security flags)"
310    )]
311    pub full: bool,
312    #[arg(
313        long = "no-local",
314        help = "Exclude connections where LocalAddr equals RemoteAddr"
315    )]
316    pub no_local: bool,
317    #[arg(
318        long = "exposure",
319        help = "Diagnose external exposure per port (binding + firewall layer)"
320    )]
321    pub exposure: bool,
322}
323
324#[derive(Args, Debug)]
325#[command(
326    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
327    after_help = crate::cli::help_render::CONFIG_AFTER_HELP
328)]
329pub struct ConfigCmd {
330    #[arg(
331        long,
332        help = "Show the current project config instead of opening global XBP paths"
333    )]
334    pub project: bool,
335    #[arg(long, help = "Print global XBP paths without opening them")]
336    pub no_open: bool,
337    #[command(subcommand)]
338    pub provider: Option<ConfigProviderCmd>,
339}
340
341#[derive(Subcommand, Debug)]
342pub enum ConfigProviderCmd {
343    #[command(about = "Manage the OpenRouter API key used by AI-enabled commands")]
344    Openrouter(ConfigSecretCmd),
345    #[command(about = "Manage the GitHub OAuth2 token used for release automation")]
346    Github(ConfigSecretCmd),
347    #[command(
348        about = "Manage Cloudflare API credentials used by secrets, DNS, and domains (run without a subcommand for the interactive setup wizard)"
349    )]
350    Cloudflare(CloudflareConfigCmd),
351    #[command(
352        about = "Manage the Linear API key used for release-note issue linking and initiative publishing"
353    )]
354    Linear(LinearConfigCmd),
355    #[command(about = "Manage npm registry auth and guided npm publish config")]
356    Npm(RegistryConfigCmd),
357    #[command(about = "Manage crates.io auth and guided crate publish config")]
358    Crates(CratesConfigCmd),
359    #[command(about = "Manage guided release config in .xbp/xbp.yaml")]
360    Release(ReleaseConfigCmd),
361    #[command(
362        about = "Interactively manage static OpenAPI generation in .xbp/xbp.yaml (run without a subcommand for the setup wizard)"
363    )]
364    Openapi(OpenapiConfigCmd),
365}
366
367#[derive(Args, Debug)]
368pub struct ConfigSecretCmd {
369    #[command(subcommand)]
370    pub action: ConfigSecretAction,
371}
372
373#[derive(Subcommand, Debug)]
374pub enum ConfigSecretAction {
375    #[command(about = "Set provider key (omit value to enter it securely)")]
376    SetKey {
377        #[arg(help = "Provider key/token value")]
378        key: Option<String>,
379    },
380    #[command(about = "Delete the stored provider key")]
381    DeleteKey,
382    #[command(about = "Show whether a key is configured (masked by default)")]
383    Show {
384        #[arg(long, help = "Print full key/token value (not masked)")]
385        raw: bool,
386    },
387}
388
389#[derive(Args, Debug)]
390pub struct CloudflareConfigCmd {
391    #[command(subcommand)]
392    pub action: Option<CloudflareConfigAction>,
393}
394
395#[derive(Subcommand, Debug)]
396pub enum CloudflareConfigAction {
397    #[command(about = "Set Cloudflare API token (omit value to enter it securely)")]
398    SetKey {
399        #[arg(help = "Cloudflare API token")]
400        key: Option<String>,
401    },
402    #[command(about = "Delete the stored Cloudflare API token")]
403    DeleteKey,
404    #[command(about = "Show whether a Cloudflare API token is configured")]
405    ShowKey {
406        #[arg(long, help = "Print full token value (not masked)")]
407        raw: bool,
408    },
409    #[command(about = "Set the default Cloudflare account ID")]
410    SetAccountId {
411        #[arg(help = "Cloudflare account ID")]
412        account_id: Option<String>,
413    },
414    #[command(about = "Delete the stored default Cloudflare account ID")]
415    DeleteAccountId,
416    #[command(about = "Show whether a Cloudflare account ID is configured")]
417    ShowAccountId {
418        #[arg(long, help = "Print full account ID value (not masked)")]
419        raw: bool,
420    },
421    #[command(about = "Interactive dashboard OAuth linking flow for Cloudflare credentials")]
422    Login,
423    #[command(about = "Show Cloudflare credential sources and readiness")]
424    Status,
425    #[command(about = "Run the interactive Cloudflare credential setup wizard")]
426    Setup,
427}
428
429#[derive(Args, Debug)]
430pub struct LinearConfigCmd {
431    #[command(subcommand)]
432    pub action: LinearConfigAction,
433}
434
435#[derive(Subcommand, Debug)]
436pub enum LinearConfigAction {
437    #[command(about = "Set Linear API key (omit value to enter it securely)")]
438    SetKey {
439        #[arg(help = "Linear API key/token value")]
440        key: Option<String>,
441    },
442    #[command(about = "Delete the stored Linear API key")]
443    DeleteKey,
444    #[command(about = "Show whether a Linear API key is configured (masked by default)")]
445    Show {
446        #[arg(long, help = "Print full key/token value (not masked)")]
447        raw: bool,
448    },
449    #[command(
450        name = "select-initiative",
451        about = "Pick a Linear initiative for the current repo and save it to .xbp/xbp.yaml"
452    )]
453    SelectInitiative,
454}
455
456#[derive(Args, Debug)]
457pub struct RegistryConfigCmd {
458    #[command(subcommand)]
459    pub action: RegistryConfigAction,
460}
461
462#[derive(Args, Debug)]
463pub struct CratesConfigCmd {
464    #[command(subcommand)]
465    pub action: CratesConfigAction,
466}
467
468#[derive(Args, Debug)]
469pub struct ReleaseConfigCmd {
470    #[command(subcommand)]
471    pub action: ReleaseConfigAction,
472}
473
474#[derive(Args, Debug)]
475pub struct OpenapiConfigCmd {
476    #[command(subcommand)]
477    pub action: Option<OpenapiConfigAction>,
478}
479
480#[derive(Subcommand, Debug)]
481pub enum OpenapiConfigAction {
482    #[command(about = "Run the interactive OpenAPI setup wizard for .xbp/xbp.yaml")]
483    Setup,
484    #[command(about = "Show project and per-service OpenAPI generation settings")]
485    Show,
486}
487
488#[derive(Subcommand, Debug)]
489pub enum RegistryConfigAction {
490    #[command(about = "Set registry token/key (omit value to enter it securely)")]
491    SetKey {
492        #[arg(help = "Registry token value")]
493        key: Option<String>,
494    },
495    #[command(about = "Delete the stored registry token")]
496    DeleteKey,
497    #[command(about = "Show whether a registry token is configured (masked by default)")]
498    Show {
499        #[arg(long, help = "Print full token value (not masked)")]
500        raw: bool,
501    },
502    #[command(
503        name = "setup-release",
504        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
505    )]
506    SetupRelease,
507}
508
509#[derive(Subcommand, Debug)]
510pub enum CratesConfigAction {
511    #[command(about = "Set crates.io token (omit value to enter it securely)")]
512    SetKey {
513        #[arg(help = "crates.io token value")]
514        key: Option<String>,
515    },
516    #[command(about = "Delete the stored crates.io token from global XBP config")]
517    DeleteKey,
518    #[command(about = "Show whether a crates.io token is configured (masked by default)")]
519    Show {
520        #[arg(long, help = "Print full token value (not masked)")]
521        raw: bool,
522    },
523    #[command(
524        name = "setup-release",
525        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
526    )]
527    SetupRelease,
528    #[command(
529        about = "Run `cargo login` using the stored crates token and sync Cargo's local credentials file"
530    )]
531    Login {
532        #[arg(help = "Optional crates.io token value to save before logging in")]
533        key: Option<String>,
534    },
535    #[command(
536        about = "Run `cargo logout` to remove Cargo's local crates.io credentials while keeping XBP's stored token"
537    )]
538    Logout,
539}
540
541#[derive(Subcommand, Debug)]
542pub enum ReleaseConfigAction {
543    #[command(
544        name = "setup",
545        about = "Interactively configure release tag naming in .xbp/xbp.yaml"
546    )]
547    Setup,
548}
549
550#[derive(Args, Debug)]
551#[command(help_template = crate::cli::help_render::XBP_HELP_TEMPLATE)]
552pub struct CurlCmd {
553    #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
554    pub url: Option<String>,
555    #[arg(long, help = "Disable the default 15 second timeout")]
556    pub no_timeout: bool,
557}
558
559#[derive(Args, Debug)]
560#[command(
561    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
562    after_help = crate::cli::help_render::LOGIN_AFTER_HELP
563)]
564pub struct LoginCmd {
565    #[command(subcommand)]
566    pub action: Option<LoginSubCommand>,
567}
568
569#[derive(Subcommand, Debug)]
570pub enum LoginSubCommand {
571    #[command(about = "Show whether the current CLI session is still valid")]
572    Status,
573    #[command(about = "Revoke the current CLI token and clear local login state")]
574    Logout,
575}
576
577#[derive(Args, Debug)]
578#[command(
579    subcommand_precedence_over_arg = true,
580    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
581    after_help = crate::cli::help_render::VERSION_AFTER_HELP
582)]
583pub struct VersionCmd {
584    #[arg(
585        help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
586    )]
587    pub target: Option<String>,
588    #[arg(
589        short = 'v',
590        long = "version",
591        help = "Explicit version target; equivalent to the positional version value and overrides it when both are provided"
592    )]
593    pub explicit_version: Option<String>,
594    #[arg(long, help = "Show normalized git tags from `git tag --list`")]
595    pub git: bool,
596    #[command(subcommand)]
597    pub command: Option<VersionSubCommand>,
598}
599
600#[derive(Subcommand, Debug)]
601pub enum VersionSubCommand {
602    #[command(
603        about = "Create and push a git tag for this version, then create a GitHub release",
604        visible_alias = "r"
605    )]
606    Release(VersionReleaseCmd),
607    #[command(
608        about = "Manage Rust workspace release/version drift, sync, validation, and publish flow",
609        arg_required_else_help = true
610    )]
611    Workspace(VersionWorkspaceCmd),
612    #[command(
613        about = "Manage explicit release-domain ownership, validation, sync, diagnosis, and guarded releases",
614        arg_required_else_help = true
615    )]
616    Domain(VersionDomainCmd),
617    /// Discover package roots (Cargo.toml, package.json, etc.) and register them as services
618    #[command(name = "discover", alias = "register", alias = "register-services")]
619    Discover(VersionDiscoverServicesCmd),
620    #[command(
621        about = "Bump versions only for packages with uncommitted changes in the working tree"
622    )]
623    Bump(VersionBumpCmd),
624}
625
626#[derive(Args, Debug)]
627pub struct VersionBumpCmd {
628    #[arg(long, help = "Preview bump selections without writing version files")]
629    pub dry_run: bool,
630    #[arg(
631        long,
632        group = "bump_kind",
633        help = "Default bump kind for --all and interactive default selection"
634    )]
635    pub major: bool,
636    #[arg(
637        long,
638        group = "bump_kind",
639        help = "Default bump kind for --all and interactive default"
640    )]
641    pub minor: bool,
642    #[arg(
643        long,
644        group = "bump_kind",
645        help = "Default bump kind for --all and interactive default"
646    )]
647    pub patch: bool,
648    #[arg(
649        long,
650        help = "Bump every mutated package with the selected default kind without prompting"
651    )]
652    pub all: bool,
653}
654
655#[derive(Args, Debug)]
656pub struct VersionDiscoverServicesCmd {
657    #[arg(
658        long,
659        help = "Preview discovered nested XBP services without writing .xbp/xbp.yaml"
660    )]
661    pub dry_run: bool,
662    #[arg(
663        long,
664        help = "Discover nested services only; do not register them in the root config (opt out)"
665    )]
666    pub no_register: bool,
667}
668
669#[derive(Args, Debug)]
670pub struct VersionReleaseCmd {
671    #[arg(
672        long,
673        help = "Release this version instead of auto-detecting from tracked files"
674    )]
675    pub version: Option<String>,
676    #[arg(
677        long = "flag",
678        value_enum,
679        help = "Append build metadata to the release version: dev, stable, beta, alpha, nightly, or exp"
680    )]
681    pub flag: Option<VersionReleaseFlag>,
682    #[arg(
683        long,
684        help = "Allow releasing with uncommitted changes in the working tree"
685    )]
686    pub allow_dirty: bool,
687    #[arg(long, help = "Release title (defaults to <version> - <repo>)")]
688    pub title: Option<String>,
689    #[arg(long, help = "Release notes body (Markdown)")]
690    pub notes: Option<String>,
691    #[arg(long, help = "Read release notes body from a file")]
692    pub notes_file: Option<PathBuf>,
693    #[arg(long, help = "Create as draft release")]
694    pub draft: bool,
695    #[arg(long, help = "Mark release as pre-release")]
696    pub prerelease: bool,
697    #[arg(
698        long,
699        help = "Run configured npm/crates publish workflows before creating the GitHub release"
700    )]
701    pub publish: bool,
702    #[arg(
703        long,
704        requires = "publish",
705        help = "When `--publish` is enabled: skip configured preflight commands, allow a dirty working tree, and auto-sync workspace crate versions. Version release also does not fail on deploy-only config issues (e.g. duplicate service ports)."
706    )]
707    pub force: bool,
708    #[arg(
709        long,
710        value_enum,
711        default_value_t = VersionReleaseLatest::Legacy,
712        help = "Control GitHub latest flag: true, false, or legacy"
713    )]
714    pub make_latest: VersionReleaseLatest,
715}
716
717#[derive(Copy, Clone, Debug, ValueEnum)]
718pub enum VersionReleaseLatest {
719    True,
720    False,
721    Legacy,
722}
723
724#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)]
725pub enum VersionReleaseFlag {
726    Dev,
727    Stable,
728    Beta,
729    Alpha,
730    Nightly,
731    Exp,
732}
733
734impl VersionReleaseFlag {
735    pub fn as_str(self) -> &'static str {
736        match self {
737            Self::Dev => "dev",
738            Self::Stable => "stable",
739            Self::Beta => "beta",
740            Self::Alpha => "alpha",
741            Self::Nightly => "nightly",
742            Self::Exp => "exp",
743        }
744    }
745}
746
747#[derive(Args, Debug)]
748#[command(
749    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
750    after_help = crate::cli::help_render::PUBLISH_AFTER_HELP
751)]
752pub struct PublishCmd {
753    #[arg(
754        long,
755        help = "Validate and print what would publish without uploading packages"
756    )]
757    pub dry_run: bool,
758    #[arg(
759        long,
760        help = "Allow publish workflows to run with a dirty working tree"
761    )]
762    pub allow_dirty: bool,
763    #[arg(long, help = "Skip configured preflight commands and publish anyway")]
764    pub force: bool,
765    #[arg(
766        long,
767        help = "When publishing a crate workspace member, also publish missing internal prerequisites in dependency order"
768    )]
769    pub include_prereqs: bool,
770    #[arg(long, help = "Limit publishing to one target: npm or crates")]
771    pub target: Option<String>,
772    #[arg(
773        long,
774        help = "Publish the npm package or crate belonging to this configured service"
775    )]
776    pub service: Option<String>,
777    #[arg(
778        long,
779        help = "Limit publishing to the workflow whose manifest matches this path"
780    )]
781    pub manifest_path: Option<PathBuf>,
782}
783
784#[derive(Args, Debug)]
785#[command(
786    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
787    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"
788)]
789pub struct VersionWorkspaceCmd {
790    #[command(subcommand)]
791    pub command: VersionWorkspaceSubCommand,
792}
793
794#[derive(Args, Debug)]
795#[command(
796    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
797    after_help = "Examples:\n  xbp version domain init --name auth --root services/auth --write\n  xbp version domain doctor --domain auth\n  xbp version domain sync --domain auth --check\n  xbp version domain diagnose --domain auth --log build.log\n  xbp version domain release --domain auth --patch --deploy"
798)]
799pub struct VersionDomainCmd {
800    #[command(subcommand)]
801    pub command: VersionDomainSubCommand,
802}
803
804#[derive(Subcommand, Debug)]
805pub enum VersionDomainSubCommand {
806    #[command(about = "Discover or scaffold a version-domain policy")]
807    Init(VersionDomainInitCmd),
808    #[command(about = "Validate release-domain ownership and configured surfaces")]
809    Doctor(VersionDomainDoctorCmd),
810    #[command(about = "Preview or apply version-domain surface synchronization")]
811    Sync(VersionDomainSyncCmd),
812    #[command(about = "Classify build logs for release-domain drift and unsafe alignments")]
813    Diagnose(VersionDomainDiagnoseCmd),
814    #[command(about = "Run a guarded domain version bump/set and optional deploy")]
815    Release(VersionDomainReleaseCmd),
816}
817
818#[derive(Args, Debug)]
819pub struct VersionDomainInitCmd {
820    #[arg(long, help = "Release domain name")]
821    pub name: String,
822    #[arg(long, help = "Domain root path relative to the XBP project root")]
823    pub root: PathBuf,
824    #[arg(long, help = "Persist the generated domain to .xbp/xbp.yaml")]
825    pub write: bool,
826}
827
828#[derive(Args, Debug)]
829pub struct VersionDomainDoctorCmd {
830    #[arg(long = "domain", help = "Release domain name")]
831    pub domain: String,
832}
833
834#[derive(Args, Debug)]
835pub struct VersionDomainSyncCmd {
836    #[arg(long = "domain", help = "Release domain name")]
837    pub domain: String,
838    #[arg(
839        long,
840        conflicts_with = "write",
841        help = "Validate only; do not write files"
842    )]
843    pub check: bool,
844    #[arg(long, conflicts_with = "check", help = "Write configured surfaces")]
845    pub write: bool,
846}
847
848#[derive(Args, Debug)]
849pub struct VersionDomainDiagnoseCmd {
850    #[arg(long = "domain", help = "Release domain name")]
851    pub domain: String,
852    #[arg(long, help = "Build or deploy log file to classify")]
853    pub log: Option<PathBuf>,
854}
855
856#[derive(Args, Debug)]
857#[command(group(
858    clap::ArgGroup::new("domain_release_version")
859        .required(true)
860        .args(["patch", "minor", "major", "version"])
861))]
862pub struct VersionDomainReleaseCmd {
863    #[arg(long = "domain", help = "Release domain name")]
864    pub domain: String,
865    #[arg(long, help = "Bump the domain patch version")]
866    pub patch: bool,
867    #[arg(long, help = "Bump the domain minor version")]
868    pub minor: bool,
869    #[arg(long, help = "Bump the domain major version")]
870    pub major: bool,
871    #[arg(long, help = "Set an explicit domain version")]
872    pub version: Option<String>,
873    #[arg(long, help = "Run the configured Cloudflare app release after sync")]
874    pub deploy: bool,
875    #[arg(long, help = "Allow an otherwise blocked major-version jump")]
876    pub allow_major_jump: bool,
877    #[arg(
878        long,
879        value_name = "DOMAIN",
880        help = "Explicitly record an allowed cross-domain version source"
881    )]
882    pub allow_cross_domain_version: Option<String>,
883}
884
885#[derive(Args, Debug, Clone, Default)]
886pub struct VersionWorkspaceTargetArgs {
887    #[arg(
888        long,
889        help = "Workspace repo root to inspect (defaults to current project root)"
890    )]
891    pub repo: Option<PathBuf>,
892    #[arg(long, help = "Emit machine-readable JSON output")]
893    pub json: bool,
894}
895
896#[derive(Subcommand, Debug)]
897pub enum VersionWorkspaceSubCommand {
898    #[command(about = "Detect workspace release drift and exit non-zero when mismatches exist")]
899    Check(VersionWorkspaceCheckCmd),
900    #[command(about = "Preview or apply workspace-wide version alignment")]
901    Sync(VersionWorkspaceSyncCmd),
902    #[command(about = "Run structural and optional cargo validation for workspace publishability")]
903    Validate(VersionWorkspaceValidateCmd),
904    #[command(about = "Plan or execute crates.io publishing for workspace packages")]
905    Publish(VersionWorkspacePublishCmd),
906}
907
908#[derive(Args, Debug)]
909pub struct VersionWorkspaceCheckCmd {
910    #[command(flatten)]
911    pub target: VersionWorkspaceTargetArgs,
912    #[arg(
913        long,
914        help = "Expected release version (defaults to the root package version)"
915    )]
916    pub version: Option<String>,
917}
918
919#[derive(Args, Debug)]
920pub struct VersionWorkspaceSyncCmd {
921    #[command(flatten)]
922    pub target: VersionWorkspaceTargetArgs,
923    #[arg(
924        long,
925        help = "Target release version (defaults to the root package version)"
926    )]
927    pub version: Option<String>,
928    #[arg(
929        long,
930        help = "Write changes to disk instead of previewing the sync plan"
931    )]
932    pub write: bool,
933}
934
935#[derive(Args, Debug)]
936pub struct VersionWorkspaceValidateCmd {
937    #[command(flatten)]
938    pub target: VersionWorkspaceTargetArgs,
939    #[arg(long, help = "Limit cargo validation to a single package name")]
940    pub package: Option<String>,
941    #[arg(long, help = "Run `cargo check -q` as part of validation")]
942    pub cargo_check: bool,
943    #[arg(
944        long,
945        help = "Run `cargo publish --dry-run --locked` for publishable packages"
946    )]
947    pub package_dry_run: bool,
948}
949
950#[derive(Args, Debug)]
951#[command(arg_required_else_help = true)]
952pub struct VersionWorkspacePublishCmd {
953    #[command(subcommand)]
954    pub command: VersionWorkspacePublishSubCommand,
955}
956
957#[derive(Subcommand, Debug)]
958pub enum VersionWorkspacePublishSubCommand {
959    #[command(about = "Show publish order, crates.io visibility, and blockers without publishing")]
960    Plan(VersionWorkspacePublishPlanCmd),
961    #[command(about = "Publish workspace packages in dependency order")]
962    Run(VersionWorkspacePublishRunCmd),
963}
964
965#[derive(Args, Debug)]
966pub struct VersionWorkspacePublishPlanCmd {
967    #[command(flatten)]
968    pub target: VersionWorkspaceTargetArgs,
969    #[arg(long, help = "Limit the plan to one package")]
970    pub only: Option<String>,
971    #[arg(
972        long,
973        help = "When planning a single package, also include missing internal prerequisites"
974    )]
975    pub include_prereqs: bool,
976}
977
978#[derive(Args, Debug)]
979pub struct VersionWorkspacePublishRunCmd {
980    #[command(flatten)]
981    pub target: VersionWorkspaceTargetArgs,
982    #[arg(long, help = "Preview publish actions without calling cargo publish")]
983    pub dry_run: bool,
984    #[arg(
985        long,
986        help = "Start publishing from this package in the computed order"
987    )]
988    pub from: Option<String>,
989    #[arg(long, help = "Publish only this package")]
990    pub only: Option<String>,
991    #[arg(
992        long,
993        help = "When publishing one package, also publish missing internal prerequisites in dependency order"
994    )]
995    pub include_prereqs: bool,
996    #[arg(long, help = "Continue publishing remaining packages after a failure")]
997    pub continue_on_error: bool,
998    #[arg(long, help = "Allow publishing from a dirty worktree")]
999    pub allow_dirty: bool,
1000    #[arg(
1001        long,
1002        default_value_t = 180.0,
1003        help = "How long to wait for each published version to become visible on crates.io"
1004    )]
1005    pub timeout_seconds: f64,
1006    #[arg(
1007        long,
1008        default_value_t = 5.0,
1009        help = "How often to poll crates.io for the just-published version"
1010    )]
1011    pub poll_interval_seconds: f64,
1012}
1013
1014#[derive(Args, Debug)]
1015pub struct RedeployV2Cmd {
1016    #[arg(short = 'p', long = "password")]
1017    pub password: Option<String>,
1018    #[arg(short = 'u', long = "username")]
1019    pub username: Option<String>,
1020    #[arg(short = 'h', long = "host")]
1021    pub host: Option<String>,
1022    #[arg(short = 'd', long = "project-dir")]
1023    pub project_dir: Option<String>,
1024}
1025
1026#[derive(Args, Debug)]
1027#[command(
1028    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1029    after_help = crate::cli::help_render::LOGS_AFTER_HELP
1030)]
1031pub struct LogsCmd {
1032    #[arg()]
1033    pub project: Option<String>,
1034    #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
1035    pub ssh_host: Option<String>,
1036    #[arg(long = "ssh-username", help = "SSH username for remote host")]
1037    pub ssh_username: Option<String>,
1038    #[arg(long = "ssh-password", help = "SSH password for remote host")]
1039    pub ssh_password: Option<String>,
1040}
1041
1042#[derive(Args, Debug)]
1043#[command(
1044    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1045    after_help = crate::cli::help_render::SSH_AFTER_HELP
1046)]
1047pub struct SshCmd {
1048    #[arg(long = "host", alias = "ssh-host", help = "SSH host or IP address")]
1049    pub ssh_host: Option<String>,
1050    #[arg(
1051        long = "port",
1052        default_value_t = 22,
1053        help = "SSH port for direct connections"
1054    )]
1055    pub ssh_port: u16,
1056    #[arg(
1057        long = "username",
1058        alias = "ssh-username",
1059        help = "SSH username for the remote host"
1060    )]
1061    pub ssh_username: Option<String>,
1062    #[arg(
1063        long = "password",
1064        alias = "ssh-password",
1065        help = "SSH password (omit to use stored config or a secure prompt)"
1066    )]
1067    pub ssh_password: Option<String>,
1068    #[arg(
1069        long,
1070        help = "Path to a private key file to use instead of password auth"
1071    )]
1072    pub private_key: Option<PathBuf>,
1073    #[arg(long, help = "Passphrase for --private-key when required")]
1074    pub private_key_passphrase: Option<String>,
1075    #[arg(
1076        long,
1077        help = "Run this remote command in a PTY instead of opening the default login shell"
1078    )]
1079    pub command: Option<String>,
1080    #[arg(
1081        long,
1082        help = "TERM value sent to the server (default: TERM env var or xterm-256color)"
1083    )]
1084    pub term: Option<String>,
1085    #[arg(long, help = "Disable SSH host key verification")]
1086    pub no_host_key_check: bool,
1087    #[arg(
1088        long,
1089        help = "Pin the SSH host key as a base64 blob when using tunnels or first-connect flows"
1090    )]
1091    pub host_key: Option<String>,
1092    #[arg(
1093        long,
1094        help = "Path to a known_hosts file used for SSH host verification"
1095    )]
1096    pub known_hosts_file: Option<PathBuf>,
1097    #[arg(
1098        long,
1099        help = "Cloudflare Access hostname used to open a local cloudflared TCP forwarder"
1100    )]
1101    pub cloudflared_hostname: Option<String>,
1102    #[arg(long, help = "Override the cloudflared binary path")]
1103    pub cloudflared_binary: Option<PathBuf>,
1104    #[arg(
1105        long,
1106        help = "Optional destination host:port passed to cloudflared access tcp"
1107    )]
1108    pub cloudflared_destination: Option<String>,
1109}
1110
1111#[derive(Args, Debug)]
1112#[command(
1113    arg_required_else_help = true,
1114    disable_help_subcommand = true,
1115    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE
1116)]
1117pub struct CloudflaredCmd {
1118    #[command(subcommand)]
1119    pub command: CloudflaredSubCommand,
1120}
1121
1122#[derive(Subcommand, Debug)]
1123pub enum CloudflaredSubCommand {
1124    #[command(about = "Start a local cloudflared Access TCP forwarder")]
1125    Tcp(CloudflaredTcpCmd),
1126}
1127
1128#[derive(Args, Debug)]
1129#[command(
1130    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1131    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"
1132)]
1133pub struct CloudflaredTcpCmd {
1134    #[arg(long, help = "Protected Cloudflare Access hostname")]
1135    pub hostname: Option<String>,
1136    #[arg(
1137        long,
1138        help = "Local listener address for the forwarder (default: auto-allocate 127.0.0.1:<port>)"
1139    )]
1140    pub listener: Option<String>,
1141    #[arg(
1142        long,
1143        help = "Optional destination host:port passed to cloudflared access tcp"
1144    )]
1145    pub destination: Option<String>,
1146    #[arg(long, help = "Override the cloudflared binary path")]
1147    pub binary: Option<PathBuf>,
1148}
1149
1150#[derive(Args, Debug)]
1151#[command(
1152    arg_required_else_help = true,
1153    disable_help_subcommand = true,
1154    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1155    after_help = crate::cli::help_render::NGINX_AFTER_HELP
1156)]
1157pub struct NginxCmd {
1158    #[command(subcommand)]
1159    pub command: NginxSubCommand,
1160}
1161
1162#[derive(Args, Debug)]
1163#[command(
1164    arg_required_else_help = true,
1165    disable_help_subcommand = true,
1166    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE
1167)]
1168pub struct NetworkCmd {
1169    #[command(subcommand)]
1170    pub command: NetworkSubCommand,
1171}
1172
1173#[derive(Subcommand, Debug)]
1174pub enum NetworkSubCommand {
1175    #[command(about = "Manage persistent floating IP configuration")]
1176    FloatingIp(NetworkFloatingIpCmd),
1177    #[command(about = "Inspect discovered network configuration sources")]
1178    Config(NetworkConfigCmd),
1179    #[command(about = "Manage Hetzner-specific Linux network configuration")]
1180    Hetzner(NetworkHetznerCmd),
1181}
1182
1183#[derive(Args, Debug)]
1184pub struct NetworkFloatingIpCmd {
1185    #[command(subcommand)]
1186    pub command: NetworkFloatingIpSubCommand,
1187}
1188
1189#[derive(Subcommand, Debug)]
1190pub enum NetworkFloatingIpSubCommand {
1191    #[command(about = "Add a persistent floating IP entry to detected network backend")]
1192    Add {
1193        #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
1194        ip: String,
1195        #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
1196        cidr: Option<u8>,
1197        #[arg(long, help = "Network interface override (auto-detected when omitted)")]
1198        interface: Option<String>,
1199        #[arg(long, help = "Optional label for backend metadata/file naming")]
1200        label: Option<String>,
1201        #[arg(long, help = "Apply network changes after writing config")]
1202        apply: bool,
1203        #[arg(long, help = "Preview computed changes without writing files")]
1204        dry_run: bool,
1205    },
1206    #[command(about = "List floating IPs from runtime and persisted network config")]
1207    List {
1208        #[arg(long, help = "Emit JSON output")]
1209        json: bool,
1210    },
1211}
1212
1213#[derive(Args, Debug)]
1214pub struct NetworkConfigCmd {
1215    #[command(subcommand)]
1216    pub command: NetworkConfigSubCommand,
1217}
1218
1219#[derive(Subcommand, Debug)]
1220pub enum NetworkConfigSubCommand {
1221    #[command(about = "List detected backend and configuration source files")]
1222    List {
1223        #[arg(long, help = "Emit JSON output")]
1224        json: bool,
1225    },
1226}
1227
1228#[derive(Args, Debug)]
1229pub struct NetworkHetznerCmd {
1230    #[command(subcommand)]
1231    pub command: NetworkHetznerSubCommand,
1232}
1233
1234#[derive(Subcommand, Debug)]
1235pub enum NetworkHetznerSubCommand {
1236    #[command(about = "Configure a Hetzner vSwitch VLAN interface persistently")]
1237    Vswitch(NetworkHetznerVswitchCmd),
1238}
1239
1240#[derive(Args, Debug)]
1241pub struct NetworkHetznerVswitchCmd {
1242    #[command(subcommand)]
1243    pub command: NetworkHetznerVswitchSubCommand,
1244}
1245
1246#[derive(Subcommand, Debug)]
1247pub enum NetworkHetznerVswitchSubCommand {
1248    #[command(about = "Write persistent Linux config for a Hetzner vSwitch VLAN interface")]
1249    Setup {
1250        #[arg(
1251            long,
1252            help = "Private IPv4 address to assign on the vSwitch VLAN interface"
1253        )]
1254        ip: String,
1255        #[arg(
1256            long,
1257            default_value_t = 24,
1258            help = "CIDR prefix for --ip (default: 24)"
1259        )]
1260        cidr: u8,
1261        #[arg(long, help = "Physical parent interface (auto-detected when omitted)")]
1262        interface: Option<String>,
1263        #[arg(long, help = "Hetzner vSwitch VLAN ID")]
1264        vlan_id: u16,
1265        #[arg(long, default_value_t = 1400, help = "Interface MTU (default: 1400)")]
1266        mtu: u16,
1267        #[arg(
1268            long,
1269            default_value = "10.0.3.1",
1270            help = "Gateway for the routed Hetzner cloud network"
1271        )]
1272        gateway: String,
1273        #[arg(
1274            long,
1275            default_value = "10.0.0.0/16",
1276            help = "Destination CIDR routed through the Hetzner vSwitch gateway"
1277        )]
1278        route_cidr: String,
1279        #[arg(long, help = "Apply or activate the new config immediately")]
1280        apply: bool,
1281        #[arg(long, help = "Preview file changes without writing them")]
1282        dry_run: bool,
1283    },
1284}
1285
1286#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1287pub enum NginxDnsMode {
1288    Manual,
1289    Plugin,
1290}
1291
1292#[derive(Subcommand, Debug)]
1293pub enum NginxSubCommand {
1294    #[command(
1295        about = "Provision an HTTPS NGINX reverse proxy with Certbot",
1296        long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
1297and write final HTTP->HTTPS redirect + TLS proxy config.\n\
1298\n\
1299Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
1300Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
1301with --dns-plugin and --dns-creds for non-interactive provider automation."
1302    )]
1303    Setup {
1304        #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
1305        domain: String,
1306        #[arg(short, long, help = "Port to proxy to")]
1307        port: u16,
1308        #[arg(
1309            short,
1310            long,
1311            help = "Email used for Let's Encrypt account registration"
1312        )]
1313        email: String,
1314        #[arg(
1315            long,
1316            value_enum,
1317            default_value_t = NginxDnsMode::Manual,
1318            help = "DNS challenge mode for wildcard certificates: manual or plugin"
1319        )]
1320        dns_mode: NginxDnsMode,
1321        #[arg(
1322            long,
1323            help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
1324        )]
1325        dns_plugin: Option<String>,
1326        #[arg(
1327            long,
1328            help = "Path to DNS plugin credentials file for --dns-mode plugin"
1329        )]
1330        dns_creds: Option<PathBuf>,
1331        #[arg(
1332            long,
1333            default_value_t = true,
1334            action = clap::ArgAction::Set,
1335            value_parser = clap::builder::BoolishValueParser::new(),
1336            help = "For wildcard domains, also request the base domain certificate (true|false)"
1337        )]
1338        include_base: bool,
1339    },
1340    #[command(about = "List discovered NGINX sites with listen/upstream ports")]
1341    List,
1342    #[command(about = "Show full NGINX config for one domain or all domains")]
1343    Show {
1344        #[arg(help = "Optional domain name to inspect")]
1345        domain: Option<String>,
1346    },
1347    #[command(about = "Open an NGINX site config in your configured editor")]
1348    Edit {
1349        #[arg(help = "Domain name to edit")]
1350        domain: String,
1351    },
1352    #[command(about = "Update upstream port for an existing NGINX site")]
1353    Update {
1354        #[arg(short, long, help = "Domain name to update")]
1355        domain: String,
1356        #[arg(short, long, help = "New port to proxy to")]
1357        port: u16,
1358    },
1359}
1360
1361#[derive(Args, Debug)]
1362#[command(
1363    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1364    after_help = crate::cli::help_render::DIAG_AFTER_HELP
1365)]
1366pub struct DiagCmd {
1367    #[arg(long, help = "Check Nginx configuration")]
1368    pub nginx: bool,
1369    #[arg(long, hide = true)]
1370    pub refresh_system_inventory: bool,
1371    #[arg(
1372        long,
1373        help = "Refresh and print persisted machine inventory in the global XBP config"
1374    )]
1375    pub codetime: bool,
1376    #[arg(
1377        long,
1378        help = "Inspect Cursor roaming data on Windows; implies --codetime"
1379    )]
1380    pub cursor: bool,
1381    #[arg(long, help = "Check specific ports (comma-separated)")]
1382    pub ports: Option<String>,
1383    #[arg(long, help = "Skip internet speed test")]
1384    pub no_speed_test: bool,
1385    #[arg(
1386        long,
1387        help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
1388    )]
1389    pub compose_file: Option<String>,
1390}
1391
1392#[derive(Args, Debug)]
1393pub struct MonitorCmd {
1394    #[command(subcommand)]
1395    pub command: Option<MonitorSubCommand>,
1396}
1397
1398#[derive(Subcommand, Debug)]
1399pub enum MonitorSubCommand {
1400    Check,
1401    Start,
1402}
1403
1404#[cfg(feature = "monitoring")]
1405#[derive(Args, Debug)]
1406pub struct MonitoringCmd {
1407    #[command(subcommand)]
1408    pub command: MonitoringSubCommand,
1409}
1410
1411#[cfg(feature = "monitoring")]
1412#[derive(Subcommand, Debug)]
1413pub enum MonitoringSubCommand {
1414    Serve {
1415        #[arg(
1416            short,
1417            long,
1418            default_value = "prodzilla.yml",
1419            help = "Monitoring config file"
1420        )]
1421        file: String,
1422    },
1423    RunOnce {
1424        #[arg(
1425            short,
1426            long,
1427            default_value = "prodzilla.yml",
1428            help = "Monitoring config file"
1429        )]
1430        file: String,
1431        #[arg(long, help = "Run probes only")]
1432        probes_only: bool,
1433        #[arg(long, help = "Run stories only")]
1434        stories_only: bool,
1435    },
1436    List {
1437        #[arg(
1438            short,
1439            long,
1440            default_value = "prodzilla.yml",
1441            help = "Monitoring config file"
1442        )]
1443        file: String,
1444    },
1445}
1446
1447#[derive(Args, Debug)]
1448#[command(
1449    arg_required_else_help = true,
1450    disable_help_subcommand = true,
1451    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1452    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."
1453)]
1454pub struct ApiCmd {
1455    #[command(subcommand)]
1456    pub command: ApiSubCommand,
1457}
1458
1459#[derive(Args, Debug)]
1460#[command(
1461    arg_required_else_help = true,
1462    disable_help_subcommand = true,
1463    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1464    after_help = "Examples:\n  xbp runners orgs list\n  xbp runners groups list --organization-id <uuid>\n  xbp runners groups create --organization-id <uuid> --name builders --visibility selected\n  xbp runners groups access <runner-group-id>\n  xbp runners inventory --organization-id <uuid>\n  xbp runners hosts enroll --organization-id <uuid> --platform linux --hostname runner-1 --runner-name-prefix xbp-linux\n  xbp runners hosts preflight --runner-host-id <uuid>\n  xbp runners status --organization-id <uuid>\n  xbp runners deploy --organization-id <uuid> --runner-host-id <uuid>\n  xbp runners agent serve --runner-host-id <uuid>"
1465)]
1466pub struct RunnersCmd {
1467    #[command(subcommand)]
1468    pub command: RunnersSubCommand,
1469}
1470
1471#[derive(Subcommand, Debug)]
1472pub enum RunnersSubCommand {
1473    #[command(about = "List organizations already registered in the XBP control plane")]
1474    OrgsList(RunnersOrgsListCmd),
1475    #[command(about = "Manage runner groups")]
1476    Groups(RunnersGroupsCmd),
1477    #[command(about = "List or enroll runner hosts")]
1478    Hosts(RunnersHostsCmd),
1479    #[command(about = "List runner inventory for an organization")]
1480    Inventory(RunnersInventoryCmd),
1481    #[command(about = "Show runner status for an organization or host")]
1482    Status(RunnersStatusCmd),
1483    #[command(about = "Show recent runner job logs and failures")]
1484    Logs(RunnersLogsCmd),
1485    #[command(about = "Queue a runner sync job")]
1486    Sync(RunnersSyncCmd),
1487    #[command(about = "Queue a runner deployment job")]
1488    Deploy(RunnersDeployCmd),
1489    #[command(about = "Queue a runner update job")]
1490    Update(RunnersUpdateCmd),
1491    #[command(about = "Queue a runner removal job")]
1492    Remove(RunnersRemoveCmd),
1493    #[command(about = "Run a local runner host poller that claims and executes jobs")]
1494    Agent(RunnersAgentCmd),
1495}
1496
1497#[derive(Args, Debug)]
1498pub struct RunnersOrgsListCmd {
1499    #[command(flatten)]
1500    pub target: ApiTargetOptions,
1501}
1502
1503#[derive(Args, Debug)]
1504pub struct RunnersGroupsCmd {
1505    #[command(subcommand)]
1506    pub command: RunnersGroupsSubCommand,
1507}
1508
1509#[derive(Subcommand, Debug)]
1510pub enum RunnersGroupsSubCommand {
1511    #[command(about = "List runner groups for an organization")]
1512    List(RunnersGroupsListCmd),
1513    #[command(about = "Create or upsert a runner group")]
1514    Create(RunnersGroupsCreateCmd),
1515    #[command(about = "Update an existing runner group")]
1516    Update(RunnersGroupsCreateCmd),
1517    #[command(about = "Delete a runner group")]
1518    Delete(RunnersGroupsDeleteCmd),
1519    #[command(about = "Inspect effective repository access for a runner group")]
1520    Access(RunnersGroupsAccessCmd),
1521}
1522
1523#[derive(Args, Debug)]
1524pub struct RunnersGroupsListCmd {
1525    #[arg(long, help = "Organization ID")]
1526    pub organization_id: String,
1527    #[command(flatten)]
1528    pub target: ApiTargetOptions,
1529}
1530
1531#[derive(Args, Debug, Clone)]
1532pub struct RunnersGroupsCreateCmd {
1533    #[arg(long, help = "Organization ID")]
1534    pub organization_id: String,
1535    #[arg(long, help = "Runner group name")]
1536    pub name: String,
1537    #[arg(long, help = "Visibility: all, selected, or private")]
1538    pub visibility: Option<String>,
1539    #[arg(long, help = "Optional GitHub installation ID")]
1540    pub github_installation_id: Option<String>,
1541    #[arg(long, help = "Optional GitHub runner group ID")]
1542    pub github_runner_group_id: Option<i64>,
1543    #[arg(long, help = "Optional sync state")]
1544    pub sync_state: Option<String>,
1545    #[arg(long, help = "Mark group as inherited")]
1546    pub inherited: bool,
1547    #[arg(long, help = "Allow public repositories")]
1548    pub allows_public_repositories: bool,
1549    #[arg(long, help = "Prompt for visibility and repository access settings")]
1550    pub interactive: bool,
1551    #[arg(long, help = "Restrict group to workflows")]
1552    pub restricted_to_workflows: bool,
1553    #[arg(long, help = "Selected workflows JSON array")]
1554    pub selected_workflows_json: Option<String>,
1555    #[arg(long, help = "Metadata JSON object")]
1556    pub metadata_json: Option<String>,
1557    #[command(flatten)]
1558    pub target: ApiTargetOptions,
1559}
1560
1561#[derive(Args, Debug)]
1562pub struct RunnersGroupsDeleteCmd {
1563    #[arg(help = "Runner group ID")]
1564    pub runner_group_id: String,
1565    #[command(flatten)]
1566    pub target: ApiTargetOptions,
1567}
1568
1569#[derive(Args, Debug)]
1570pub struct RunnersGroupsAccessCmd {
1571    #[arg(help = "Runner group ID")]
1572    pub runner_group_id: String,
1573    #[command(flatten)]
1574    pub target: ApiTargetOptions,
1575}
1576
1577#[derive(Args, Debug)]
1578pub struct RunnersHostsCmd {
1579    #[command(subcommand)]
1580    pub command: RunnersHostsSubCommand,
1581}
1582
1583#[derive(Subcommand, Debug)]
1584pub enum RunnersHostsSubCommand {
1585    #[command(about = "List runner hosts")]
1586    List(RunnersHostsListCmd),
1587    #[command(about = "Enroll or upsert a runner host")]
1588    Enroll(Box<RunnersHostsEnrollCmd>),
1589    #[command(about = "Run host preflight checks and persist the result")]
1590    Preflight(RunnersHostsPreflightCmd),
1591    #[command(about = "Inspect host enrollment, heartbeat, and preflight state")]
1592    Status(RunnersHostsStatusCmd),
1593}
1594
1595#[derive(Args, Debug)]
1596pub struct RunnersHostsListCmd {
1597    #[arg(long, help = "Optional organization ID")]
1598    pub organization_id: Option<String>,
1599    #[arg(long, help = "Optional daemon ID")]
1600    pub daemon_id: Option<String>,
1601    #[arg(long, help = "Optional status filter")]
1602    pub status: Option<String>,
1603    #[command(flatten)]
1604    pub target: ApiTargetOptions,
1605}
1606
1607#[derive(Args, Debug)]
1608pub struct RunnersHostsEnrollCmd {
1609    #[arg(long, help = "Organization ID")]
1610    pub organization_id: String,
1611    #[arg(long, help = "Optional daemon ID for daemon-backed hosts")]
1612    pub daemon_id: Option<String>,
1613    #[arg(long, help = "Host kind: daemon or rogue")]
1614    pub host_kind: String,
1615    #[arg(long, help = "Platform: linux, windows, or macos")]
1616    pub platform: String,
1617    #[arg(long, help = "Hostname")]
1618    pub hostname: String,
1619    #[arg(long, help = "Runner name prefix")]
1620    pub runner_name_prefix: String,
1621    #[arg(long, help = "Optional display name")]
1622    pub display_name: Option<String>,
1623    #[arg(long, help = "Optional architecture")]
1624    pub arch: Option<String>,
1625    #[arg(long, help = "Optional status")]
1626    pub status: Option<String>,
1627    #[arg(long, help = "Labels JSON object")]
1628    pub labels_json: Option<String>,
1629    #[arg(long, help = "Capabilities JSON object")]
1630    pub capabilities_json: Option<String>,
1631    #[arg(long, help = "Metadata JSON object")]
1632    pub metadata_json: Option<String>,
1633    #[command(flatten)]
1634    pub target: ApiTargetOptions,
1635}
1636
1637#[derive(Args, Debug)]
1638pub struct RunnersHostsPreflightCmd {
1639    #[arg(long, help = "Runner host ID")]
1640    pub runner_host_id: String,
1641    #[arg(
1642        long,
1643        help = "Persist the preflight result back to the control-plane API"
1644    )]
1645    pub write_api: bool,
1646    #[command(flatten)]
1647    pub target: ApiTargetOptions,
1648}
1649
1650#[derive(Args, Debug)]
1651pub struct RunnersHostsStatusCmd {
1652    #[arg(long, help = "Runner host ID")]
1653    pub runner_host_id: String,
1654    #[command(flatten)]
1655    pub target: ApiTargetOptions,
1656}
1657
1658#[derive(Args, Debug)]
1659pub struct RunnersInventoryCmd {
1660    #[arg(long, help = "Organization ID")]
1661    pub organization_id: String,
1662    #[command(flatten)]
1663    pub target: ApiTargetOptions,
1664}
1665
1666#[derive(Args, Debug)]
1667pub struct RunnersStatusCmd {
1668    #[arg(long, help = "Organization ID")]
1669    pub organization_id: String,
1670    #[arg(long, help = "Optional runner host ID")]
1671    pub runner_host_id: Option<String>,
1672    #[arg(long, help = "Optional runner ID")]
1673    pub runner_id: Option<String>,
1674    #[arg(long, help = "Optional daemon ID")]
1675    pub daemon_id: Option<String>,
1676    #[arg(long, help = "Optional status filter")]
1677    pub status: Option<String>,
1678    #[arg(long, help = "Optional phase filter")]
1679    pub phase: Option<String>,
1680    #[arg(long, default_value_t = 20, help = "Maximum jobs to show")]
1681    pub limit: usize,
1682    #[command(flatten)]
1683    pub target: ApiTargetOptions,
1684}
1685
1686#[derive(Args, Debug)]
1687pub struct RunnersLogsCmd {
1688    #[arg(long, help = "Optional organization ID")]
1689    pub organization_id: Option<String>,
1690    #[arg(long, help = "Optional runner host ID")]
1691    pub runner_host_id: Option<String>,
1692    #[arg(long, help = "Optional runner ID")]
1693    pub runner_id: Option<String>,
1694    #[arg(long, help = "Optional daemon ID")]
1695    pub daemon_id: Option<String>,
1696    #[arg(long, help = "Optional status filter")]
1697    pub status: Option<String>,
1698    #[arg(long, help = "Optional phase filter")]
1699    pub phase: Option<String>,
1700    #[arg(long, default_value_t = 10, help = "Maximum jobs to show")]
1701    pub limit: usize,
1702    #[command(flatten)]
1703    pub target: ApiTargetOptions,
1704}
1705
1706#[derive(Args, Debug)]
1707pub struct RunnersSyncCmd {
1708    #[arg(long, help = "Organization ID")]
1709    pub organization_id: String,
1710    #[arg(long, help = "Optional runner host ID")]
1711    pub runner_host_id: Option<String>,
1712    #[arg(long, help = "Optional daemon ID")]
1713    pub daemon_id: Option<String>,
1714    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
1715    pub run_after: Option<String>,
1716    #[arg(long, help = "Payload JSON object")]
1717    pub payload_json: Option<String>,
1718    #[command(flatten)]
1719    pub target: ApiTargetOptions,
1720}
1721
1722#[derive(Args, Debug)]
1723pub struct RunnersDeployCmd {
1724    #[arg(long, help = "Organization ID")]
1725    pub organization_id: String,
1726    #[arg(long, help = "Optional runner host ID")]
1727    pub runner_host_id: Option<String>,
1728    #[arg(long, help = "Optional daemon ID")]
1729    pub daemon_id: Option<String>,
1730    #[arg(long, help = "Optional existing runner ID")]
1731    pub runner_id: Option<String>,
1732    #[arg(long, help = "Optional priority")]
1733    pub priority: Option<i32>,
1734    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
1735    pub run_after: Option<String>,
1736    #[arg(long, help = "Payload JSON object")]
1737    pub payload_json: Option<String>,
1738    #[command(flatten)]
1739    pub target: ApiTargetOptions,
1740}
1741
1742#[derive(Args, Debug)]
1743pub struct RunnersUpdateCmd {
1744    #[arg(long, help = "Organization ID")]
1745    pub organization_id: String,
1746    #[arg(long, help = "Optional runner host ID")]
1747    pub runner_host_id: Option<String>,
1748    #[arg(long, help = "Optional daemon ID")]
1749    pub daemon_id: Option<String>,
1750    #[arg(long, help = "Optional existing runner ID")]
1751    pub runner_id: Option<String>,
1752    #[arg(long, help = "Optional priority")]
1753    pub priority: Option<i32>,
1754    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
1755    pub run_after: Option<String>,
1756    #[arg(long, help = "Payload JSON object")]
1757    pub payload_json: Option<String>,
1758    #[command(flatten)]
1759    pub target: ApiTargetOptions,
1760}
1761
1762#[derive(Args, Debug)]
1763pub struct RunnersRemoveCmd {
1764    #[arg(long, help = "Organization ID")]
1765    pub organization_id: String,
1766    #[arg(long, help = "Existing runner ID")]
1767    pub runner_id: String,
1768    #[arg(long, help = "Optional runner host ID")]
1769    pub runner_host_id: Option<String>,
1770    #[arg(long, help = "Optional daemon ID")]
1771    pub daemon_id: Option<String>,
1772    #[arg(long, help = "Optional priority")]
1773    pub priority: Option<i32>,
1774    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
1775    pub run_after: Option<String>,
1776    #[arg(long, help = "Payload JSON object")]
1777    pub payload_json: Option<String>,
1778    #[command(flatten)]
1779    pub target: ApiTargetOptions,
1780}
1781
1782#[derive(Args, Debug)]
1783pub struct RunnersAgentCmd {
1784    #[command(subcommand)]
1785    pub command: RunnersAgentSubCommand,
1786}
1787
1788#[derive(Subcommand, Debug)]
1789pub enum RunnersAgentSubCommand {
1790    #[command(about = "Serve as a local runner host agent")]
1791    Serve(RunnersAgentServeCmd),
1792}
1793
1794#[derive(Args, Debug)]
1795pub struct RunnersAgentServeCmd {
1796    #[arg(long, help = "Runner host ID")]
1797    pub runner_host_id: String,
1798    #[arg(long, default_value_t = 15, help = "Polling interval in seconds")]
1799    pub interval_seconds: u64,
1800    #[arg(long, help = "Process at most one job and exit")]
1801    pub once: bool,
1802    #[command(flatten)]
1803    pub target: ApiTargetOptions,
1804}
1805
1806#[derive(Args, Debug)]
1807#[command(
1808    arg_required_else_help = true,
1809    disable_help_subcommand = true,
1810    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1811    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"
1812)]
1813pub struct McpCmd {
1814    #[command(subcommand)]
1815    pub command: McpSubCommand,
1816}
1817
1818#[derive(Subcommand, Debug)]
1819pub enum McpSubCommand {
1820    #[command(about = "Start the MCP HTTP/SSE server (foreground or detached)")]
1821    Serve(McpServeCmd),
1822    #[command(about = "Install and enable xbp-mcp.service via systemd")]
1823    Install(McpInstallCmd),
1824    #[command(about = "Check whether the MCP server is reachable")]
1825    Status(McpStatusCmd),
1826    #[command(about = "Print Cursor MCP configuration JSON")]
1827    Config(McpConfigCmd),
1828    #[command(about = "Print prefilled MCP Inspector URLs and tool-call presets")]
1829    Inspector(McpInspectorCmd),
1830}
1831
1832#[derive(Args, Debug)]
1833pub struct McpServeCmd {
1834    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
1835    pub bind: String,
1836    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
1837    pub port: u16,
1838    #[arg(
1839        long,
1840        help = "Spawn a background MCP server process and print Cursor config"
1841    )]
1842    pub detach: bool,
1843    #[arg(long, help = "Use stdio MCP transport instead of HTTP/SSE")]
1844    pub stdio: bool,
1845    #[arg(
1846        long,
1847        help = "Show a system tray icon (xbp.app icon) with a Quit action"
1848    )]
1849    pub tray: bool,
1850}
1851
1852#[derive(Args, Debug)]
1853pub struct McpInstallCmd {
1854    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
1855    pub bind: String,
1856    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
1857    pub port: u16,
1858}
1859
1860#[derive(Args, Debug)]
1861pub struct McpStatusCmd {
1862    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
1863    pub bind: String,
1864    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
1865    pub port: u16,
1866}
1867
1868#[derive(Args, Debug)]
1869pub struct McpConfigCmd {
1870    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
1871    pub bind: String,
1872    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
1873    pub port: u16,
1874}
1875
1876#[derive(Args, Debug)]
1877pub struct McpInspectorCmd {
1878    #[arg(
1879        long,
1880        default_value = "127.0.0.1",
1881        help = "Bind address for the xbp MCP server"
1882    )]
1883    pub bind: String,
1884    #[arg(
1885        long,
1886        default_value_t = 1113,
1887        help = "HTTP port for the xbp MCP SSE transport"
1888    )]
1889    pub port: u16,
1890    #[arg(long, default_value_t = 6274, help = "MCP Inspector UI port")]
1891    pub inspector_port: u16,
1892    #[arg(
1893        long,
1894        help = "Write .xbp/mcp-inspector.json for `npx @modelcontextprotocol/inspector --config`"
1895    )]
1896    pub write_config: bool,
1897    #[arg(
1898        long,
1899        help = "Launch MCP Inspector via npx (writes config first; requires Node.js)"
1900    )]
1901    pub launch: bool,
1902    #[arg(long, help = "Emit machine-readable JSON playbook")]
1903    pub json: bool,
1904}
1905
1906#[derive(Args, Debug)]
1907#[command(
1908    arg_required_else_help = true,
1909    disable_help_subcommand = true,
1910    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1911    after_help = crate::cli::help_render::WORKTREE_WATCH_AFTER_HELP
1912)]
1913pub struct WorktreeWatchCmd {
1914    #[command(subcommand)]
1915    pub command: WorktreeWatchSubCommand,
1916}
1917
1918#[derive(Subcommand, Debug)]
1919pub enum WorktreeWatchSubCommand {
1920    #[command(about = "Watch the current git worktree and append mutation events locally")]
1921    Start(WorktreeWatchStartCmd),
1922    #[command(about = "Stop a detached background worktree watcher")]
1923    Stop(WorktreeWatchStopCmd),
1924    #[command(about = "Upload unsynced local mutation spool files to xbp.app")]
1925    Sync(WorktreeWatchSyncCmd),
1926    #[command(
1927        about = "Show spool path, sync backlog, and local activity stats",
1928        after_help = crate::cli::help_render::WORKTREE_WATCH_STATUS_AFTER_HELP
1929    )]
1930    Status(WorktreeWatchStatusCmd),
1931}
1932
1933#[derive(Args, Debug, Clone)]
1934pub struct WorktreeWatchTarget {
1935    #[arg(
1936        long,
1937        conflicts_with = "parent",
1938        help = "Git repository root to watch; defaults to the current repo"
1939    )]
1940    pub repo: Option<PathBuf>,
1941    #[arg(
1942        long,
1943        conflicts_with = "repo",
1944        help = "Folder containing git repositories to target recursively"
1945    )]
1946    pub parent: Option<PathBuf>,
1947}
1948
1949#[derive(Args, Debug)]
1950pub struct WorktreeWatchStartCmd {
1951    #[command(flatten)]
1952    pub target: WorktreeWatchTarget,
1953    #[arg(long, help = "Spawn the watcher as a detached background process")]
1954    pub detach: bool,
1955    #[arg(
1956        long,
1957        default_value_t = 60,
1958        help = "Upload queued events every N seconds while watching; set 0 to disable periodic sync"
1959    )]
1960    pub sync_interval_seconds: u64,
1961    #[arg(long, help = "Record current git commit state once and exit")]
1962    pub once: bool,
1963}
1964
1965#[derive(Args, Debug)]
1966pub struct WorktreeWatchStopCmd {
1967    #[command(flatten)]
1968    pub target: WorktreeWatchTarget,
1969    #[arg(
1970        long,
1971        help = "Stop the PID from watcher state even when the command line cannot be verified"
1972    )]
1973    pub force: bool,
1974}
1975
1976#[derive(Args, Debug)]
1977pub struct WorktreeWatchSyncCmd {
1978    #[command(flatten)]
1979    pub target: WorktreeWatchTarget,
1980    #[arg(long, help = "Print the upload plan without sending it to xbp.app")]
1981    pub dry_run: bool,
1982    #[arg(
1983        long,
1984        help = "Upload all local JSONL spool files, including files already marked synced"
1985    )]
1986    pub resync: bool,
1987}
1988
1989#[derive(Args, Debug)]
1990pub struct WorktreeWatchStatusCmd {
1991    #[command(flatten)]
1992    pub target: WorktreeWatchTarget,
1993    #[arg(long, help = "Emit status as JSON")]
1994    pub json: bool,
1995    #[arg(
1996        long,
1997        help = "Print decoded mutation and commit records from local JSONL spool files"
1998    )]
1999    pub records: bool,
2000    #[arg(
2001        long,
2002        default_value_t = 50,
2003        requires = "records",
2004        help = "Maximum decoded JSONL records to print per repository"
2005    )]
2006    pub record_limit: usize,
2007    #[arg(
2008        long,
2009        help = "Generate and print activity statistics from local JSONL spool files (writes stats.json)"
2010    )]
2011    pub stats: bool,
2012    #[arg(
2013        long,
2014        help = "Generate and print repository activity across all locally spooled branches (writes repo-activity.json)"
2015    )]
2016    pub repo_activity: bool,
2017    #[arg(
2018        long,
2019        default_value_t = 15,
2020        help = "Maximum minutes between mutation events counted as one coding session (used by --stats / --repo-activity)"
2021    )]
2022    pub stats_gap_minutes: u64,
2023}
2024
2025#[derive(Args, Debug, Clone, Default)]
2026pub struct ApiTargetOptions {
2027    #[arg(long, help = "Override the request base URL for this command")]
2028    pub base_url: Option<String>,
2029    #[arg(
2030        long,
2031        help = "Target the hosted web origin (xbp.app) instead of the configured API_XBP_URL base"
2032    )]
2033    pub web: bool,
2034    #[arg(
2035        long,
2036        help = "Skip bearer token auth even when XBP_API_TOKEN is configured"
2037    )]
2038    pub no_auth: bool,
2039    #[arg(
2040        long,
2041        help = "Extra header in 'Name: Value' format",
2042        value_name = "HEADER"
2043    )]
2044    pub header: Vec<String>,
2045    #[arg(long, help = "Print response headers")]
2046    pub include_headers: bool,
2047    #[arg(
2048        long,
2049        help = "Print the response body as-is without JSON pretty formatting"
2050    )]
2051    pub raw: bool,
2052}
2053
2054#[cfg(feature = "docker")]
2055#[derive(Args, Debug)]
2056pub struct DockerCmd {
2057    #[arg(
2058        trailing_var_arg = true,
2059        allow_hyphen_values = true,
2060        help = "Arguments to pass directly to the Docker CLI (default: --help)"
2061    )]
2062    pub args: Vec<String>,
2063}
2064
2065#[derive(Subcommand, Debug)]
2066pub enum ApiSubCommand {
2067    #[command(about = "Install and enable the local xbp-api.service on Linux/systemd")]
2068    Install {
2069        #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
2070        port: u16,
2071    },
2072    #[command(about = "Call the XBP API health endpoint")]
2073    Health(ApiHealthCmd),
2074    #[command(about = "Manage XBP control-plane projects")]
2075    Projects(ApiProjectsCmd),
2076    #[command(about = "Manage XBP daemon registrations and heartbeats")]
2077    Daemons(ApiDaemonsCmd),
2078    #[command(about = "Manage XBP deployment jobs")]
2079    Jobs(ApiJobsCmd),
2080    #[command(about = "Manage XBP runner hosts, groups, inventory, and runner jobs")]
2081    Runners(ApiRunnersCmd),
2082    #[command(about = "Manage XBP proxy routes on the local API server")]
2083    Routes(ApiRoutesCmd),
2084    #[command(about = "Send an authenticated HTTP request to the configured XBP API surface")]
2085    Request(ApiRequestCmd),
2086}
2087
2088#[derive(Args, Debug)]
2089pub struct ApiHealthCmd {
2090    #[command(flatten)]
2091    pub target: ApiTargetOptions,
2092}
2093
2094#[derive(Args, Debug)]
2095pub struct ApiProjectsCmd {
2096    #[command(subcommand)]
2097    pub command: ApiProjectsSubCommand,
2098}
2099
2100#[derive(Subcommand, Debug)]
2101pub enum ApiProjectsSubCommand {
2102    #[command(about = "List projects from the XBP control-plane API")]
2103    List(ApiProjectsListCmd),
2104    #[command(about = "Create or upsert a control-plane project")]
2105    Create(Box<ApiProjectsCreateCmd>),
2106}
2107
2108#[derive(Args, Debug)]
2109pub struct ApiProjectsListCmd {
2110    #[arg(long, help = "Optional organization ID filter")]
2111    pub organization_id: Option<String>,
2112    #[command(flatten)]
2113    pub target: ApiTargetOptions,
2114}
2115
2116#[derive(Args, Debug)]
2117pub struct ApiProjectsCreateCmd {
2118    #[arg(long, help = "Project name")]
2119    pub name: String,
2120    #[arg(long, help = "Project path or repo path key")]
2121    pub path: String,
2122    #[arg(long, help = "Optional organization ID")]
2123    pub organization_id: Option<String>,
2124    #[arg(long, help = "Optional project slug")]
2125    pub slug: Option<String>,
2126    #[arg(long, help = "Optional project version")]
2127    pub version: Option<String>,
2128    #[arg(long, help = "Optional build directory")]
2129    pub build_dir: Option<String>,
2130    #[arg(long, help = "Optional runtime enum value")]
2131    pub runtime: Option<String>,
2132    #[arg(long, help = "Optional default branch")]
2133    pub default_branch: Option<String>,
2134    #[arg(long, help = "Optional repository root directory")]
2135    pub root_directory: Option<String>,
2136    #[arg(long, help = "Optional build command")]
2137    pub build_command: Option<String>,
2138    #[arg(long, help = "Optional install command")]
2139    pub install_command: Option<String>,
2140    #[arg(long, help = "Optional start command")]
2141    pub start_command: Option<String>,
2142    #[arg(long, help = "Optional output directory")]
2143    pub output_directory: Option<String>,
2144    #[arg(long, help = "Repository JSON payload matching GitRepositoryRef")]
2145    pub repository_json: Option<String>,
2146    #[arg(long, help = "Runtime policy JSON payload")]
2147    pub runtime_policy_json: Option<String>,
2148    #[arg(long, help = "Metadata JSON object")]
2149    pub metadata_json: Option<String>,
2150    #[command(flatten)]
2151    pub target: ApiTargetOptions,
2152}
2153
2154#[derive(Args, Debug)]
2155pub struct ApiDaemonsCmd {
2156    #[command(subcommand)]
2157    pub command: ApiDaemonsSubCommand,
2158}
2159
2160#[derive(Subcommand, Debug)]
2161pub enum ApiDaemonsSubCommand {
2162    #[command(about = "List registered daemons")]
2163    List(ApiDaemonsListCmd),
2164    #[command(about = "Register or upsert a daemon record")]
2165    Register(ApiDaemonsRegisterCmd),
2166    #[command(about = "Post a heartbeat update for a daemon")]
2167    Heartbeat(ApiDaemonsHeartbeatCmd),
2168    #[command(about = "Update daemon status only")]
2169    UpdateStatus(ApiDaemonsUpdateStatusCmd),
2170}
2171
2172#[derive(Args, Debug)]
2173pub struct ApiDaemonsListCmd {
2174    #[command(flatten)]
2175    pub target: ApiTargetOptions,
2176}
2177
2178#[derive(Args, Debug)]
2179pub struct ApiDaemonsRegisterCmd {
2180    #[arg(long, help = "Daemon node name")]
2181    pub node_name: String,
2182    #[arg(long, help = "Daemon hostname")]
2183    pub hostname: String,
2184    #[arg(long, help = "Daemon binary version")]
2185    pub version: String,
2186    #[arg(long, help = "Optional region")]
2187    pub region: Option<String>,
2188    #[arg(long, help = "Optional public IP")]
2189    pub public_ip: Option<String>,
2190    #[arg(long, help = "Optional internal IP")]
2191    pub internal_ip: Option<String>,
2192    #[arg(long, help = "Optional status enum value")]
2193    pub status: Option<String>,
2194    #[arg(long, help = "Optional CPU core count")]
2195    pub cpu_cores: Option<i32>,
2196    #[arg(long, help = "Optional total memory in MB")]
2197    pub memory_total_mb: Option<i32>,
2198    #[arg(long, help = "Optional total disk in GB")]
2199    pub disk_total_gb: Option<i32>,
2200    #[arg(long, help = "Labels JSON object")]
2201    pub labels_json: Option<String>,
2202    #[arg(long, help = "Metadata JSON object")]
2203    pub metadata_json: Option<String>,
2204    #[command(flatten)]
2205    pub target: ApiTargetOptions,
2206}
2207
2208#[derive(Args, Debug)]
2209pub struct ApiDaemonsHeartbeatCmd {
2210    #[arg(help = "Daemon ID")]
2211    pub daemon_id: String,
2212    #[arg(long, help = "Optional status enum value")]
2213    pub status: Option<String>,
2214    #[arg(long, help = "Optional daemon version")]
2215    pub version: Option<String>,
2216    #[arg(long, help = "Optional public IP")]
2217    pub public_ip: Option<String>,
2218    #[arg(long, help = "Optional internal IP")]
2219    pub internal_ip: Option<String>,
2220    #[arg(long, help = "Optional CPU core count")]
2221    pub cpu_cores: Option<i32>,
2222    #[arg(long, help = "Optional total memory in MB")]
2223    pub memory_total_mb: Option<i32>,
2224    #[arg(long, help = "Optional total disk in GB")]
2225    pub disk_total_gb: Option<i32>,
2226    #[arg(long, help = "Labels JSON object")]
2227    pub labels_json: Option<String>,
2228    #[command(flatten)]
2229    pub target: ApiTargetOptions,
2230}
2231
2232#[derive(Args, Debug)]
2233pub struct ApiDaemonsUpdateStatusCmd {
2234    #[arg(help = "Daemon ID")]
2235    pub daemon_id: String,
2236    #[arg(long, help = "Daemon status enum value")]
2237    pub status: String,
2238    #[command(flatten)]
2239    pub target: ApiTargetOptions,
2240}
2241
2242#[derive(Args, Debug)]
2243pub struct ApiJobsCmd {
2244    #[command(subcommand)]
2245    pub command: ApiJobsSubCommand,
2246}
2247
2248#[derive(Args, Debug)]
2249pub struct ApiRunnersCmd {
2250    #[command(subcommand)]
2251    pub command: ApiRunnersSubCommand,
2252}
2253
2254#[derive(Subcommand, Debug)]
2255pub enum ApiRunnersSubCommand {
2256    #[command(about = "List runner hosts")]
2257    HostsList(ApiRunnersHostsListCmd),
2258    #[command(about = "Register or upsert a runner host")]
2259    HostsEnroll(ApiRunnersHostsEnrollCmd),
2260    #[command(about = "Post a heartbeat update for a runner host")]
2261    HostsHeartbeat(ApiRunnersHostsHeartbeatCmd),
2262    #[command(about = "List persisted preflight records for a runner host")]
2263    HostsPreflightsList(ApiRunnersHostsPreflightsListCmd),
2264    #[command(about = "Create or replace a persisted preflight record for a runner host")]
2265    HostsPreflightsUpsert(ApiRunnersHostsPreflightsUpsertCmd),
2266    #[command(about = "List runner groups for an organization")]
2267    GroupsList(ApiRunnersGroupsListCmd),
2268    #[command(about = "Create or upsert a runner group")]
2269    GroupsCreate(ApiRunnersGroupsCreateCmd),
2270    #[command(about = "Update an existing runner group")]
2271    GroupsUpdate(ApiRunnersGroupsCreateCmd),
2272    #[command(about = "Delete a runner group")]
2273    GroupsDelete(ApiRunnersGroupsDeleteCmd),
2274    #[command(about = "List repository access for a runner group")]
2275    GroupRepositoriesList(ApiRunnersGroupRepositoriesListCmd),
2276    #[command(about = "Grant or upsert repository access for a runner group")]
2277    GroupRepositoriesCreate(ApiRunnersGroupRepositoriesCreateCmd),
2278    #[command(about = "List runner inventory for an organization")]
2279    InventoryList(ApiRunnersInventoryListCmd),
2280    #[command(about = "Upsert a runner inventory record")]
2281    InventoryUpsert(ApiRunnersInventoryUpsertCmd),
2282    #[command(about = "List runner jobs")]
2283    JobsList(ApiRunnersJobsListCmd),
2284    #[command(about = "Create a runner job")]
2285    JobsCreate(ApiRunnersJobsCreateCmd),
2286    #[command(about = "Claim the next runner job")]
2287    JobsClaim(ApiRunnersJobsClaimCmd),
2288    #[command(about = "Update a runner job")]
2289    JobsUpdate(ApiRunnersJobsUpdateCmd),
2290}
2291
2292#[derive(Args, Debug)]
2293pub struct ApiRunnersHostsListCmd {
2294    #[arg(long)]
2295    pub organization_id: Option<String>,
2296    #[arg(long)]
2297    pub daemon_id: Option<String>,
2298    #[arg(long)]
2299    pub status: Option<String>,
2300    #[command(flatten)]
2301    pub target: ApiTargetOptions,
2302}
2303
2304#[derive(Args, Debug)]
2305pub struct ApiRunnersHostsEnrollCmd {
2306    #[arg(long)]
2307    pub organization_id: String,
2308    #[arg(long)]
2309    pub daemon_id: Option<String>,
2310    #[arg(long)]
2311    pub host_kind: String,
2312    #[arg(long)]
2313    pub platform: String,
2314    #[arg(long)]
2315    pub hostname: String,
2316    #[arg(long)]
2317    pub runner_name_prefix: String,
2318    #[arg(long)]
2319    pub display_name: Option<String>,
2320    #[arg(long)]
2321    pub arch: Option<String>,
2322    #[arg(long)]
2323    pub status: Option<String>,
2324    #[arg(long)]
2325    pub labels_json: Option<String>,
2326    #[arg(long)]
2327    pub capabilities_json: Option<String>,
2328    #[arg(long)]
2329    pub metadata_json: Option<String>,
2330    #[command(flatten)]
2331    pub target: ApiTargetOptions,
2332}
2333
2334#[derive(Args, Debug)]
2335pub struct ApiRunnersHostsHeartbeatCmd {
2336    #[arg(help = "Runner host ID")]
2337    pub runner_host_id: String,
2338    #[arg(long)]
2339    pub status: Option<String>,
2340    #[arg(long)]
2341    pub labels_json: Option<String>,
2342    #[arg(long)]
2343    pub capabilities_json: Option<String>,
2344    #[command(flatten)]
2345    pub target: ApiTargetOptions,
2346}
2347
2348#[derive(Args, Debug)]
2349pub struct ApiRunnersHostsPreflightsListCmd {
2350    #[arg(long)]
2351    pub runner_host_id: String,
2352    #[command(flatten)]
2353    pub target: ApiTargetOptions,
2354}
2355
2356#[derive(Args, Debug)]
2357pub struct ApiRunnersHostsPreflightsUpsertCmd {
2358    #[arg(long)]
2359    pub runner_host_id: String,
2360    #[arg(long)]
2361    pub platform: String,
2362    #[arg(long)]
2363    pub status: String,
2364    #[arg(long)]
2365    pub docker_available: Option<bool>,
2366    #[arg(long)]
2367    pub service_manager: Option<String>,
2368    #[arg(long)]
2369    pub checks_json: Option<String>,
2370    #[arg(long)]
2371    pub checked_at: Option<String>,
2372    #[command(flatten)]
2373    pub target: ApiTargetOptions,
2374}
2375
2376#[derive(Args, Debug, Clone)]
2377pub struct ApiRunnersGroupsListCmd {
2378    #[arg(long)]
2379    pub organization_id: String,
2380    #[command(flatten)]
2381    pub target: ApiTargetOptions,
2382}
2383
2384#[derive(Args, Debug, Clone)]
2385pub struct ApiRunnersGroupsCreateCmd {
2386    #[arg(long)]
2387    pub organization_id: String,
2388    #[arg(long)]
2389    pub name: String,
2390    #[arg(long)]
2391    pub visibility: Option<String>,
2392    #[arg(long)]
2393    pub github_installation_id: Option<String>,
2394    #[arg(long)]
2395    pub github_runner_group_id: Option<i64>,
2396    #[arg(long)]
2397    pub sync_state: Option<String>,
2398    #[arg(long)]
2399    pub sync_error: Option<String>,
2400    #[arg(long)]
2401    pub inherited: bool,
2402    #[arg(long)]
2403    pub allows_public_repositories: bool,
2404    #[arg(long)]
2405    pub interactive: bool,
2406    #[arg(long)]
2407    pub restricted_to_workflows: bool,
2408    #[arg(long)]
2409    pub selected_workflows_json: Option<String>,
2410    #[arg(long)]
2411    pub metadata_json: Option<String>,
2412    #[command(flatten)]
2413    pub target: ApiTargetOptions,
2414}
2415
2416#[derive(Args, Debug)]
2417pub struct ApiRunnersGroupsDeleteCmd {
2418    #[arg(help = "Runner group ID")]
2419    pub runner_group_id: String,
2420    #[command(flatten)]
2421    pub target: ApiTargetOptions,
2422}
2423
2424#[derive(Args, Debug)]
2425pub struct ApiRunnersGroupRepositoriesListCmd {
2426    #[arg(help = "Runner group ID")]
2427    pub runner_group_id: String,
2428    #[command(flatten)]
2429    pub target: ApiTargetOptions,
2430}
2431
2432#[derive(Args, Debug)]
2433pub struct ApiRunnersGroupRepositoriesCreateCmd {
2434    #[arg(help = "Runner group ID")]
2435    pub runner_group_id: String,
2436    #[arg(long)]
2437    pub github_repository_id: Option<i64>,
2438    #[arg(long)]
2439    pub repository_owner: String,
2440    #[arg(long)]
2441    pub repository_name: String,
2442    #[arg(long)]
2443    pub repository_full_name: String,
2444    #[arg(long)]
2445    pub is_private: bool,
2446    #[command(flatten)]
2447    pub target: ApiTargetOptions,
2448}
2449
2450#[derive(Args, Debug)]
2451pub struct ApiRunnersInventoryListCmd {
2452    #[arg(long)]
2453    pub organization_id: String,
2454    #[command(flatten)]
2455    pub target: ApiTargetOptions,
2456}
2457
2458#[derive(Args, Debug)]
2459pub struct ApiRunnersInventoryUpsertCmd {
2460    #[arg(long)]
2461    pub organization_id: String,
2462    #[arg(long)]
2463    pub runner_host_id: Option<String>,
2464    #[arg(long)]
2465    pub runner_group_id: Option<String>,
2466    #[arg(long)]
2467    pub github_runner_id: Option<i64>,
2468    #[arg(long)]
2469    pub name: String,
2470    #[arg(long)]
2471    pub platform: String,
2472    #[arg(long)]
2473    pub os: Option<String>,
2474    #[arg(long)]
2475    pub architecture: Option<String>,
2476    #[arg(long)]
2477    pub status: Option<String>,
2478    #[arg(long)]
2479    pub busy: bool,
2480    #[arg(long)]
2481    pub labels_json: Option<String>,
2482    #[arg(long)]
2483    pub metadata_json: Option<String>,
2484    #[command(flatten)]
2485    pub target: ApiTargetOptions,
2486}
2487
2488#[derive(Args, Debug, Clone)]
2489pub struct ApiRunnersJobsListCmd {
2490    #[arg(long)]
2491    pub organization_id: Option<String>,
2492    #[arg(long)]
2493    pub runner_host_id: Option<String>,
2494    #[arg(long)]
2495    pub runner_id: Option<String>,
2496    #[arg(long)]
2497    pub daemon_id: Option<String>,
2498    #[arg(long)]
2499    pub status: Option<String>,
2500    #[arg(long)]
2501    pub phase: Option<String>,
2502    #[arg(long)]
2503    pub limit: Option<usize>,
2504    #[command(flatten)]
2505    pub target: ApiTargetOptions,
2506}
2507
2508#[derive(Args, Debug)]
2509pub struct ApiRunnersJobsCreateCmd {
2510    #[arg(long)]
2511    pub organization_id: String,
2512    #[arg(long)]
2513    pub runner_host_id: Option<String>,
2514    #[arg(long)]
2515    pub daemon_id: Option<String>,
2516    #[arg(long)]
2517    pub runner_id: Option<String>,
2518    #[arg(long)]
2519    pub job_kind: String,
2520    #[arg(long)]
2521    pub priority: Option<i32>,
2522    #[arg(long)]
2523    pub max_attempts: Option<i32>,
2524    #[arg(long)]
2525    pub run_after: Option<String>,
2526    #[arg(long)]
2527    pub payload_json: Option<String>,
2528    #[command(flatten)]
2529    pub target: ApiTargetOptions,
2530}
2531
2532#[derive(Args, Debug)]
2533pub struct ApiRunnersJobsClaimCmd {
2534    #[arg(long)]
2535    pub runner_host_id: Option<String>,
2536    #[arg(long)]
2537    pub daemon_id: Option<String>,
2538    #[arg(long)]
2539    pub locked_by: Option<String>,
2540    #[command(flatten)]
2541    pub target: ApiTargetOptions,
2542}
2543
2544#[derive(Args, Debug)]
2545pub struct ApiRunnersJobsUpdateCmd {
2546    #[arg(help = "Runner job ID")]
2547    pub runner_job_id: String,
2548    #[arg(long)]
2549    pub status: String,
2550    #[arg(long)]
2551    pub error_text: Option<String>,
2552    #[command(flatten)]
2553    pub target: ApiTargetOptions,
2554}
2555
2556#[derive(Subcommand, Debug)]
2557pub enum ApiJobsSubCommand {
2558    #[command(about = "List deployment jobs")]
2559    List(ApiJobsListCmd),
2560    #[command(about = "Create a deployment job for a project")]
2561    Create(ApiJobsCreateCmd),
2562    #[command(about = "Claim the next deployment job for a daemon")]
2563    Claim(ApiJobsClaimCmd),
2564    #[command(about = "Update deployment job status")]
2565    Update(ApiJobsUpdateCmd),
2566}
2567
2568#[derive(Args, Debug)]
2569pub struct ApiJobsListCmd {
2570    #[arg(long, help = "Optional project ID filter")]
2571    pub project_id: Option<String>,
2572    #[arg(long, help = "Optional deployment ID filter")]
2573    pub deployment_id: Option<String>,
2574    #[arg(long, help = "Optional daemon ID filter")]
2575    pub daemon_id: Option<String>,
2576    #[arg(long, help = "Optional status filter")]
2577    pub status: Option<String>,
2578    #[arg(long, help = "Optional result limit")]
2579    pub limit: Option<usize>,
2580    #[command(flatten)]
2581    pub target: ApiTargetOptions,
2582}
2583
2584#[derive(Args, Debug)]
2585pub struct ApiJobsCreateCmd {
2586    #[arg(long, help = "Project ID")]
2587    pub project_id: String,
2588    #[arg(long, help = "Deployment ID")]
2589    pub deployment_id: String,
2590    #[arg(long, help = "Optional daemon ID assignment")]
2591    pub daemon_id: Option<String>,
2592    #[arg(long, help = "Optional priority")]
2593    pub priority: Option<i32>,
2594    #[arg(long, help = "Optional max attempts")]
2595    pub max_attempts: Option<i32>,
2596    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
2597    pub run_after: Option<String>,
2598    #[arg(long, help = "Optional payload JSON object")]
2599    pub payload_json: Option<String>,
2600    #[command(flatten)]
2601    pub target: ApiTargetOptions,
2602}
2603
2604#[derive(Args, Debug)]
2605pub struct ApiJobsClaimCmd {
2606    #[arg(long, help = "Daemon ID claiming work")]
2607    pub daemon_id: String,
2608    #[arg(long, help = "Optional lock owner")]
2609    pub locked_by: Option<String>,
2610    #[command(flatten)]
2611    pub target: ApiTargetOptions,
2612}
2613
2614#[derive(Args, Debug)]
2615pub struct ApiJobsUpdateCmd {
2616    #[arg(help = "Deployment job ID")]
2617    pub job_id: String,
2618    #[arg(long, help = "Deployment job status enum value")]
2619    pub status: String,
2620    #[arg(long, help = "Optional error text")]
2621    pub error_text: Option<String>,
2622    #[command(flatten)]
2623    pub target: ApiTargetOptions,
2624}
2625
2626#[derive(Args, Debug)]
2627pub struct ApiRoutesCmd {
2628    #[command(subcommand)]
2629    pub command: ApiRoutesSubCommand,
2630}
2631
2632#[derive(Subcommand, Debug)]
2633pub enum ApiRoutesSubCommand {
2634    #[command(about = "List configured proxy routes")]
2635    List(ApiRoutesListCmd),
2636    #[command(about = "Create or replace a proxy route")]
2637    Create(ApiRoutesCreateCmd),
2638    #[command(about = "Delete a proxy route by domain")]
2639    Delete(ApiRoutesDeleteCmd),
2640}
2641
2642#[derive(Args, Debug)]
2643pub struct ApiRoutesListCmd {
2644    #[command(flatten)]
2645    pub target: ApiTargetOptions,
2646}
2647
2648#[derive(Args, Debug)]
2649pub struct ApiRoutesCreateCmd {
2650    #[arg(long, help = "Domain name for the route")]
2651    pub domain: String,
2652    #[arg(long, help = "Upstream target URL", required = true)]
2653    pub target: Vec<String>,
2654    #[arg(
2655        long,
2656        help = "Weighted upstream target in url=weight form",
2657        value_name = "URL=WEIGHT"
2658    )]
2659    pub weighted_target: Vec<String>,
2660    #[arg(long, help = "Optional header condition")]
2661    pub header_condition: Option<String>,
2662    #[arg(long, help = "Optional path prefix condition")]
2663    pub path_prefix: Option<String>,
2664    #[command(flatten)]
2665    pub target_options: ApiTargetOptions,
2666}
2667
2668#[derive(Args, Debug)]
2669pub struct ApiRoutesDeleteCmd {
2670    #[arg(help = "Domain name for the route")]
2671    pub domain: String,
2672    #[command(flatten)]
2673    pub target: ApiTargetOptions,
2674}
2675
2676#[derive(Args, Debug)]
2677pub struct ApiRequestCmd {
2678    #[arg(help = "Request path like /projects or a full https:// URL")]
2679    pub path: String,
2680    #[arg(
2681        short = 'X',
2682        long,
2683        help = "HTTP method to use (default: GET, or POST when a body is provided)"
2684    )]
2685    pub method: Option<String>,
2686    #[arg(short = 'd', long, help = "Inline request body string, typically JSON")]
2687    pub body: Option<String>,
2688    #[arg(long, help = "Read the request body from a file")]
2689    pub body_file: Option<PathBuf>,
2690    #[command(flatten)]
2691    pub target: ApiTargetOptions,
2692}
2693#[derive(Args, Debug)]
2694pub struct TailCmd {
2695    #[arg(long, help = "Tail Kafka topic instead of log files")]
2696    pub kafka: bool,
2697    #[arg(long, help = "Ship logs to Kafka")]
2698    pub ship: bool,
2699}
2700
2701#[derive(Args, Debug)]
2702#[command(
2703    arg_required_else_help = true,
2704    disable_help_subcommand = true,
2705    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2706    after_help = crate::cli::help_render::GENERATE_AFTER_HELP
2707)]
2708pub struct GenerateCmd {
2709    #[command(subcommand)]
2710    pub command: GenerateSubCommand,
2711}
2712
2713#[derive(Subcommand, Debug)]
2714pub enum GenerateSubCommand {
2715    #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
2716    Config(GenerateConfigCmd),
2717    #[cfg(feature = "openapi-gen")]
2718    #[command(about = "Generate static OpenAPI documents for configured services")]
2719    Openapi(GenerateOpenApiCmd),
2720    Systemd(GenerateSystemdCmd),
2721}
2722
2723#[cfg(feature = "openapi-gen")]
2724#[derive(Args, Debug)]
2725pub struct GenerateOpenApiCmd {
2726    #[arg(long, action = clap::ArgAction::Append, help = "Generate only the named service (repeatable)")]
2727    pub service: Vec<String>,
2728    #[arg(
2729        long,
2730        conflicts_with = "service",
2731        help = "Generate every eligible service and the aggregate"
2732    )]
2733    pub all: bool,
2734    #[arg(long, value_parser = ["yaml", "json", "both"], help = "Override configured output formats")]
2735    pub format: Option<String>,
2736    #[arg(
2737        long,
2738        help = "Override the output path (requires one service and one format)"
2739    )]
2740    pub output: Option<PathBuf>,
2741    #[arg(long, help = "Fail if generated output differs without writing files")]
2742    pub check: bool,
2743    #[arg(long, help = "Render and validate without writing files")]
2744    pub dry_run: bool,
2745    #[arg(long, help = "Fail when a source type cannot be resolved")]
2746    pub strict: bool,
2747    #[arg(long, help = "Ignore the parsed-source cache")]
2748    pub no_cache: bool,
2749}
2750
2751#[derive(Args, Debug)]
2752pub struct GenerateConfigCmd {
2753    #[arg(
2754        long,
2755        help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
2756    )]
2757    pub force: bool,
2758    #[arg(
2759        long,
2760        help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
2761    )]
2762    pub update: bool,
2763    #[arg(
2764        long,
2765        help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
2766    )]
2767    pub from_json: Option<PathBuf>,
2768}
2769
2770#[derive(Args, Debug)]
2771#[command(
2772    arg_required_else_help = true,
2773    disable_help_subcommand = true,
2774    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2775    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 configure --write-config\n  xbp workers deploy run --app athena-studio\n  xbp workers deploy ci --app athena --all\n  xbp workers deploy sync-env-local\n  xbp workers deploy ci --version-upload\n  xbp workers worktree link-dev-vars"
2776)]
2777pub struct WorkersCmd {
2778    #[arg(
2779        long,
2780        help = "Workers project root (defaults to current dir, or apps/web inside the current XBP repo when present)"
2781    )]
2782    pub root: Option<PathBuf>,
2783    #[arg(
2784        long,
2785        help = "Worker app name from .xbp/xbp.yaml `workers:` (defaults to the app matching the current directory)"
2786    )]
2787    pub app: Option<String>,
2788    #[arg(long, help = "Cloudflare API token override")]
2789    pub token: Option<String>,
2790    #[arg(long, help = "Cloudflare account ID override")]
2791    pub account_id: Option<String>,
2792    #[command(subcommand)]
2793    pub command: WorkersSubCommand,
2794}
2795
2796#[derive(Subcommand, Debug)]
2797pub enum WorkersSubCommand {
2798    #[command(
2799        alias = "ls",
2800        about = "List Cloudflare Worker scripts with build and placement status",
2801        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"
2802    )]
2803    List(WorkersListCmd),
2804    #[command(
2805        about = "Show deployment status, stream runtime logs, or fetch Workers Builds CI logs",
2806        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"
2807    )]
2808    Logs(WorkersLogsCmd),
2809    #[command(about = "Manage secret bindings for a Worker script or Wrangler environment")]
2810    Secrets(WorkersSecretsCmd),
2811    #[command(about = "Fetch remote Worker settings via the Cloudflare API")]
2812    Settings(WorkersSettingsCmd),
2813    #[command(about = "Inspect or generate Wrangler config helpers")]
2814    Wrangler(WorkersWranglerCmd),
2815    #[command(about = "Run Wrangler D1 migration helpers")]
2816    D1(WorkersD1Cmd),
2817    #[command(about = "Run Worker deploy and predeploy helpers")]
2818    Deploy(WorkersDeployCmd),
2819    #[command(about = "Inspect shared worktree paths or link shared dev files")]
2820    Worktree(WorkersWorktreeCmd),
2821    #[command(about = "Show resolved Worker runtime metadata from local env and config files")]
2822    Env(WorkersEnvCmd),
2823}
2824
2825#[derive(Args, Debug, Default)]
2826pub struct WorkersListCmd {
2827    #[arg(
2828        long,
2829        help = "Show every Worker in the account instead of only Workers for this XBP project"
2830    )]
2831    pub all: bool,
2832    #[arg(long, help = "Output the worker inventory as JSON")]
2833    pub json: bool,
2834    #[arg(short = 'n', long = "limit", help = "Show at most N workers")]
2835    pub limit: Option<usize>,
2836    #[arg(
2837        long = "status",
2838        help = "Filter by build status (failed, success, running, unknown)"
2839    )]
2840    pub status: Option<String>,
2841    #[arg(long, help = "Shortcut for --status failed")]
2842    pub failed: bool,
2843    #[arg(
2844        long = "sort",
2845        default_value = "name",
2846        help = "Sort workers by name, modified, or status"
2847    )]
2848    pub sort: String,
2849    #[arg(long = "no-color", help = "Disable ANSI colors in table output")]
2850    pub no_color: bool,
2851}
2852
2853#[derive(Args, Debug)]
2854pub struct WorkersLogsCmd {
2855    #[command(flatten)]
2856    pub target: WorkersTargetArgs,
2857    #[arg(
2858        short = 'f',
2859        long = "follow",
2860        help = "Stream runtime logs until interrupted (wrangler tail)"
2861    )]
2862    pub follow: bool,
2863    #[arg(
2864        long = "build",
2865        help = "Show Workers Builds CI logs instead of runtime deployment output"
2866    )]
2867    pub build: bool,
2868    #[arg(
2869        long = "failed",
2870        help = "When using --build, prefer the latest failed build"
2871    )]
2872    pub failed: bool,
2873    #[arg(long, help = "Output logs as JSON")]
2874    pub json: bool,
2875    #[arg(
2876        short = 'n',
2877        long = "lines",
2878        help = "Show only the last N log lines (deployments/build output)"
2879    )]
2880    pub lines: Option<usize>,
2881    #[arg(
2882        short = 'o',
2883        long = "output",
2884        help = "Write log output to a file instead of stdout"
2885    )]
2886    pub output: Option<PathBuf>,
2887    #[arg(
2888        short = 'g',
2889        long = "grep",
2890        help = "Filter log lines matching this pattern (case-insensitive substring)"
2891    )]
2892    pub grep: Option<String>,
2893    #[arg(
2894        short = 'e',
2895        long = "errors-only",
2896        help = "Show only lines that look like errors or failures"
2897    )]
2898    pub errors_only: bool,
2899    #[arg(long = "no-color", help = "Disable ANSI colors in log output")]
2900    pub no_color: bool,
2901    #[arg(
2902        long = "list-builds",
2903        help = "List recent Workers Builds before fetching logs (with --build)"
2904    )]
2905    pub list_builds: bool,
2906    #[arg(
2907        long = "build-index",
2908        help = "Select build by index from --list-builds (0 = latest)"
2909    )]
2910    pub build_index: Option<usize>,
2911    #[arg(
2912        long,
2913        help = "Poll until the selected Workers Build finishes before fetching logs"
2914    )]
2915    pub wait: bool,
2916    #[arg(long = "no-wait", help = "Skip polling for in-progress Workers Builds")]
2917    pub no_wait: bool,
2918    #[arg(
2919        long = "wait-seconds",
2920        default_value_t = 120,
2921        help = "Maximum seconds to poll an in-progress Workers Build"
2922    )]
2923    pub wait_seconds: u64,
2924    #[arg(help = "Worker script name. When omitted, xbp prompts interactively.")]
2925    pub script_name: Option<String>,
2926}
2927
2928#[derive(Args, Debug, Clone, Default)]
2929pub struct WorkersTargetArgs {
2930    #[arg(
2931        long,
2932        help = "Worker base name (defaults to wrangler config name or xbp)"
2933    )]
2934    pub worker: Option<String>,
2935    #[arg(
2936        long = "environment",
2937        alias = "env",
2938        help = "Wrangler environment name. The remote script resolves to <worker>-<environment>."
2939    )]
2940    pub environment: Option<String>,
2941    #[arg(
2942        long,
2943        help = "Exact remote script name override. When set, this bypasses <worker>-<environment> resolution."
2944    )]
2945    pub script: Option<String>,
2946}
2947
2948#[derive(Args, Debug)]
2949pub struct WorkersSecretsCmd {
2950    #[command(flatten)]
2951    pub target: WorkersTargetArgs,
2952    #[command(subcommand)]
2953    pub command: WorkersSecretsSubCommand,
2954}
2955
2956#[derive(Subcommand, Debug)]
2957pub enum WorkersSecretsSubCommand {
2958    #[command(about = "List secret bindings on the resolved Worker script")]
2959    List(WorkersSecretsListCmd),
2960    #[command(about = "Fetch one secret binding metadata or value")]
2961    Get(WorkersSecretsGetCmd),
2962    #[command(about = "Create or update a secret binding")]
2963    Put(WorkersSecretsPutCmd),
2964    #[command(about = "Delete a secret binding")]
2965    Delete(WorkersSecretsDeleteCmd),
2966    #[command(about = "Create, update, or delete multiple secret bindings from a file")]
2967    Bulk(WorkersSecretsBulkCmd),
2968}
2969
2970#[derive(Args, Debug, Default)]
2971pub struct WorkersSecretsListCmd {}
2972
2973#[derive(Args, Debug)]
2974pub struct WorkersSecretsGetCmd {
2975    #[arg(long, help = "Secret binding name")]
2976    pub name: String,
2977}
2978
2979#[derive(Args, Debug)]
2980pub struct WorkersSecretsPutCmd {
2981    #[arg(long, help = "Secret binding name")]
2982    pub name: String,
2983    #[arg(long, help = "Secret value")]
2984    pub value: Option<String>,
2985    #[arg(long, help = "Read the secret value from stdin instead of --value")]
2986    pub from_stdin: bool,
2987}
2988
2989#[derive(Args, Debug)]
2990pub struct WorkersSecretsDeleteCmd {
2991    #[arg(long, help = "Secret binding name")]
2992    pub name: String,
2993}
2994
2995#[derive(Args, Debug)]
2996pub struct WorkersSecretsBulkCmd {
2997    #[arg(long, help = "Path to a .env or JSON file containing secret updates")]
2998    pub file: PathBuf,
2999    #[arg(
3000        long,
3001        default_value = "env",
3002        help = "Input format: env or json. For json, pass an object mapping names to string values or null for deletes."
3003    )]
3004    pub format: String,
3005}
3006
3007#[derive(Args, Debug)]
3008pub struct WorkersSettingsCmd {
3009    #[command(flatten)]
3010    pub target: WorkersTargetArgs,
3011}
3012
3013#[derive(Args, Debug)]
3014pub struct WorkersWranglerCmd {
3015    #[command(subcommand)]
3016    pub command: WorkersWranglerSubCommand,
3017}
3018
3019#[derive(Subcommand, Debug)]
3020pub enum WorkersWranglerSubCommand {
3021    #[command(about = "Generate a Wrangler deploy config JSON file from env vars")]
3022    GenerateConfig(WorkersWranglerGenerateConfigCmd),
3023    #[command(about = "Resolve which Wrangler config file local dev should use")]
3024    ConfigPath(WorkersWranglerConfigPathCmd),
3025    #[command(
3026        name = "run",
3027        alias = "exec",
3028        about = "Run any official Wrangler command from the selected Worker root",
3029        after_help = "Pass Wrangler arguments after `--`. Examples:\n  xbp workers wrangler run -- d1 list\n  xbp workers wrangler run -- d1 execute DB --remote --file schema.sql\n  xbp workers wrangler run -- r2 bucket create r2-bucket\n  xbp workers wrangler run -- r2 bucket cors set r2-bucket --file cors.json\n  xbp workers wrangler run -- queues create xbp-events\n  xbp workers wrangler run -- secrets-store store list --remote"
3030    )]
3031    Run(WorkersWranglerRunCmd),
3032}
3033
3034#[derive(Args, Debug)]
3035pub struct WorkersWranglerGenerateConfigCmd {
3036    #[arg(
3037        long,
3038        default_value = "wrangler.deploy.json",
3039        help = "Output filename, relative to the worker root unless absolute"
3040    )]
3041    pub output: PathBuf,
3042}
3043
3044#[derive(Args, Debug)]
3045pub struct WorkersWranglerConfigPathCmd {
3046    #[arg(
3047        long,
3048        default_value = "serve",
3049        help = "Calling command name, for example serve"
3050    )]
3051    pub command_name: String,
3052    #[arg(
3053        long,
3054        default_value = "development",
3055        help = "Execution mode, for example development or production"
3056    )]
3057    pub mode: String,
3058}
3059
3060#[derive(Args, Debug)]
3061pub struct WorkersWranglerRunCmd {
3062    #[arg(
3063        required = true,
3064        trailing_var_arg = true,
3065        allow_hyphen_values = true,
3066        help = "Arguments passed verbatim to the official Wrangler CLI"
3067    )]
3068    pub args: Vec<String>,
3069}
3070
3071#[derive(Args, Debug)]
3072pub struct WorkersD1Cmd {
3073    #[command(subcommand)]
3074    pub command: WorkersD1SubCommand,
3075}
3076
3077#[derive(Subcommand, Debug)]
3078pub enum WorkersD1SubCommand {
3079    #[command(about = "Apply pending Wrangler D1 migrations")]
3080    Migrations(WorkersD1MigrationsCmd),
3081}
3082
3083#[derive(Args, Debug)]
3084pub struct WorkersD1MigrationsCmd {
3085    #[command(subcommand)]
3086    pub command: WorkersD1MigrationsSubCommand,
3087}
3088
3089#[derive(Subcommand, Debug)]
3090pub enum WorkersD1MigrationsSubCommand {
3091    #[command(about = "Apply pending migrations to a local or remote D1 database")]
3092    Apply(WorkersD1MigrationsApplyCmd),
3093}
3094
3095#[derive(Args, Debug)]
3096pub struct WorkersD1MigrationsApplyCmd {
3097    #[arg(help = "D1 database binding or name, for example DB")]
3098    pub database: String,
3099    #[arg(
3100        long,
3101        conflicts_with = "remote",
3102        help = "Apply migrations to the local Wrangler D1 database"
3103    )]
3104    pub local: bool,
3105    #[arg(
3106        long,
3107        conflicts_with = "local",
3108        help = "Apply migrations to the remote D1 database"
3109    )]
3110    pub remote: bool,
3111    #[arg(long, help = "Wrangler config path override")]
3112    pub config: Option<PathBuf>,
3113    #[command(flatten)]
3114    pub target: WorkersTargetArgs,
3115    #[arg(
3116        long,
3117        help = "Persist local D1 state to this directory. When omitted in a git worktree, xbp uses the shared .wrangler/state path automatically."
3118    )]
3119    pub persist_to: Option<PathBuf>,
3120    #[arg(
3121        long,
3122        help = "Disable the automatic shared .wrangler/state path when running local migrations from a git worktree"
3123    )]
3124    pub no_shared_worktree_state: bool,
3125}
3126
3127#[derive(Args, Debug)]
3128pub struct WorkersDeployCmd {
3129    #[arg(
3130        long,
3131        help = "Run the deploy action for every worker app configured in .xbp/xbp.yaml"
3132    )]
3133    pub all: bool,
3134    #[command(subcommand)]
3135    pub command: WorkersDeploySubCommand,
3136}
3137
3138#[derive(Subcommand, Debug)]
3139pub enum WorkersDeploySubCommand {
3140    #[command(
3141        about = "Discover worker apps, sync Wrangler config, and optionally write .xbp/xbp.yaml"
3142    )]
3143    Configure(WorkersDeployConfigureCmd),
3144    #[command(about = "Run the configured deploy command for one or more worker apps")]
3145    Run(WorkersDeployRunCmd),
3146    #[command(about = "Run the predeploy sync flow unless Workers CI mode is active")]
3147    Predeploy(WorkersDeployPredeployCmd),
3148    #[command(about = "Read .env.local or process env, then emit .dev.vars and Wrangler configs")]
3149    SyncEnvLocal(WorkersDeploySyncEnvLocalCmd),
3150    #[command(
3151        about = "Run the built-in Cloudflare Worker CI deploy workflow (build + wrangler deploy)"
3152    )]
3153    Ci(WorkersDeployCiCmd),
3154    #[command(
3155        about = "Run the existing deploy-selection flow that chooses CI or local deploy behavior"
3156    )]
3157    Select(WorkersDeploySelectCmd),
3158}
3159
3160#[derive(Args, Debug, Default)]
3161pub struct WorkersDeployConfigureCmd {
3162    #[arg(
3163        long,
3164        help = "Scan the repo for Wrangler projects and merge them into .xbp/xbp.yaml `workers:`"
3165    )]
3166    pub write_config: bool,
3167    #[arg(
3168        long,
3169        help = "Preview worker discovery and config writes without changing files"
3170    )]
3171    pub dry_run: bool,
3172}
3173
3174#[derive(Args, Debug, Default)]
3175pub struct WorkersDeployRunCmd {}
3176
3177#[derive(Args, Debug)]
3178pub struct WorkersDeployPredeployCmd {
3179    #[arg(long, help = "Force Workers CI mode and skip local sync")]
3180    pub ci: bool,
3181}
3182
3183#[derive(Args, Debug)]
3184pub struct WorkersDeploySyncEnvLocalCmd {}
3185
3186#[derive(Args, Debug)]
3187pub struct WorkersDeployCiCmd {
3188    #[arg(long, help = "Upload a new version without immediately deploying it")]
3189    pub version_upload: bool,
3190}
3191
3192#[derive(Args, Debug)]
3193pub struct WorkersDeploySelectCmd {
3194    #[arg(long, help = "Force the WORKERS_CI=1 branch of the selector")]
3195    pub ci: bool,
3196    #[arg(
3197        long,
3198        help = "Branch name to expose as WORKERS_CI_BRANCH when --ci is set"
3199    )]
3200    pub branch: Option<String>,
3201}
3202
3203#[derive(Args, Debug)]
3204pub struct WorkersWorktreeCmd {
3205    #[command(subcommand)]
3206    pub command: WorkersWorktreeSubCommand,
3207}
3208
3209#[derive(Subcommand, Debug)]
3210pub enum WorkersWorktreeSubCommand {
3211    #[command(about = "Print repo-root, primary worktree, and shared Wrangler state paths")]
3212    Paths(WorkersWorktreePathsCmd),
3213    #[command(
3214        about = "Symlink apps/web/.dev.vars and wrangler.dev.jsonc from the primary worktree when in a linked worktree"
3215    )]
3216    LinkDevVars(WorkersWorktreeLinkDevVarsCmd),
3217}
3218
3219#[derive(Args, Debug, Default)]
3220pub struct WorkersWorktreePathsCmd {}
3221
3222#[derive(Args, Debug, Default)]
3223pub struct WorkersWorktreeLinkDevVarsCmd {}
3224
3225#[derive(Args, Debug)]
3226pub struct WorkersEnvCmd {
3227    #[command(flatten)]
3228    pub target: WorkersTargetArgs,
3229    #[arg(
3230        long,
3231        help = "Show resolved plain-text binding values instead of masking them"
3232    )]
3233    pub show_values: bool,
3234}
3235
3236#[derive(Args, Debug)]
3237#[command(
3238    arg_required_else_help = true,
3239    disable_help_subcommand = true,
3240    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
3241    after_help = "Examples:\n  xbp cloudflare doctor --app auth\n  xbp cloudflare init --app auth --worker-root apps/auth-worker --container-port 8787 --health-path /health --write\n  xbp cloudflare deploy --app auth --rollout immediate --dry-run\n  xbp cloudflare release --app auth --version 1.2.3 --rollout gradual\n  xbp cloudflare containers instances --app auth\n  xbp cloudflare jobs enqueue deploy --app auth --rollout immediate"
3242)]
3243pub struct CloudflareCmd {
3244    #[arg(
3245        long,
3246        global = true,
3247        help = "XBP project root (defaults to the nearest directory containing .xbp/xbp.yaml)"
3248    )]
3249    pub root: Option<PathBuf>,
3250    #[arg(
3251        long,
3252        global = true,
3253        help = "Worker app name from .xbp/xbp.yaml `workers:`"
3254    )]
3255    pub app: Option<String>,
3256    #[arg(long, global = true, help = "Cloudflare API token override")]
3257    pub token: Option<String>,
3258    #[arg(long, global = true, help = "Cloudflare account ID override")]
3259    pub account_id: Option<String>,
3260    #[command(subcommand)]
3261    pub command: CloudflareSubCommand,
3262}
3263
3264#[derive(Subcommand, Debug)]
3265pub enum CloudflareSubCommand {
3266    #[command(
3267        about = "Validate Wrangler, container metadata, bindings, secrets, and deploy readiness"
3268    )]
3269    Doctor(CloudflareDoctorCmd),
3270    #[command(about = "Discover or write the project-level Worker container contract")]
3271    Init(CloudflareInitCmd),
3272    #[command(about = "Run the canonical Worker + Container deploy workflow")]
3273    Deploy(CloudflareDeployCmd),
3274    #[command(about = "Sync a version-bearing deploy var, deploy, and verify live health")]
3275    Release(CloudflareReleaseCmd),
3276    #[command(about = "Wrap official Wrangler container commands from the selected app root")]
3277    Containers(CloudflareContainersCmd),
3278    #[command(
3279        about = "Manage Cloudflare Tunnels through official Wrangler commands",
3280        after_help = "Tunnel commands are experimental in Wrangler. Examples:\n  xbp cloudflare tunnel create my-app\n  xbp cloudflare tunnel list\n  xbp cloudflare tunnel info my-app\n  xbp cloudflare tunnel run my-app\n  xbp cloudflare tunnel run --token <TOKEN>\n  xbp cloudflare tunnel quick-start http://localhost:8080\n  xbp cloudflare tunnel delete my-app --force"
3281    )]
3282    Tunnel(CloudflareTunnelCmd),
3283    #[command(
3284        about = "Manage Cloudflare Workflows through official Wrangler commands",
3285        after_help = "Pass the documented Wrangler workflows command after `workflows`. Examples:\n  xbp cloudflare workflows list\n  xbp cloudflare workflows describe my-workflow\n  xbp cloudflare workflows trigger my-workflow '{\"key\":\"value\"}'\n  xbp cloudflare workflows instances list my-workflow --local\n  xbp cloudflare workflows instances describe my-workflow latest\n  xbp cloudflare workflows instances send-event my-workflow latest --type my-event --payload '{\"key\":\"value\"}'\n  xbp cloudflare workflows instances pause my-workflow latest\n  xbp cloudflare workflows instances resume my-workflow latest\n  xbp cloudflare workflows instances restart my-workflow latest\n  xbp cloudflare workflows instances terminate my-workflow latest"
3286    )]
3287    Workflows(CloudflareWorkflowsCmd),
3288    #[command(
3289        about = "Run Wrangler local-development commands with environment-specific vars",
3290        after_help = "Pass the documented Wrangler local-development command after `env`. Examples:\n  xbp cloudflare env dev\n  xbp cloudflare env dev --env staging\n  xbp cloudflare env dev --env staging --port 8787"
3291    )]
3292    Env(CloudflareEnvCmd),
3293    #[command(about = "Manage local file-backed Cloudflare workflow jobs")]
3294    Jobs(CloudflareJobsCmd),
3295}
3296
3297#[derive(Args, Debug, Default)]
3298pub struct CloudflareDoctorCmd {
3299    #[arg(
3300        long,
3301        help = "Skip live Wrangler calls and only validate local files/config"
3302    )]
3303    pub offline: bool,
3304}
3305
3306#[derive(Args, Debug)]
3307pub struct CloudflareInitCmd {
3308    #[arg(long, help = "Worker app root to store in .xbp/xbp.yaml")]
3309    pub worker_root: PathBuf,
3310    #[arg(long, help = "Container port exposed by the runtime")]
3311    pub container_port: u16,
3312    #[arg(
3313        long,
3314        default_value = "/health",
3315        help = "Container health endpoint path"
3316    )]
3317    pub health_path: String,
3318    #[arg(long, help = "Container Durable Object class name")]
3319    pub class_name: Option<String>,
3320    #[arg(long, help = "Worker binding name for the container Durable Object")]
3321    pub binding: Option<String>,
3322    #[arg(
3323        long,
3324        default_value = "Dockerfile",
3325        help = "Dockerfile path relative to the Worker root"
3326    )]
3327    pub dockerfile: PathBuf,
3328    #[arg(long, help = "Cloudflare container application id")]
3329    pub application_id: Option<String>,
3330    #[arg(
3331        long = "required-secret",
3332        help = "Required secret/env binding name",
3333        value_name = "NAME"
3334    )]
3335    pub required_secrets: Vec<String>,
3336    #[arg(long, help = "Wrangler vars key that should carry release versions")]
3337    pub version_var: Option<String>,
3338    #[arg(
3339        long,
3340        help = "Persist the discovered/scaffolded contract to .xbp/xbp.yaml"
3341    )]
3342    pub write: bool,
3343}
3344
3345#[derive(Args, Debug)]
3346pub struct CloudflareDeployCmd {
3347    #[arg(long, value_enum, default_value_t = CloudflareRollout::Immediate)]
3348    pub rollout: CloudflareRollout,
3349    #[arg(long, help = "Run validation and Wrangler deploy --dry-run only")]
3350    pub dry_run: bool,
3351    #[arg(
3352        long,
3353        help = "Run post-deploy verification without invoking Wrangler deploy"
3354    )]
3355    pub skip_deploy: bool,
3356    #[arg(
3357        long,
3358        help = "Allow the container image tag to remain unchanged after deploy verification"
3359    )]
3360    pub allow_unchanged_container_image: bool,
3361    #[arg(
3362        long,
3363        help = "Delete old container image tags after successful verification"
3364    )]
3365    pub prune_old_images: bool,
3366    #[arg(long, help = "Image tag count to retain when pruning old tags")]
3367    pub keep_image_tag_count: Option<usize>,
3368}
3369
3370#[derive(Args, Debug)]
3371pub struct CloudflareReleaseCmd {
3372    #[arg(long, help = "Release version to write to the configured version var")]
3373    pub version: String,
3374    #[arg(
3375        long,
3376        help = "Release domain that must validate before this container-backed Worker release"
3377    )]
3378    pub domain: Option<String>,
3379    #[arg(long, value_enum, default_value_t = CloudflareRollout::Immediate)]
3380    pub rollout: CloudflareRollout,
3381    #[arg(
3382        long,
3383        help = "Run release verification without invoking Wrangler deploy"
3384    )]
3385    pub skip_deploy: bool,
3386    #[arg(
3387        long,
3388        help = "Allow the container image tag to remain unchanged after release verification"
3389    )]
3390    pub allow_unchanged_container_image: bool,
3391    #[arg(
3392        long,
3393        help = "Delete old container image tags after successful verification"
3394    )]
3395    pub prune_old_images: bool,
3396    #[arg(long, help = "Image tag count to retain when pruning old tags")]
3397    pub keep_image_tag_count: Option<usize>,
3398}
3399
3400#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
3401#[serde(rename_all = "kebab-case")]
3402pub enum CloudflareRollout {
3403    Immediate,
3404    Gradual,
3405    None,
3406}
3407
3408impl CloudflareRollout {
3409    pub fn as_str(self) -> &'static str {
3410        match self {
3411            Self::Immediate => "immediate",
3412            Self::Gradual => "gradual",
3413            Self::None => "none",
3414        }
3415    }
3416}
3417
3418#[derive(Args, Debug)]
3419pub struct CloudflareContainersCmd {
3420    #[command(subcommand)]
3421    pub command: CloudflareContainersSubCommand,
3422}
3423
3424#[derive(Args, Debug)]
3425pub struct CloudflareTunnelCmd {
3426    #[command(subcommand)]
3427    pub command: CloudflareTunnelSubCommand,
3428}
3429
3430#[derive(Subcommand, Debug)]
3431pub enum CloudflareTunnelSubCommand {
3432    #[command(about = "Create a remotely managed Cloudflare Tunnel")]
3433    Create(CloudflareTunnelCreateCmd),
3434    #[command(about = "Delete a Cloudflare Tunnel")]
3435    Delete(CloudflareTunnelDeleteCmd),
3436    #[command(about = "Show details for a Cloudflare Tunnel")]
3437    Info(CloudflareTunnelInfoCmd),
3438    #[command(about = "List Cloudflare Tunnels")]
3439    List(CloudflareTunnelListCmd),
3440    #[command(about = "Run a named or token-authenticated Cloudflare Tunnel")]
3441    Run(CloudflareTunnelRunCmd),
3442    #[command(about = "Start a temporary anonymous Quick Tunnel")]
3443    QuickStart(CloudflareTunnelQuickStartCmd),
3444}
3445
3446#[derive(Args, Debug)]
3447pub struct CloudflareTunnelCreateCmd {
3448    #[arg(help = "Unique tunnel name within the Cloudflare account")]
3449    pub name: String,
3450}
3451
3452#[derive(Args, Debug)]
3453pub struct CloudflareTunnelDeleteCmd {
3454    #[arg(help = "Tunnel name or UUID")]
3455    pub tunnel: String,
3456    #[arg(long, help = "Skip the confirmation prompt")]
3457    pub force: bool,
3458}
3459
3460#[derive(Args, Debug)]
3461pub struct CloudflareTunnelInfoCmd {
3462    #[arg(help = "Tunnel name or UUID")]
3463    pub tunnel: String,
3464}
3465
3466#[derive(Args, Debug, Default)]
3467pub struct CloudflareTunnelListCmd {}
3468
3469#[derive(Args, Debug)]
3470pub struct CloudflareTunnelRunCmd {
3471    #[arg(help = "Tunnel name or UUID; omit when using --token")]
3472    pub tunnel: Option<String>,
3473    #[arg(long, help = "Tunnel token; avoids API lookup and authentication")]
3474    pub token: Option<String>,
3475    #[arg(
3476        long,
3477        help = "cloudflared log level: debug, info, warn, error, or fatal"
3478    )]
3479    pub log_level: Option<String>,
3480}
3481
3482#[derive(Args, Debug)]
3483pub struct CloudflareTunnelQuickStartCmd {
3484    #[arg(help = "Local URL to expose, for example http://localhost:8080")]
3485    pub url: String,
3486}
3487
3488#[derive(Args, Debug)]
3489pub struct CloudflareWorkflowsCmd {
3490    #[command(subcommand)]
3491    pub command: CloudflareWorkflowsSubCommand,
3492}
3493
3494#[derive(Subcommand, Debug)]
3495pub enum CloudflareWorkflowsSubCommand {
3496    #[command(external_subcommand)]
3497    External(Vec<String>),
3498}
3499
3500#[derive(Args, Debug)]
3501pub struct CloudflareEnvCmd {
3502    #[command(subcommand)]
3503    pub command: CloudflareEnvSubCommand,
3504}
3505
3506#[derive(Subcommand, Debug)]
3507pub enum CloudflareEnvSubCommand {
3508    #[command(external_subcommand)]
3509    External(Vec<String>),
3510}
3511
3512#[derive(Subcommand, Debug)]
3513pub enum CloudflareContainersSubCommand {
3514    #[command(about = "Run wrangler containers list")]
3515    List(CloudflareContainersListCmd),
3516    #[command(
3517        about = "Run wrangler containers info for the configured or supplied application id"
3518    )]
3519    Info(CloudflareContainersInfoCmd),
3520    #[command(
3521        about = "Run wrangler containers instances for the configured or supplied application id"
3522    )]
3523    Instances(CloudflareContainersInstancesCmd),
3524    #[command(about = "Run wrangler containers push")]
3525    Push(CloudflareContainersPushCmd),
3526    #[command(about = "Open wrangler containers ssh for an instance")]
3527    Ssh(CloudflareContainersSshCmd),
3528}
3529
3530#[derive(Args, Debug, Default)]
3531pub struct CloudflareContainersListCmd {
3532    #[arg(long, help = "Ask Wrangler for JSON output when supported")]
3533    pub json: bool,
3534}
3535
3536#[derive(Args, Debug, Default)]
3537pub struct CloudflareContainersInfoCmd {
3538    #[arg(long, help = "Container application id override")]
3539    pub application_id: Option<String>,
3540    #[arg(long, help = "Ask Wrangler for JSON output when supported")]
3541    pub json: bool,
3542}
3543
3544#[derive(Args, Debug, Default)]
3545pub struct CloudflareContainersInstancesCmd {
3546    #[arg(long, help = "Container application id override")]
3547    pub application_id: Option<String>,
3548    #[arg(long, help = "Ask Wrangler for JSON output when supported")]
3549    pub json: bool,
3550}
3551
3552#[derive(Args, Debug)]
3553pub struct CloudflareContainersPushCmd {
3554    #[arg(help = "Image tag or image name to push through Wrangler")]
3555    pub image: String,
3556}
3557
3558#[derive(Args, Debug)]
3559pub struct CloudflareContainersSshCmd {
3560    #[arg(help = "Container instance id")]
3561    pub instance_id: String,
3562    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
3563    pub args: Vec<String>,
3564}
3565
3566#[derive(Args, Debug)]
3567pub struct CloudflareJobsCmd {
3568    #[command(subcommand)]
3569    pub command: CloudflareJobsSubCommand,
3570}
3571
3572#[derive(Subcommand, Debug)]
3573pub enum CloudflareJobsSubCommand {
3574    #[command(about = "Queue a local Cloudflare workflow job")]
3575    Enqueue(CloudflareJobsEnqueueCmd),
3576    #[command(about = "Run queued local Cloudflare workflow jobs")]
3577    Run(CloudflareJobsRunCmd),
3578    #[command(about = "Show one local Cloudflare workflow job")]
3579    Status(CloudflareJobsStatusCmd),
3580    #[command(about = "List local Cloudflare workflow jobs")]
3581    List(CloudflareJobsListCmd),
3582    #[command(about = "Print stored logs for one local Cloudflare workflow job")]
3583    Logs(CloudflareJobsLogsCmd),
3584}
3585
3586#[derive(Args, Debug)]
3587pub struct CloudflareJobsEnqueueCmd {
3588    #[arg(value_enum)]
3589    pub workflow: CloudflareJobWorkflow,
3590    #[arg(long, value_enum, default_value_t = CloudflareRollout::Immediate)]
3591    pub rollout: CloudflareRollout,
3592    #[arg(long, help = "Required for release jobs")]
3593    pub version: Option<String>,
3594    #[arg(long, help = "Queue deploy jobs in dry-run mode")]
3595    pub dry_run: bool,
3596    #[arg(long, help = "Queue verification without invoking Wrangler deploy")]
3597    pub skip_deploy: bool,
3598    #[arg(long, help = "Allow an unchanged container image during verification")]
3599    pub allow_unchanged_container_image: bool,
3600    #[arg(long, help = "Prune old container image tags after verification")]
3601    pub prune_old_images: bool,
3602    #[arg(long, help = "Image tag count to retain when pruning old tags")]
3603    pub keep_image_tag_count: Option<usize>,
3604}
3605
3606#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
3607#[serde(rename_all = "kebab-case")]
3608pub enum CloudflareJobWorkflow {
3609    Doctor,
3610    Deploy,
3611    Release,
3612}
3613
3614impl CloudflareJobWorkflow {
3615    pub fn as_str(self) -> &'static str {
3616        match self {
3617            Self::Doctor => "doctor",
3618            Self::Deploy => "deploy",
3619            Self::Release => "release",
3620        }
3621    }
3622}
3623
3624#[derive(Args, Debug, Default)]
3625pub struct CloudflareJobsRunCmd {
3626    #[arg(long, help = "Run only the next queued job")]
3627    pub once: bool,
3628}
3629
3630#[derive(Args, Debug)]
3631pub struct CloudflareJobsStatusCmd {
3632    pub job_id: String,
3633}
3634
3635#[derive(Args, Debug, Default)]
3636pub struct CloudflareJobsListCmd {}
3637
3638#[derive(Args, Debug)]
3639pub struct CloudflareJobsLogsCmd {
3640    pub job_id: String,
3641}
3642
3643#[cfg(feature = "secrets")]
3644#[derive(Args, Debug)]
3645pub struct SecretsCmd {
3646    #[arg(long, value_enum, default_value_t = SecretsProviderKind::Github, help = "Secrets provider to use")]
3647    pub provider: SecretsProviderKind,
3648    #[arg(long, help = "GitHub repository override (owner/repo)")]
3649    pub repo: Option<String>,
3650    #[arg(
3651        long,
3652        help = "Provider token override (GitHub token or Cloudflare API token)"
3653    )]
3654    pub token: Option<String>,
3655    #[arg(long, help = "Cloudflare account ID override")]
3656    pub account_id: Option<String>,
3657    #[arg(
3658        long = "environment",
3659        alias = "env",
3660        default_value = "xbp-dev",
3661        help = "Environment to sync (default: xbp-dev). Nested services are scoped automatically, e.g. xbp-dev-web."
3662    )]
3663    pub environment: String,
3664    #[arg(
3665        long,
3666        help = "Service name from .xbp/xbp.yaml. If omitted, XBP resolves it from the current directory or prompts when ambiguous."
3667    )]
3668    pub service: Option<String>,
3669    #[command(subcommand)]
3670    pub command: Option<SecretsSubCommand>,
3671}
3672
3673#[cfg(feature = "secrets")]
3674#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
3675pub enum SecretsProviderKind {
3676    Github,
3677    Cloudflare,
3678    Railway,
3679    Vercel,
3680}
3681
3682#[cfg(feature = "secrets")]
3683#[derive(Subcommand, Debug)]
3684pub enum SecretsSubCommand {
3685    /// List available secrets providers
3686    #[command(alias = "ls", alias = "list-providers")]
3687    Providers,
3688    /// List local env vars from the preferred env file
3689    List(ListCmd),
3690    /// Push local env vars to the secrets provider (GitHub)
3691    Push(PushCmd),
3692    /// Pull secrets from the provider into .env.local
3693    Pull(PullCmd),
3694    /// Generate .env.default from source code inspection
3695    GenerateDefault(GenerateDefaultCmd),
3696    /// Generate .env.example with categories and defaults
3697    GenerateExample(GenerateExampleCmd),
3698    /// Compare local env with remote (GitHub) variables
3699    Diff,
3700    /// Verify that all required env vars are available locally
3701    Verify,
3702    /// Check connectivity, token scope, and repo access for secrets
3703    #[command(name = "diag", alias = "doctor")]
3704    Diag,
3705    /// Manage Cloudflare secrets stores
3706    Stores(SecretsStoresCmd),
3707    /// Manage Cloudflare secrets in a store
3708    Secrets(CloudflareSecretsCmd),
3709    /// Inspect Cloudflare quota usage
3710    Quota(SecretsQuotaCmd),
3711    /// Show secrets command usage
3712    #[command(name = "usage")]
3713    Usage,
3714}
3715
3716#[cfg(feature = "secrets")]
3717#[derive(Args, Debug)]
3718pub struct ListCmd {
3719    #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
3720    pub file: Option<String>,
3721    #[arg(long, help = "Output format: plain (default) or json")]
3722    pub format: Option<String>,
3723}
3724
3725#[cfg(feature = "secrets")]
3726#[derive(Args, Debug)]
3727pub struct PushCmd {
3728    #[arg(long, help = "Path to env file (default: .env.local/.env)")]
3729    pub file: Option<String>,
3730    #[arg(
3731        long = "dev-vars-file",
3732        help = "Path to Cloudflare .dev.vars file. Defaults to .dev.vars when it exists."
3733    )]
3734    pub dev_vars_file: Option<String>,
3735    #[arg(
3736        long,
3737        help = "Skip the Cloudflare .dev.vars GitHub environment sync lane"
3738    )]
3739    pub skip_dev_vars: bool,
3740    #[arg(
3741        long,
3742        help = "Force overwrite existing GitHub Actions environment variables"
3743    )]
3744    pub force: bool,
3745    #[arg(long, help = "Show what would be pushed without making changes")]
3746    pub dry_run: bool,
3747}
3748
3749#[cfg(feature = "secrets")]
3750#[derive(Args, Debug)]
3751pub struct PullCmd {
3752    #[arg(long, help = "Output file path (default: .env.local)")]
3753    pub output: Option<String>,
3754    #[arg(
3755        long = "dev-vars-output",
3756        help = "Output path for Cloudflare .dev.vars. Defaults to .dev.vars when it already exists."
3757    )]
3758    pub dev_vars_output: Option<String>,
3759    #[arg(
3760        long,
3761        help = "Pull the Cloudflare .dev.vars environment even when .dev.vars does not exist yet"
3762    )]
3763    pub include_dev_vars: bool,
3764    #[arg(
3765        long,
3766        help = "Skip the Cloudflare .dev.vars GitHub environment sync lane"
3767    )]
3768    pub skip_dev_vars: bool,
3769}
3770
3771#[cfg(feature = "secrets")]
3772#[derive(Args, Debug)]
3773pub struct GenerateDefaultCmd {
3774    #[arg(long, help = "Output file path (default: .env.default)")]
3775    pub output: Option<String>,
3776}
3777
3778#[cfg(feature = "secrets")]
3779#[derive(Args, Debug)]
3780pub struct GenerateExampleCmd {
3781    #[arg(long, help = "Output file path (default: .env.example)")]
3782    pub output: Option<String>,
3783    #[arg(long, help = "Remove keys from .env.local not in .env.example")]
3784    pub clean: bool,
3785    #[arg(long, help = "Only include vars matching prefix (repeatable)")]
3786    pub include_prefix: Vec<String>,
3787    #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
3788    pub exclude_prefix: Vec<String>,
3789}
3790
3791#[cfg(feature = "secrets")]
3792#[derive(Args, Debug)]
3793pub struct SecretsStoresCmd {
3794    #[command(subcommand)]
3795    pub command: SecretsStoresSubCommand,
3796}
3797
3798#[cfg(feature = "secrets")]
3799#[derive(Subcommand, Debug)]
3800pub enum SecretsStoresSubCommand {
3801    List(CloudflareSecretsStoreListCmd),
3802    Get(CloudflareSecretsStoreGetCmd),
3803    Create(CloudflareSecretsStoreCreateCmd),
3804    Delete(CloudflareSecretsStoreDeleteCmd),
3805}
3806
3807#[cfg(feature = "secrets")]
3808#[derive(Args, Debug)]
3809pub struct CloudflareSecretsStoreListCmd {}
3810
3811#[cfg(feature = "secrets")]
3812#[derive(Args, Debug)]
3813pub struct CloudflareSecretsStoreGetCmd {
3814    #[arg(long)]
3815    pub store_id: String,
3816}
3817
3818#[cfg(feature = "secrets")]
3819#[derive(Args, Debug)]
3820pub struct CloudflareSecretsStoreCreateCmd {
3821    #[arg(long)]
3822    pub name: String,
3823}
3824
3825#[cfg(feature = "secrets")]
3826#[derive(Args, Debug)]
3827pub struct CloudflareSecretsStoreDeleteCmd {
3828    #[arg(long)]
3829    pub store_id: String,
3830}
3831
3832#[cfg(feature = "secrets")]
3833#[derive(Args, Debug)]
3834pub struct CloudflareSecretsCmd {
3835    #[command(subcommand)]
3836    pub command: CloudflareSecretsSubCommand,
3837}
3838
3839#[cfg(feature = "secrets")]
3840#[derive(Subcommand, Debug)]
3841pub enum CloudflareSecretsSubCommand {
3842    List(CloudflareSecretsListCmd),
3843    Get(CloudflareSecretsGetCmd),
3844    Create(CloudflareSecretsCreateCmd),
3845    Edit(CloudflareSecretsEditCmd),
3846    Delete(CloudflareSecretsDeleteCmd),
3847    #[command(name = "delete-bulk")]
3848    DeleteBulk(CloudflareSecretsBulkDeleteCmd),
3849    Duplicate(CloudflareSecretsDuplicateCmd),
3850}
3851
3852#[cfg(feature = "secrets")]
3853#[derive(Args, Debug)]
3854pub struct CloudflareSecretsListCmd {
3855    #[arg(long)]
3856    pub store_id: String,
3857}
3858
3859#[cfg(feature = "secrets")]
3860#[derive(Args, Debug)]
3861pub struct CloudflareSecretsGetCmd {
3862    #[arg(long)]
3863    pub store_id: String,
3864    #[arg(long)]
3865    pub secret_id: String,
3866}
3867
3868#[cfg(feature = "secrets")]
3869#[derive(Args, Debug)]
3870pub struct CloudflareSecretsCreateCmd {
3871    #[arg(long)]
3872    pub store_id: String,
3873    #[arg(long)]
3874    pub name: String,
3875    #[arg(long)]
3876    pub value: String,
3877    #[arg(long, value_delimiter = ',')]
3878    pub scopes: Vec<String>,
3879    #[arg(long)]
3880    pub comment: Option<String>,
3881}
3882
3883#[cfg(feature = "secrets")]
3884#[derive(Args, Debug)]
3885pub struct CloudflareSecretsEditCmd {
3886    #[arg(long)]
3887    pub store_id: String,
3888    #[arg(long)]
3889    pub secret_id: String,
3890    #[arg(long)]
3891    pub name: Option<String>,
3892    #[arg(long)]
3893    pub value: Option<String>,
3894    #[arg(long, value_delimiter = ',')]
3895    pub scopes: Vec<String>,
3896    #[arg(long)]
3897    pub comment: Option<String>,
3898}
3899
3900#[cfg(feature = "secrets")]
3901#[derive(Args, Debug)]
3902pub struct CloudflareSecretsDeleteCmd {
3903    #[arg(long)]
3904    pub store_id: String,
3905    #[arg(long)]
3906    pub secret_id: String,
3907}
3908
3909#[cfg(feature = "secrets")]
3910#[derive(Args, Debug)]
3911pub struct CloudflareSecretsBulkDeleteCmd {
3912    #[arg(long)]
3913    pub store_id: String,
3914    #[arg(long = "secret-id", required = true)]
3915    pub secret_ids: Vec<String>,
3916}
3917
3918#[cfg(feature = "secrets")]
3919#[derive(Args, Debug)]
3920pub struct CloudflareSecretsDuplicateCmd {
3921    #[arg(long)]
3922    pub store_id: String,
3923    #[arg(long)]
3924    pub secret_id: String,
3925    #[arg(long)]
3926    pub name: String,
3927    #[arg(long, value_delimiter = ',')]
3928    pub scopes: Vec<String>,
3929    #[arg(long)]
3930    pub comment: Option<String>,
3931}
3932
3933#[cfg(feature = "secrets")]
3934#[derive(Args, Debug)]
3935pub struct SecretsQuotaCmd {
3936    #[command(subcommand)]
3937    pub command: SecretsQuotaSubCommand,
3938}
3939
3940#[cfg(feature = "secrets")]
3941#[derive(Subcommand, Debug)]
3942pub enum SecretsQuotaSubCommand {
3943    Get(SecretsQuotaGetCmd),
3944}
3945
3946#[cfg(feature = "secrets")]
3947#[derive(Args, Debug)]
3948pub struct SecretsQuotaGetCmd {}
3949
3950const DNS_HELP_TEMPLATE: &str = crate::cli::help_render::XBP_HELP_TEMPLATE;
3951
3952const DNS_COMMAND_AFTER_HELP: &str = "\
3953Examples:
3954  xbp dns providers
3955  xbp dns zones list --provider cloudflare --account-id acc_123
3956  xbp dns records list --provider cloudflare --zone-id zone_123
3957  xbp dns records create --provider cloudflare --zone-id zone_123 --type A --name api --content 127.0.0.1
3958  xbp dns dnssec get --provider cloudflare --zone-id zone_123
3959  xbp dns settings edit --provider cloudflare --zone-id zone_123 --flatten-all-cnames true
3960
3961Notes:
3962  Start with `xbp dns providers` to see what is implemented today.
3963  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`).";
3964
3965const DNS_ZONES_AFTER_HELP: &str = "\
3966Examples:
3967  xbp dns zones list --provider cloudflare --account-id acc_123
3968  xbp dns zones get --provider cloudflare --zone-id zone_123
3969  xbp dns zones create --provider cloudflare --name example.com --account-id acc_123 --jump-start
3970  xbp dns zones edit --provider cloudflare --zone-id zone_123 --paused true
3971  xbp dns zones delete --provider cloudflare --zone-id zone_123";
3972
3973const DNS_RECORDS_AFTER_HELP: &str = "\
3974Examples:
3975  xbp dns records list --provider cloudflare --zone-id zone_123
3976  xbp dns records get --provider cloudflare --zone-id zone_123 --record-id rec_123
3977  xbp dns records create --provider cloudflare --zone-id zone_123 --type A --name api --content 127.0.0.1
3978  xbp dns records edit --provider cloudflare --zone-id zone_123 --record-id rec_123 --proxied true
3979  xbp dns records import --provider cloudflare --zone-id zone_123 --file zone.txt
3980  xbp dns records export --provider cloudflare --zone-id zone_123 --output zone.txt";
3981
3982const DNS_DNSSEC_AFTER_HELP: &str = "\
3983Examples:
3984  xbp dns dnssec get --provider cloudflare --zone-id zone_123
3985  xbp dns dnssec edit --provider cloudflare --zone-id zone_123 --status active";
3986
3987const DNS_SETTINGS_AFTER_HELP: &str = "\
3988Examples:
3989  xbp dns settings get --provider cloudflare --zone-id zone_123
3990  xbp dns settings edit --provider cloudflare --zone-id zone_123 --flatten-all-cnames true
3991  xbp dns settings edit --provider cloudflare --zone-id zone_123 --nameservers-type custom --nameservers-ns-set 2";
3992
3993const DNS_PROVIDERS_AFTER_HELP: &str = "\
3994Examples:
3995  xbp dns providers
3996
3997What this shows:
3998  Implemented providers are wired into `xbp dns` today.
3999  Planned providers are tracked in the CLI surface but not callable yet.";
4000
4001#[derive(Args, Debug)]
4002#[command(
4003    about = "Manage DNS providers, zones, records, DNSSEC, and provider-level settings",
4004    arg_required_else_help = true,
4005    disable_help_subcommand = true,
4006    help_template = DNS_HELP_TEMPLATE,
4007    after_help = DNS_COMMAND_AFTER_HELP
4008)]
4009pub struct DnsCmd {
4010    #[command(subcommand)]
4011    pub command: DnsSubCommand,
4012}
4013
4014#[derive(Subcommand, Debug)]
4015pub enum DnsSubCommand {
4016    #[command(
4017        alias = "ls",
4018        alias = "list",
4019        about = "List supported DNS providers and current implementation status",
4020        after_help = DNS_PROVIDERS_AFTER_HELP
4021    )]
4022    Providers,
4023    #[command(about = "Inspect and manage DNS zones")]
4024    Zones(DnsZonesCmd),
4025    #[command(about = "List, create, edit, import, export, and batch DNS records")]
4026    Records(DnsRecordsCmd),
4027    #[command(about = "Inspect or edit DNSSEC status for a zone")]
4028    Dnssec(DnssecCmd),
4029    #[command(about = "Inspect or edit provider DNS settings for a zone")]
4030    Settings(DnsSettingsCmd),
4031}
4032
4033#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
4034pub enum DnsProviderKind {
4035    Cloudflare,
4036    Hetzner,
4037    Vercel,
4038    Custom,
4039}
4040
4041#[derive(Args, Debug)]
4042#[command(
4043    about = "Inspect and manage DNS zones",
4044    arg_required_else_help = true,
4045    help_template = DNS_HELP_TEMPLATE,
4046    after_help = DNS_ZONES_AFTER_HELP
4047)]
4048pub struct DnsZonesCmd {
4049    #[command(subcommand)]
4050    pub command: DnsZonesSubCommand,
4051}
4052
4053#[derive(Subcommand, Debug)]
4054pub enum DnsZonesSubCommand {
4055    #[command(about = "List zones for a provider account")]
4056    List(DnsZoneListCmd),
4057    #[command(about = "Fetch one zone by id")]
4058    Get(DnsZoneGetCmd),
4059    #[command(about = "Create a new zone")]
4060    Create(DnsZoneCreateCmd),
4061    #[command(about = "Edit zone-level properties")]
4062    Edit(DnsZoneEditCmd),
4063    #[command(about = "Delete a zone")]
4064    Delete(DnsZoneDeleteCmd),
4065}
4066
4067#[derive(Args, Debug)]
4068pub struct DnsZoneListCmd {
4069    #[arg(long, value_enum, default_value = "cloudflare")]
4070    pub provider: DnsProviderKind,
4071    #[arg(long)]
4072    pub account_id: Option<String>,
4073    #[arg(long)]
4074    pub account_name: Option<String>,
4075    #[arg(long = "account-name-op")]
4076    pub account_name_op: Option<String>,
4077    #[arg(long)]
4078    pub name: Option<String>,
4079    #[arg(long = "name-op")]
4080    pub name_op: Option<String>,
4081    #[arg(long)]
4082    pub status: Option<String>,
4083    #[arg(long = "type", value_delimiter = ',')]
4084    pub zone_types: Vec<String>,
4085    #[arg(long)]
4086    pub r#match: Option<String>,
4087    #[arg(long)]
4088    pub order: Option<String>,
4089    #[arg(long)]
4090    pub direction: Option<String>,
4091    #[arg(long)]
4092    pub page: Option<u64>,
4093    #[arg(long = "per-page")]
4094    pub per_page: Option<u64>,
4095    #[arg(long)]
4096    pub token: Option<String>,
4097}
4098
4099#[derive(Args, Debug)]
4100pub struct DnsZoneGetCmd {
4101    #[arg(long, value_enum, default_value = "cloudflare")]
4102    pub provider: DnsProviderKind,
4103    #[arg(long)]
4104    pub zone_id: String,
4105    #[arg(long)]
4106    pub token: Option<String>,
4107}
4108
4109#[derive(Args, Debug)]
4110pub struct DnsZoneCreateCmd {
4111    #[arg(long, value_enum, default_value = "cloudflare")]
4112    pub provider: DnsProviderKind,
4113    #[arg(long)]
4114    pub name: String,
4115    #[arg(long)]
4116    pub account_id: Option<String>,
4117    #[arg(long)]
4118    pub jump_start: bool,
4119    #[arg(long = "type")]
4120    pub zone_type: Option<String>,
4121    #[arg(long)]
4122    pub token: Option<String>,
4123}
4124
4125#[derive(Args, Debug)]
4126pub struct DnsZoneEditCmd {
4127    #[arg(long, value_enum, default_value = "cloudflare")]
4128    pub provider: DnsProviderKind,
4129    #[arg(long)]
4130    pub zone_id: String,
4131    #[arg(long)]
4132    pub paused: Option<bool>,
4133    #[arg(long = "type")]
4134    pub zone_type: Option<String>,
4135    #[arg(long = "vanity-name-server")]
4136    pub vanity_name_servers: Vec<String>,
4137    #[arg(long)]
4138    pub token: Option<String>,
4139}
4140
4141#[derive(Args, Debug)]
4142pub struct DnsZoneDeleteCmd {
4143    #[arg(long, value_enum, default_value = "cloudflare")]
4144    pub provider: DnsProviderKind,
4145    #[arg(long)]
4146    pub zone_id: String,
4147    #[arg(long)]
4148    pub token: Option<String>,
4149}
4150
4151#[derive(Args, Debug)]
4152#[command(
4153    about = "List, create, edit, import, export, and batch DNS records",
4154    arg_required_else_help = true,
4155    help_template = DNS_HELP_TEMPLATE,
4156    after_help = DNS_RECORDS_AFTER_HELP
4157)]
4158pub struct DnsRecordsCmd {
4159    #[command(subcommand)]
4160    pub command: DnsRecordsSubCommand,
4161}
4162
4163#[derive(Subcommand, Debug)]
4164pub enum DnsRecordsSubCommand {
4165    #[command(about = "List records in a zone")]
4166    List(DnsRecordListCmd),
4167    #[command(about = "Fetch one record by id")]
4168    Get(DnsRecordGetCmd),
4169    #[command(about = "Create a new DNS record")]
4170    Create(DnsRecordCreateCmd),
4171    #[command(about = "Replace a record by id with a full payload")]
4172    Replace(DnsRecordReplaceCmd),
4173    #[command(about = "Patch selected fields on a record")]
4174    Edit(DnsRecordEditCmd),
4175    #[command(about = "Delete a record")]
4176    Delete(DnsRecordDeleteCmd),
4177    #[command(about = "Apply a Cloudflare batch record payload from JSON")]
4178    Batch(DnsRecordBatchCmd),
4179    #[command(about = "Import a BIND-style zone file into a zone")]
4180    Import(DnsRecordImportCmd),
4181    #[command(about = "Export a zone as a BIND-style zone file")]
4182    Export(DnsRecordExportCmd),
4183}
4184
4185#[derive(Args, Debug)]
4186pub struct DnsRecordListCmd {
4187    #[arg(long, value_enum, default_value = "cloudflare")]
4188    pub provider: DnsProviderKind,
4189    #[arg(long)]
4190    pub zone_id: String,
4191    #[arg(long = "type")]
4192    pub record_type: Option<String>,
4193    #[arg(long)]
4194    pub name: Option<String>,
4195    #[arg(long)]
4196    pub page: Option<u64>,
4197    #[arg(long = "per-page")]
4198    pub per_page: Option<u64>,
4199    #[arg(long)]
4200    pub token: Option<String>,
4201}
4202
4203#[derive(Args, Debug)]
4204pub struct DnsRecordGetCmd {
4205    #[arg(long, value_enum, default_value = "cloudflare")]
4206    pub provider: DnsProviderKind,
4207    #[arg(long)]
4208    pub zone_id: String,
4209    #[arg(long)]
4210    pub record_id: String,
4211    #[arg(long)]
4212    pub token: Option<String>,
4213}
4214
4215#[derive(Args, Debug)]
4216pub struct DnsRecordCreateCmd {
4217    #[arg(long, value_enum, default_value = "cloudflare")]
4218    pub provider: DnsProviderKind,
4219    #[arg(long)]
4220    pub zone_id: String,
4221    #[arg(long = "type")]
4222    pub record_type: String,
4223    #[arg(long)]
4224    pub name: String,
4225    #[arg(long)]
4226    pub content: String,
4227    #[arg(long)]
4228    pub ttl: Option<u32>,
4229    #[arg(long)]
4230    pub proxied: Option<bool>,
4231    #[arg(long)]
4232    pub priority: Option<u32>,
4233    #[arg(long)]
4234    pub comment: Option<String>,
4235    #[arg(long = "tag")]
4236    pub tags: Vec<String>,
4237    #[arg(long = "data-json")]
4238    pub data_json: Option<String>,
4239    #[arg(long = "settings-json")]
4240    pub settings_json: Option<String>,
4241    #[arg(long)]
4242    pub token: Option<String>,
4243}
4244
4245#[derive(Args, Debug)]
4246pub struct DnsRecordReplaceCmd {
4247    #[command(flatten)]
4248    pub common: DnsRecordCreateCmd,
4249    #[arg(long)]
4250    pub record_id: String,
4251}
4252
4253#[derive(Args, Debug)]
4254pub struct DnsRecordEditCmd {
4255    #[arg(long, value_enum, default_value = "cloudflare")]
4256    pub provider: DnsProviderKind,
4257    #[arg(long)]
4258    pub zone_id: String,
4259    #[arg(long)]
4260    pub record_id: String,
4261    #[arg(long = "type")]
4262    pub record_type: Option<String>,
4263    #[arg(long)]
4264    pub name: Option<String>,
4265    #[arg(long)]
4266    pub content: Option<String>,
4267    #[arg(long)]
4268    pub ttl: Option<u32>,
4269    #[arg(long)]
4270    pub proxied: Option<bool>,
4271    #[arg(long)]
4272    pub priority: Option<u32>,
4273    #[arg(long)]
4274    pub comment: Option<String>,
4275    #[arg(long = "tag")]
4276    pub tags: Vec<String>,
4277    #[arg(long = "data-json")]
4278    pub data_json: Option<String>,
4279    #[arg(long = "settings-json")]
4280    pub settings_json: Option<String>,
4281    #[arg(long)]
4282    pub token: Option<String>,
4283}
4284
4285#[derive(Args, Debug)]
4286pub struct DnsRecordDeleteCmd {
4287    #[arg(long, value_enum, default_value = "cloudflare")]
4288    pub provider: DnsProviderKind,
4289    #[arg(long)]
4290    pub zone_id: String,
4291    #[arg(long)]
4292    pub record_id: String,
4293    #[arg(long)]
4294    pub token: Option<String>,
4295}
4296
4297#[derive(Args, Debug)]
4298pub struct DnsRecordBatchCmd {
4299    #[arg(long, value_enum, default_value = "cloudflare")]
4300    pub provider: DnsProviderKind,
4301    #[arg(long)]
4302    pub zone_id: String,
4303    #[arg(long)]
4304    pub input: PathBuf,
4305    #[arg(long)]
4306    pub token: Option<String>,
4307}
4308
4309#[derive(Args, Debug)]
4310pub struct DnsRecordImportCmd {
4311    #[arg(long, value_enum, default_value = "cloudflare")]
4312    pub provider: DnsProviderKind,
4313    #[arg(long)]
4314    pub zone_id: String,
4315    #[arg(long)]
4316    pub file: PathBuf,
4317    #[arg(long)]
4318    pub token: Option<String>,
4319}
4320
4321#[derive(Args, Debug)]
4322pub struct DnsRecordExportCmd {
4323    #[arg(long, value_enum, default_value = "cloudflare")]
4324    pub provider: DnsProviderKind,
4325    #[arg(long)]
4326    pub zone_id: String,
4327    #[arg(long)]
4328    pub output: Option<PathBuf>,
4329    #[arg(long)]
4330    pub token: Option<String>,
4331}
4332
4333#[derive(Args, Debug)]
4334#[command(
4335    about = "Inspect or edit DNSSEC status for a zone",
4336    arg_required_else_help = true,
4337    help_template = DNS_HELP_TEMPLATE,
4338    after_help = DNS_DNSSEC_AFTER_HELP
4339)]
4340pub struct DnssecCmd {
4341    #[command(subcommand)]
4342    pub command: DnssecSubCommand,
4343}
4344
4345#[derive(Subcommand, Debug)]
4346pub enum DnssecSubCommand {
4347    #[command(about = "Fetch DNSSEC state for a zone")]
4348    Get(DnssecGetCmd),
4349    #[command(about = "Edit DNSSEC-related flags for a zone")]
4350    Edit(DnssecEditCmd),
4351}
4352
4353#[derive(Args, Debug)]
4354pub struct DnssecGetCmd {
4355    #[arg(long, value_enum, default_value = "cloudflare")]
4356    pub provider: DnsProviderKind,
4357    #[arg(long)]
4358    pub zone_id: String,
4359    #[arg(long)]
4360    pub token: Option<String>,
4361}
4362
4363#[derive(Args, Debug)]
4364pub struct DnssecEditCmd {
4365    #[arg(long, value_enum, default_value = "cloudflare")]
4366    pub provider: DnsProviderKind,
4367    #[arg(long)]
4368    pub zone_id: String,
4369    #[arg(long)]
4370    pub status: Option<String>,
4371    #[arg(long = "dnssec-multi-signer")]
4372    pub dnssec_multi_signer: Option<bool>,
4373    #[arg(long = "dnssec-presigned")]
4374    pub dnssec_presigned: Option<bool>,
4375    #[arg(long = "dnssec-use-nsec3")]
4376    pub dnssec_use_nsec3: Option<bool>,
4377    #[arg(long)]
4378    pub token: Option<String>,
4379}
4380
4381#[derive(Args, Debug)]
4382#[command(
4383    about = "Inspect or edit provider DNS settings for a zone",
4384    arg_required_else_help = true,
4385    help_template = DNS_HELP_TEMPLATE,
4386    after_help = DNS_SETTINGS_AFTER_HELP
4387)]
4388pub struct DnsSettingsCmd {
4389    #[command(subcommand)]
4390    pub command: DnsSettingsSubCommand,
4391}
4392
4393#[derive(Subcommand, Debug)]
4394pub enum DnsSettingsSubCommand {
4395    #[command(about = "Fetch provider DNS settings for a zone")]
4396    Get(DnsSettingsGetCmd),
4397    #[command(about = "Edit provider DNS settings for a zone")]
4398    Edit(DnsSettingsEditCmd),
4399}
4400
4401#[derive(Args, Debug)]
4402pub struct DnsSettingsGetCmd {
4403    #[arg(long, value_enum, default_value = "cloudflare")]
4404    pub provider: DnsProviderKind,
4405    #[arg(long)]
4406    pub zone_id: String,
4407    #[arg(long)]
4408    pub token: Option<String>,
4409}
4410
4411#[derive(Args, Debug)]
4412pub struct DnsSettingsEditCmd {
4413    #[arg(long, value_enum, default_value = "cloudflare")]
4414    pub provider: DnsProviderKind,
4415    #[arg(long)]
4416    pub zone_id: String,
4417    #[arg(long)]
4418    pub flatten_all_cnames: Option<bool>,
4419    #[arg(long)]
4420    pub foundation_dns: Option<bool>,
4421    #[arg(long)]
4422    pub multi_provider: Option<bool>,
4423    #[arg(long)]
4424    pub ns_ttl: Option<u32>,
4425    #[arg(long)]
4426    pub secondary_overrides: Option<bool>,
4427    #[arg(long)]
4428    pub zone_mode: Option<String>,
4429    #[arg(long = "reference-zone-id")]
4430    pub reference_zone_id: Option<String>,
4431    #[arg(long = "nameservers-type")]
4432    pub nameservers_type: Option<String>,
4433    #[arg(long = "nameservers-ns-set")]
4434    pub nameservers_ns_set: Option<u32>,
4435    #[arg(long = "soa-json")]
4436    pub soa_json: Option<String>,
4437    #[arg(long)]
4438    pub token: Option<String>,
4439}
4440
4441#[derive(Args, Debug)]
4442#[command(
4443    arg_required_else_help = true,
4444    disable_help_subcommand = true,
4445    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
4446    after_help = crate::cli::help_render::DOMAINS_AFTER_HELP
4447)]
4448pub struct DomainsCmd {
4449    #[arg(long, value_enum, default_value = "cloudflare")]
4450    pub provider: DomainsProviderKind,
4451    #[arg(long)]
4452    pub account_id: Option<String>,
4453    #[arg(long)]
4454    pub token: Option<String>,
4455    #[command(subcommand)]
4456    pub command: DomainsSubCommand,
4457}
4458
4459#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
4460pub enum DomainsProviderKind {
4461    Cloudflare,
4462}
4463
4464#[derive(Subcommand, Debug)]
4465pub enum DomainsSubCommand {
4466    Search(DomainsSearchCmd),
4467    Check(DomainsCheckCmd),
4468    List(DomainsListCmd),
4469}
4470
4471#[derive(Args, Debug)]
4472pub struct DomainsSearchCmd {
4473    #[arg(long)]
4474    pub query: String,
4475    #[arg(long = "extension")]
4476    pub extensions: Vec<String>,
4477    #[arg(long)]
4478    pub limit: Option<usize>,
4479}
4480
4481#[derive(Args, Debug)]
4482pub struct DomainsCheckCmd {
4483    #[arg(long = "domain", required = true)]
4484    pub domains: Vec<String>,
4485}
4486
4487#[derive(Args, Debug)]
4488pub struct DomainsListCmd {}
4489
4490#[derive(Args, Debug)]
4491pub struct GenerateSystemdCmd {
4492    #[arg(
4493        long,
4494        default_value = "/etc/systemd/system",
4495        help = "Directory where the systemd units are written"
4496    )]
4497    pub output_dir: PathBuf,
4498    #[arg(long, help = "Only generate the unit for this service name")]
4499    pub service: Option<String>,
4500    #[arg(
4501        long,
4502        default_value_t = true,
4503        help = "Also generate the xbp-api systemd unit alongside project/services"
4504    )]
4505    pub api: bool,
4506}
4507
4508#[derive(Args, Debug)]
4509#[command(
4510    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
4511    after_help = crate::cli::help_render::DONE_AFTER_HELP
4512)]
4513pub struct DoneCmd {
4514    #[arg(long, help = "Root directory under which to discover git repos")]
4515    pub root: Option<std::path::PathBuf>,
4516    #[arg(
4517        long,
4518        default_value = "24 hours ago",
4519        help = "Git --since value (e.g. '7 days ago')"
4520    )]
4521    pub since: String,
4522    #[arg(short, long, help = "Output Markdown file path")]
4523    pub output: Option<std::path::PathBuf>,
4524    #[arg(long, help = "Skip AI summarization (OpenRouter)")]
4525    pub no_ai: bool,
4526    #[arg(short, long, help = "Discover repos recursively")]
4527    pub recursive: bool,
4528    #[arg(long, help = "Exclude repo by name (repeatable)")]
4529    pub exclude: Vec<String>,
4530}
4531
4532#[derive(Args, Debug)]
4533pub struct FixProcessMonitorJsonCmd {
4534    #[arg(help = "Path to a Cursor process-monitor JSON export")]
4535    pub path: std::path::PathBuf,
4536    #[arg(
4537        long,
4538        help = "Check whether the file needs repair without writing changes"
4539    )]
4540    pub check: bool,
4541    #[arg(
4542        long,
4543        help = "Print repaired JSON to stdout instead of overwriting the file"
4544    )]
4545    pub stdout: bool,
4546}
4547
4548#[derive(Args, Debug)]
4549pub struct CursorCmd {
4550    #[command(subcommand)]
4551    pub command: CursorSubCommand,
4552}
4553
4554#[derive(Subcommand, Debug)]
4555pub enum CursorSubCommand {
4556    #[command(about = "Upload local Cursor file history to the XBP dashboard")]
4557    Ingest {
4558        #[arg(
4559            long,
4560            help = "Scan local Cursor history without uploading to the dashboard"
4561        )]
4562        dry_run: bool,
4563    },
4564}
4565
4566#[cfg(feature = "nordvpn")]
4567#[derive(Args, Debug)]
4568pub struct NordvpnCmd {
4569    #[arg(
4570        trailing_var_arg = true,
4571        allow_hyphen_values = true,
4572        help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
4573    )]
4574    pub args: Vec<String>,
4575}
4576
4577#[cfg(feature = "kubernetes")]
4578#[derive(Args, Debug)]
4579pub struct KubernetesCmd {
4580    #[command(subcommand)]
4581    pub command: KubernetesSubCommand,
4582}
4583
4584#[cfg(feature = "kubernetes")]
4585#[derive(Args, Debug)]
4586pub struct KubernetesAddonCmd {
4587    #[command(subcommand)]
4588    pub command: KubernetesAddonSubCommand,
4589}
4590
4591#[cfg(feature = "kubernetes")]
4592#[derive(Subcommand, Debug)]
4593pub enum KubernetesAddonSubCommand {
4594    /// Show complete addon status (enabled/disabled) from `microk8s status`
4595    List,
4596    /// Enable a MicroK8s addon
4597    Enable {
4598        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
4599        name: String,
4600    },
4601    /// Disable a MicroK8s addon
4602    Disable {
4603        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
4604        name: String,
4605    },
4606}
4607
4608#[cfg(feature = "kubernetes")]
4609#[derive(Subcommand, Debug)]
4610pub enum KubernetesSubCommand {
4611    /// Validate kubectl, current context, and node readiness
4612    Check {
4613        #[arg(long, help = "Kubeconfig context to target")]
4614        context: Option<String>,
4615        #[arg(
4616            long,
4617            default_value = "default",
4618            help = "Namespace to probe for workload readiness"
4619        )]
4620        namespace: String,
4621        #[arg(long, help = "Skip live cluster calls (tooling check only)")]
4622        offline: bool,
4623    },
4624    /// Generate Deployment/Service/NetworkPolicy YAML
4625    Generate {
4626        #[arg(long, help = "Logical app name (used for resource names)")]
4627        name: String,
4628        #[arg(long, help = "Container image reference")]
4629        image: String,
4630        #[arg(long, default_value_t = 80, help = "Container port for the service")]
4631        port: u16,
4632        #[arg(long, default_value_t = 1, help = "Replica count")]
4633        replicas: u16,
4634        #[arg(
4635            long,
4636            default_value = "default",
4637            help = "Namespace for generated resources"
4638        )]
4639        namespace: String,
4640        #[arg(
4641            long,
4642            default_value = "k8s/xbp-manifest.yaml",
4643            help = "Path to write the manifest bundle"
4644        )]
4645        output: String,
4646        #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
4647        host: Option<String>,
4648    },
4649    /// Apply a manifest bundle with kubectl apply -f
4650    Apply {
4651        #[arg(long, help = "Path to manifest file")]
4652        file: String,
4653        #[arg(long, help = "Override kube context")]
4654        context: Option<String>,
4655        #[arg(long, help = "Override namespace")]
4656        namespace: Option<String>,
4657        #[arg(long, help = "Use --dry-run=server")]
4658        dry_run: bool,
4659    },
4660    /// Summarize deployments/services/pods in a namespace
4661    Status {
4662        #[arg(long, default_value = "default", help = "Namespace to summarize")]
4663        namespace: String,
4664        #[arg(long, help = "Override kube context")]
4665        context: Option<String>,
4666    },
4667    /// Manage MicroK8s addons (list, enable, disable)
4668    Addons(KubernetesAddonCmd),
4669    /// Extract Kubernetes Dashboard login token from secret describe output
4670    DashboardToken {
4671        #[arg(
4672            long,
4673            default_value = "kube-system",
4674            help = "Namespace containing the dashboard token secret"
4675        )]
4676        namespace: String,
4677        #[arg(
4678            long,
4679            default_value = "microk8s-dashboard-token",
4680            help = "Secret name containing the dashboard login token"
4681        )]
4682        secret: String,
4683        #[arg(long, help = "Override kube context")]
4684        context: Option<String>,
4685    },
4686    /// Print decoded Grafana admin credentials from observability secret
4687    ObservabilityCreds {
4688        #[arg(
4689            long,
4690            default_value = "observability",
4691            help = "Namespace containing Grafana secret"
4692        )]
4693        namespace: String,
4694        #[arg(
4695            long,
4696            default_value = "kube-prom-stack-grafana",
4697            help = "Grafana secret name"
4698        )]
4699        secret: String,
4700        #[arg(long, help = "Override kube context")]
4701        context: Option<String>,
4702    },
4703    /// Create or update a cert-manager Issuer for Let's Encrypt
4704    Issuer {
4705        #[arg(
4706            long,
4707            help = "Email used for Let's Encrypt account registration (required)"
4708        )]
4709        email: String,
4710        #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
4711        name: String,
4712        #[arg(
4713            long,
4714            default_value = "default",
4715            help = "Namespace for the Issuer resource"
4716        )]
4717        namespace: String,
4718        #[arg(
4719            long,
4720            default_value = "https://acme-v02.api.letsencrypt.org/directory",
4721            help = "ACME server URL (production by default)"
4722        )]
4723        server: String,
4724        #[arg(
4725            long,
4726            default_value = "letsencrypt-account-key",
4727            help = "Secret used to store the ACME account private key"
4728        )]
4729        private_key_secret: String,
4730        #[arg(
4731            long,
4732            default_value = "nginx",
4733            help = "Ingress class name used for HTTP01 solving"
4734        )]
4735        ingress_class_name: String,
4736        #[arg(long, help = "Override kube context")]
4737        context: Option<String>,
4738        #[arg(long, help = "Use --dry-run=server")]
4739        dry_run: bool,
4740    },
4741}
4742
4743#[cfg(test)]
4744mod tests {
4745    use super::{
4746        Cli, CloudflareConfigAction, CloudflareJobWorkflow, CloudflareRollout,
4747        CloudflareSubCommand, CloudflaredSubCommand, Commands, DnsProviderKind, DnsSubCommand,
4748        DnsZonesSubCommand, DomainsProviderKind, DomainsSubCommand, GenerateSubCommand,
4749        LinearConfigAction, NetworkFloatingIpSubCommand, NetworkHetznerSubCommand,
4750        NetworkHetznerVswitchSubCommand, NetworkSubCommand, SshCmd, WorktreeWatchSubCommand,
4751    };
4752    #[cfg(feature = "secrets")]
4753    use super::{
4754        CloudflareSecretsSubCommand, SecretsProviderKind, SecretsStoresSubCommand,
4755        SecretsSubCommand,
4756    };
4757    use clap::Parser;
4758    use std::path::PathBuf;
4759
4760    #[test]
4761    fn parses_network_floating_ip_add() {
4762        let cli = Cli::parse_from([
4763            "xbp",
4764            "network",
4765            "floating-ip",
4766            "add",
4767            "--ip",
4768            "1.2.3.4",
4769            "--apply",
4770        ]);
4771
4772        match cli.command {
4773            Some(Commands::Network(network)) => match network.command {
4774                NetworkSubCommand::FloatingIp(fip) => match fip.command {
4775                    NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
4776                        assert_eq!(ip, "1.2.3.4");
4777                        assert!(apply);
4778                    }
4779                    _ => panic!("expected add subcommand"),
4780                },
4781                _ => panic!("expected floating-ip subcommand"),
4782            },
4783            _ => panic!("expected network command"),
4784        }
4785    }
4786
4787    #[test]
4788    fn parses_generate_config_update() {
4789        let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
4790
4791        match cli.command {
4792            Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
4793                GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
4794                _ => panic!("expected generate config command"),
4795            },
4796            _ => panic!("expected generate command"),
4797        }
4798    }
4799
4800    #[test]
4801    fn parses_commit_command_with_dry_run() {
4802        let cli = Cli::parse_from(["xbp", "commit", "--dry-run", "--scope", "cli"]);
4803
4804        match cli.command {
4805            Some(Commands::Commit(commit_cmd)) => {
4806                assert!(commit_cmd.dry_run);
4807                assert_eq!(commit_cmd.scope.as_deref(), Some("cli"));
4808                assert_eq!(commit_cmd.model, None);
4809            }
4810            _ => panic!("expected commit command"),
4811        }
4812    }
4813
4814    #[test]
4815    fn parses_linear_select_initiative_config_command() {
4816        let cli = Cli::parse_from(["xbp", "config", "linear", "select-initiative"]);
4817
4818        match cli.command {
4819            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
4820                Some(super::ConfigProviderCmd::Linear(linear_cmd)) => {
4821                    assert!(matches!(
4822                        linear_cmd.action,
4823                        LinearConfigAction::SelectInitiative
4824                    ));
4825                }
4826                _ => panic!("expected linear config provider"),
4827            },
4828            _ => panic!("expected config command"),
4829        }
4830    }
4831
4832    #[test]
4833    fn parses_ssh_command_with_cloudflared_and_key_auth() {
4834        let cli = Cli::parse_from([
4835            "xbp",
4836            "ssh",
4837            "--host",
4838            "ssh.internal",
4839            "--username",
4840            "deploy",
4841            "--private-key",
4842            "C:/Users/floris/.ssh/id_ed25519",
4843            "--cloudflared-hostname",
4844            "bastion.example.com",
4845            "--command",
4846            "htop",
4847        ]);
4848
4849        let Some(Commands::Ssh(SshCmd {
4850            ssh_host,
4851            ssh_username,
4852            private_key,
4853            cloudflared_hostname,
4854            command,
4855            ..
4856        })) = cli.command
4857        else {
4858            panic!("expected shell command");
4859        };
4860
4861        assert_eq!(ssh_host.as_deref(), Some("ssh.internal"));
4862        assert_eq!(ssh_username.as_deref(), Some("deploy"));
4863        assert_eq!(
4864            private_key,
4865            Some(PathBuf::from("C:/Users/floris/.ssh/id_ed25519"))
4866        );
4867        assert_eq!(cloudflared_hostname.as_deref(), Some("bastion.example.com"));
4868        assert_eq!(command.as_deref(), Some("htop"));
4869    }
4870
4871    #[test]
4872    fn parses_cloudflared_tcp_command() {
4873        let cli = Cli::parse_from([
4874            "xbp",
4875            "cloudflared",
4876            "tcp",
4877            "--hostname",
4878            "bastion.example.com",
4879            "--listener",
4880            "127.0.0.1:2222",
4881        ]);
4882
4883        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
4884            panic!("expected cloudflared command");
4885        };
4886
4887        match cloudflared_cmd.command {
4888            CloudflaredSubCommand::Tcp(tcp_cmd) => {
4889                assert_eq!(tcp_cmd.hostname.as_deref(), Some("bastion.example.com"));
4890                assert_eq!(tcp_cmd.listener.as_deref(), Some("127.0.0.1:2222"));
4891            }
4892        }
4893    }
4894
4895    #[test]
4896    fn parses_cloudflared_tcp_without_hostname_for_handler_validation() {
4897        let cli = Cli::try_parse_from(["xbp", "cloudflared", "tcp"]).expect("parse");
4898
4899        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
4900            panic!("expected cloudflared command");
4901        };
4902
4903        match cloudflared_cmd.command {
4904            CloudflaredSubCommand::Tcp(tcp_cmd) => {
4905                assert_eq!(tcp_cmd.hostname, None);
4906                assert_eq!(tcp_cmd.listener, None);
4907            }
4908        }
4909    }
4910
4911    #[test]
4912    fn parses_cloudflare_doctor_with_app_after_subcommand() {
4913        let cli = Cli::parse_from(["xbp", "cloudflare", "doctor", "--app", "auth"]);
4914
4915        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
4916            panic!("expected cloudflare command");
4917        };
4918        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
4919        assert!(matches!(
4920            cloudflare_cmd.command,
4921            CloudflareSubCommand::Doctor(_)
4922        ));
4923    }
4924
4925    #[test]
4926    fn parses_cloudflare_tunnel_run_with_token_and_log_level() {
4927        let cli = Cli::parse_from([
4928            "xbp",
4929            "cloudflare",
4930            "tunnel",
4931            "run",
4932            "--token",
4933            "tunnel-token",
4934            "--log-level",
4935            "debug",
4936        ]);
4937
4938        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
4939            panic!("expected cloudflare command");
4940        };
4941
4942        match cloudflare_cmd.command {
4943            CloudflareSubCommand::Tunnel(tunnel_cmd) => match tunnel_cmd.command {
4944                super::CloudflareTunnelSubCommand::Run(run_cmd) => {
4945                    assert_eq!(run_cmd.tunnel, None);
4946                    assert_eq!(run_cmd.token.as_deref(), Some("tunnel-token"));
4947                    assert_eq!(run_cmd.log_level.as_deref(), Some("debug"));
4948                }
4949                _ => panic!("expected tunnel run command"),
4950            },
4951            _ => panic!("expected cloudflare tunnel command"),
4952        }
4953    }
4954
4955    #[test]
4956    fn parses_cloudflare_workflows_instance_command_with_local_flags() {
4957        let cli = Cli::parse_from([
4958            "xbp",
4959            "cloudflare",
4960            "workflows",
4961            "instances",
4962            "send-event",
4963            "my-workflow",
4964            "latest",
4965            "--type",
4966            "my-event",
4967            "--payload",
4968            "{\"key\":\"value\"}",
4969            "--local",
4970            "--port",
4971            "8787",
4972        ]);
4973
4974        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
4975            panic!("expected cloudflare command");
4976        };
4977
4978        match cloudflare_cmd.command {
4979            CloudflareSubCommand::Workflows(workflows_cmd) => match workflows_cmd.command {
4980                super::CloudflareWorkflowsSubCommand::External(args) => assert_eq!(
4981                    args,
4982                    [
4983                        "instances",
4984                        "send-event",
4985                        "my-workflow",
4986                        "latest",
4987                        "--type",
4988                        "my-event",
4989                        "--payload",
4990                        "{\"key\":\"value\"}",
4991                        "--local",
4992                        "--port",
4993                        "8787"
4994                    ]
4995                ),
4996            },
4997            _ => panic!("expected cloudflare workflows command"),
4998        }
4999    }
5000
5001    #[test]
5002    fn parses_cloudflare_env_dev_with_environment_and_port() {
5003        let cli = Cli::parse_from([
5004            "xbp",
5005            "cloudflare",
5006            "env",
5007            "dev",
5008            "--env",
5009            "staging",
5010            "--port",
5011            "8787",
5012        ]);
5013
5014        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5015            panic!("expected cloudflare command");
5016        };
5017
5018        match cloudflare_cmd.command {
5019            CloudflareSubCommand::Env(env_cmd) => match env_cmd.command {
5020                super::CloudflareEnvSubCommand::External(args) => {
5021                    assert_eq!(args, ["dev", "--env", "staging", "--port", "8787"])
5022                }
5023            },
5024            _ => panic!("expected cloudflare env command"),
5025        }
5026    }
5027
5028    #[test]
5029    fn parses_cloudflare_release_with_domain_guard() {
5030        let cli = Cli::parse_from([
5031            "xbp",
5032            "cloudflare",
5033            "release",
5034            "--app",
5035            "auth",
5036            "--version",
5037            "1.2.3",
5038            "--rollout",
5039            "gradual",
5040            "--domain",
5041            "auth-runtime",
5042        ]);
5043
5044        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5045            panic!("expected cloudflare command");
5046        };
5047        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
5048        match cloudflare_cmd.command {
5049            CloudflareSubCommand::Release(release_cmd) => {
5050                assert_eq!(release_cmd.version, "1.2.3");
5051                assert_eq!(release_cmd.rollout, CloudflareRollout::Gradual);
5052                assert_eq!(release_cmd.domain.as_deref(), Some("auth-runtime"));
5053            }
5054            _ => panic!("expected release subcommand"),
5055        }
5056    }
5057
5058    #[test]
5059    fn parses_cloudflare_deploy_parity_flags() {
5060        let cli = Cli::parse_from([
5061            "xbp",
5062            "cloudflare",
5063            "deploy",
5064            "--app",
5065            "auth",
5066            "--rollout",
5067            "none",
5068            "--skip-deploy",
5069            "--allow-unchanged-container-image",
5070            "--prune-old-images",
5071            "--keep-image-tag-count",
5072            "7",
5073        ]);
5074
5075        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5076            panic!("expected cloudflare command");
5077        };
5078        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
5079        match cloudflare_cmd.command {
5080            CloudflareSubCommand::Deploy(deploy_cmd) => {
5081                assert_eq!(deploy_cmd.rollout, CloudflareRollout::None);
5082                assert!(deploy_cmd.skip_deploy);
5083                assert!(deploy_cmd.allow_unchanged_container_image);
5084                assert!(deploy_cmd.prune_old_images);
5085                assert_eq!(deploy_cmd.keep_image_tag_count, Some(7));
5086            }
5087            _ => panic!("expected deploy subcommand"),
5088        }
5089    }
5090
5091    #[test]
5092    fn parses_cloudflare_release_parity_flags() {
5093        let cli = Cli::parse_from([
5094            "xbp",
5095            "cloudflare",
5096            "release",
5097            "--app",
5098            "auth",
5099            "--version",
5100            "1.2.3",
5101            "--rollout",
5102            "none",
5103            "--skip-deploy",
5104            "--allow-unchanged-container-image",
5105            "--prune-old-images",
5106            "--keep-image-tag-count",
5107            "5",
5108        ]);
5109
5110        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5111            panic!("expected cloudflare command");
5112        };
5113        match cloudflare_cmd.command {
5114            CloudflareSubCommand::Release(release_cmd) => {
5115                assert_eq!(release_cmd.rollout, CloudflareRollout::None);
5116                assert!(release_cmd.skip_deploy);
5117                assert!(release_cmd.allow_unchanged_container_image);
5118                assert!(release_cmd.prune_old_images);
5119                assert_eq!(release_cmd.keep_image_tag_count, Some(5));
5120            }
5121            _ => panic!("expected release subcommand"),
5122        }
5123    }
5124
5125    #[test]
5126    fn parses_cloudflare_jobs_enqueue_with_workflow_payload() {
5127        let cli = Cli::parse_from([
5128            "xbp",
5129            "cloudflare",
5130            "jobs",
5131            "enqueue",
5132            "release",
5133            "--app",
5134            "auth",
5135            "--version",
5136            "1.2.3",
5137            "--rollout",
5138            "gradual",
5139            "--skip-deploy",
5140            "--prune-old-images",
5141        ]);
5142
5143        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5144            panic!("expected cloudflare command");
5145        };
5146        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
5147        match cloudflare_cmd.command {
5148            CloudflareSubCommand::Jobs(jobs_cmd) => match jobs_cmd.command {
5149                super::CloudflareJobsSubCommand::Enqueue(enqueue_cmd) => {
5150                    assert_eq!(enqueue_cmd.workflow, CloudflareJobWorkflow::Release);
5151                    assert_eq!(enqueue_cmd.version.as_deref(), Some("1.2.3"));
5152                    assert_eq!(enqueue_cmd.rollout, CloudflareRollout::Gradual);
5153                    assert!(enqueue_cmd.skip_deploy);
5154                    assert!(enqueue_cmd.prune_old_images);
5155                }
5156                _ => panic!("expected enqueue subcommand"),
5157            },
5158            _ => panic!("expected jobs subcommand"),
5159        }
5160    }
5161
5162    #[test]
5163    fn parses_worktree_watch_stop_force_command() {
5164        let cli = Cli::parse_from([
5165            "xbp",
5166            "worktree-watch",
5167            "stop",
5168            "--repo",
5169            "C:/Users/floris/Documents/GitHub/xbp",
5170            "--force",
5171        ]);
5172
5173        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
5174            panic!("expected worktree-watch command");
5175        };
5176
5177        match worktree_cmd.command {
5178            WorktreeWatchSubCommand::Stop(stop_cmd) => {
5179                assert_eq!(
5180                    stop_cmd.target.repo,
5181                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub/xbp"))
5182                );
5183                assert!(stop_cmd.force);
5184            }
5185            _ => panic!("expected stop subcommand"),
5186        }
5187    }
5188
5189    #[test]
5190    fn parses_worktree_watch_parent_detach_command() {
5191        let cli = Cli::parse_from([
5192            "xbp",
5193            "worktree-watch",
5194            "start",
5195            "--parent",
5196            "C:/Users/floris/Documents/GitHub",
5197            "--detach",
5198        ]);
5199
5200        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
5201            panic!("expected worktree-watch command");
5202        };
5203
5204        match worktree_cmd.command {
5205            WorktreeWatchSubCommand::Start(start_cmd) => {
5206                assert_eq!(
5207                    start_cmd.target.parent,
5208                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub"))
5209                );
5210                assert!(start_cmd.detach);
5211            }
5212            _ => panic!("expected start subcommand"),
5213        }
5214    }
5215
5216    #[test]
5217    fn parses_worktree_watch_status_repo_activity_command() {
5218        let cli = Cli::parse_from([
5219            "xbp",
5220            "worktree-watch",
5221            "status",
5222            "--repo-activity",
5223            "--stats-gap-minutes",
5224            "30",
5225        ]);
5226
5227        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
5228            panic!("expected worktree-watch command");
5229        };
5230
5231        match worktree_cmd.command {
5232            WorktreeWatchSubCommand::Status(status_cmd) => {
5233                assert!(status_cmd.repo_activity);
5234                assert_eq!(status_cmd.stats_gap_minutes, 30);
5235            }
5236            _ => panic!("expected status subcommand"),
5237        }
5238    }
5239
5240    #[test]
5241    fn parses_version_workspace_publish_run_command() {
5242        let cli = Cli::parse_from([
5243            "xbp",
5244            "version",
5245            "workspace",
5246            "publish",
5247            "run",
5248            "--repo",
5249            "C:/Users/floris/Documents/GitHub/athena",
5250            "--dry-run",
5251            "--from",
5252            "athena-s3",
5253        ]);
5254
5255        let Some(Commands::Version(version_cmd)) = cli.command else {
5256            panic!("expected version command");
5257        };
5258
5259        match version_cmd.command {
5260            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
5261                match workspace_cmd.command {
5262                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
5263                        match publish_cmd.command {
5264                            super::VersionWorkspacePublishSubCommand::Run(run_cmd) => {
5265                                assert_eq!(
5266                                    run_cmd.target.repo,
5267                                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub/athena"))
5268                                );
5269                                assert!(!run_cmd.target.json);
5270                                assert!(run_cmd.dry_run);
5271                                assert_eq!(run_cmd.from.as_deref(), Some("athena-s3"));
5272                            }
5273                            _ => panic!("expected publish run"),
5274                        }
5275                    }
5276                    _ => panic!("expected workspace publish"),
5277                }
5278            }
5279            _ => panic!("expected version workspace command"),
5280        }
5281    }
5282
5283    #[test]
5284    fn parses_version_workspace_publish_plan_with_only_and_include_prereqs() {
5285        let cli = Cli::parse_from([
5286            "xbp",
5287            "version",
5288            "workspace",
5289            "publish",
5290            "plan",
5291            "--repo",
5292            "C:/Users/floris/Documents/GitHub/athena-auth",
5293            "--only",
5294            "athena-auth",
5295            "--include-prereqs",
5296        ]);
5297
5298        let Some(Commands::Version(version_cmd)) = cli.command else {
5299            panic!("expected version command");
5300        };
5301
5302        match version_cmd.command {
5303            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
5304                match workspace_cmd.command {
5305                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
5306                        match publish_cmd.command {
5307                            super::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => {
5308                                assert_eq!(
5309                                    plan_cmd.target.repo,
5310                                    Some(PathBuf::from(
5311                                        "C:/Users/floris/Documents/GitHub/athena-auth"
5312                                    ))
5313                                );
5314                                assert_eq!(plan_cmd.only.as_deref(), Some("athena-auth"));
5315                                assert!(plan_cmd.include_prereqs);
5316                            }
5317                            _ => panic!("expected publish plan"),
5318                        }
5319                    }
5320                    _ => panic!("expected workspace publish"),
5321                }
5322            }
5323            _ => panic!("expected version workspace command"),
5324        }
5325    }
5326
5327    #[test]
5328    fn parses_commit_alias_with_push_flag() {
5329        let cli = Cli::parse_from(["xbp", "c", "-p"]);
5330
5331        let Some(Commands::Commit(commit_cmd)) = cli.command else {
5332            panic!("expected commit command");
5333        };
5334
5335        assert!(commit_cmd.push);
5336        assert!(!commit_cmd.dry_run);
5337    }
5338
5339    #[test]
5340    fn parses_version_alias_release_alias() {
5341        let cli = Cli::parse_from([
5342            "xbp",
5343            "v",
5344            "r",
5345            "--draft",
5346            "--publish",
5347            "--force",
5348            "--flag",
5349            "nightly",
5350        ]);
5351
5352        let Some(Commands::Version(version_cmd)) = cli.command else {
5353            panic!("expected version command");
5354        };
5355
5356        let Some(super::VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
5357            panic!("expected release subcommand");
5358        };
5359
5360        assert!(release_cmd.draft);
5361        assert!(release_cmd.publish);
5362        assert!(release_cmd.force);
5363        assert_eq!(release_cmd.flag, Some(super::VersionReleaseFlag::Nightly));
5364    }
5365
5366    #[test]
5367    fn parses_version_domain_release_guard_flags() {
5368        let cli = Cli::parse_from([
5369            "xbp",
5370            "version",
5371            "domain",
5372            "release",
5373            "--domain",
5374            "auth-runtime",
5375            "--version",
5376            "2.0.0",
5377            "--deploy",
5378            "--allow-major-jump",
5379            "--allow-cross-domain-version",
5380            "platform",
5381        ]);
5382
5383        let Some(Commands::Version(version_cmd)) = cli.command else {
5384            panic!("expected version command");
5385        };
5386
5387        let Some(super::VersionSubCommand::Domain(domain_cmd)) = version_cmd.command else {
5388            panic!("expected domain command");
5389        };
5390
5391        match domain_cmd.command {
5392            super::VersionDomainSubCommand::Release(release_cmd) => {
5393                assert_eq!(release_cmd.domain, "auth-runtime");
5394                assert_eq!(release_cmd.version.as_deref(), Some("2.0.0"));
5395                assert!(release_cmd.deploy);
5396                assert!(release_cmd.allow_major_jump);
5397                assert_eq!(
5398                    release_cmd.allow_cross_domain_version.as_deref(),
5399                    Some("platform")
5400                );
5401            }
5402            _ => panic!("expected domain release"),
5403        }
5404    }
5405
5406    #[test]
5407    fn parses_publish_command_target_filter() {
5408        let cli = Cli::parse_from([
5409            "xbp",
5410            "publish",
5411            "--allow-dirty",
5412            "--force",
5413            "--include-prereqs",
5414            "--target",
5415            "npm",
5416            "--service",
5417            "web",
5418            "--manifest-path",
5419            "apps/web/package.json",
5420        ]);
5421
5422        let Some(Commands::Publish(publish_cmd)) = cli.command else {
5423            panic!("expected publish command");
5424        };
5425
5426        assert!(publish_cmd.allow_dirty);
5427        assert!(publish_cmd.force);
5428        assert!(publish_cmd.include_prereqs);
5429        assert_eq!(publish_cmd.target.as_deref(), Some("npm"));
5430        assert_eq!(publish_cmd.service.as_deref(), Some("web"));
5431        assert_eq!(
5432            publish_cmd.manifest_path,
5433            Some(PathBuf::from("apps/web/package.json"))
5434        );
5435    }
5436
5437    #[test]
5438    fn parses_npm_setup_release_config_command() {
5439        let cli = Cli::parse_from(["xbp", "config", "npm", "setup-release"]);
5440
5441        let Some(Commands::Config(config_cmd)) = cli.command else {
5442            panic!("expected config command");
5443        };
5444        let Some(super::ConfigProviderCmd::Npm(registry_cmd)) = config_cmd.provider else {
5445            panic!("expected npm config command");
5446        };
5447
5448        assert!(matches!(
5449            registry_cmd.action,
5450            super::RegistryConfigAction::SetupRelease
5451        ));
5452    }
5453
5454    #[test]
5455    fn parses_release_setup_config_command() {
5456        let cli = Cli::parse_from(["xbp", "config", "release", "setup"]);
5457
5458        let Some(Commands::Config(config_cmd)) = cli.command else {
5459            panic!("expected config command");
5460        };
5461        let Some(super::ConfigProviderCmd::Release(release_cmd)) = config_cmd.provider else {
5462            panic!("expected release config command");
5463        };
5464
5465        assert!(matches!(
5466            release_cmd.action,
5467            super::ReleaseConfigAction::Setup
5468        ));
5469    }
5470
5471    #[test]
5472    fn parses_crates_login_config_command() {
5473        let cli = Cli::parse_from(["xbp", "config", "crates", "login"]);
5474
5475        let Some(Commands::Config(config_cmd)) = cli.command else {
5476            panic!("expected config command");
5477        };
5478        let Some(super::ConfigProviderCmd::Crates(crates_cmd)) = config_cmd.provider else {
5479            panic!("expected crates config command");
5480        };
5481
5482        assert!(matches!(
5483            crates_cmd.action,
5484            super::CratesConfigAction::Login { .. }
5485        ));
5486    }
5487
5488    #[test]
5489    fn parses_crates_logout_config_command() {
5490        let cli = Cli::parse_from(["xbp", "config", "crates", "logout"]);
5491
5492        let Some(Commands::Config(config_cmd)) = cli.command else {
5493            panic!("expected config command");
5494        };
5495        let Some(super::ConfigProviderCmd::Crates(crates_cmd)) = config_cmd.provider else {
5496            panic!("expected crates config command");
5497        };
5498
5499        assert!(matches!(
5500            crates_cmd.action,
5501            super::CratesConfigAction::Logout
5502        ));
5503    }
5504
5505    #[test]
5506    fn parses_whoami_command() {
5507        let cli = Cli::parse_from(["xbp", "whoami"]);
5508
5509        assert!(matches!(cli.command, Some(Commands::Whoami)));
5510    }
5511
5512    #[test]
5513    fn parses_shell_alias_as_ssh_command() {
5514        let cli = Cli::parse_from(["xbp", "shell", "--host", "ssh.internal"]);
5515
5516        let Some(Commands::Ssh(ssh_cmd)) = cli.command else {
5517            panic!("expected ssh command through shell alias");
5518        };
5519
5520        assert_eq!(ssh_cmd.ssh_host.as_deref(), Some("ssh.internal"));
5521    }
5522
5523    #[test]
5524    fn parses_api_request_command() {
5525        let cli = Cli::parse_from([
5526            "xbp",
5527            "api",
5528            "request",
5529            "/api/registry/installers/python-pip",
5530            "--web",
5531            "--method",
5532            "GET",
5533            "--header",
5534            "accept: application/json",
5535        ]);
5536
5537        let Some(Commands::Api(api_cmd)) = cli.command else {
5538            panic!("expected api command");
5539        };
5540
5541        match api_cmd.command {
5542            super::ApiSubCommand::Request(request_cmd) => {
5543                assert_eq!(request_cmd.path, "/api/registry/installers/python-pip");
5544                assert!(request_cmd.target.web);
5545                assert_eq!(request_cmd.method.as_deref(), Some("GET"));
5546                assert_eq!(
5547                    request_cmd.target.header,
5548                    vec!["accept: application/json".to_string()]
5549                );
5550            }
5551            _ => panic!("expected api request subcommand"),
5552        }
5553    }
5554
5555    #[test]
5556    fn parses_api_projects_list_command() {
5557        let cli = Cli::parse_from([
5558            "xbp",
5559            "api",
5560            "projects",
5561            "list",
5562            "--organization-id",
5563            "org_123",
5564        ]);
5565
5566        let Some(Commands::Api(api_cmd)) = cli.command else {
5567            panic!("expected api command");
5568        };
5569
5570        match api_cmd.command {
5571            super::ApiSubCommand::Projects(projects_cmd) => match projects_cmd.command {
5572                super::ApiProjectsSubCommand::List(list_cmd) => {
5573                    assert_eq!(list_cmd.organization_id.as_deref(), Some("org_123"));
5574                }
5575                _ => panic!("expected projects list subcommand"),
5576            },
5577            _ => panic!("expected projects subcommand"),
5578        }
5579    }
5580
5581    #[test]
5582    fn parses_api_routes_create_command() {
5583        let cli = Cli::parse_from([
5584            "xbp",
5585            "api",
5586            "routes",
5587            "create",
5588            "--domain",
5589            "demo.local",
5590            "--target",
5591            "http://127.0.0.1:3000",
5592            "--weighted-target",
5593            "http://127.0.0.1:3001=3",
5594            "--base-url",
5595            "http://127.0.0.1:8080",
5596        ]);
5597
5598        let Some(Commands::Api(api_cmd)) = cli.command else {
5599            panic!("expected api command");
5600        };
5601
5602        match api_cmd.command {
5603            super::ApiSubCommand::Routes(routes_cmd) => match routes_cmd.command {
5604                super::ApiRoutesSubCommand::Create(create_cmd) => {
5605                    assert_eq!(create_cmd.domain, "demo.local");
5606                    assert_eq!(create_cmd.target, vec!["http://127.0.0.1:3000".to_string()]);
5607                    assert_eq!(
5608                        create_cmd.weighted_target,
5609                        vec!["http://127.0.0.1:3001=3".to_string()]
5610                    );
5611                    assert_eq!(
5612                        create_cmd.target_options.base_url.as_deref(),
5613                        Some("http://127.0.0.1:8080")
5614                    );
5615                }
5616                _ => panic!("expected routes create subcommand"),
5617            },
5618            _ => panic!("expected routes subcommand"),
5619        }
5620    }
5621
5622    #[test]
5623    fn parses_hetzner_vswitch_setup_command() {
5624        let cli = Cli::parse_from([
5625            "xbp",
5626            "network",
5627            "hetzner",
5628            "vswitch",
5629            "setup",
5630            "--ip",
5631            "10.0.3.2",
5632            "--vlan-id",
5633            "4000",
5634            "--interface",
5635            "enp0s31f6",
5636            "--apply",
5637        ]);
5638
5639        let Some(Commands::Network(network_cmd)) = cli.command else {
5640            panic!("expected network command");
5641        };
5642
5643        match network_cmd.command {
5644            NetworkSubCommand::Hetzner(hetzner_cmd) => match hetzner_cmd.command {
5645                NetworkHetznerSubCommand::Vswitch(vswitch_cmd) => match vswitch_cmd.command {
5646                    NetworkHetznerVswitchSubCommand::Setup {
5647                        ip,
5648                        cidr,
5649                        interface,
5650                        vlan_id,
5651                        apply,
5652                        ..
5653                    } => {
5654                        assert_eq!(ip, "10.0.3.2");
5655                        assert_eq!(cidr, 24);
5656                        assert_eq!(interface.as_deref(), Some("enp0s31f6"));
5657                        assert_eq!(vlan_id, 4000);
5658                        assert!(apply);
5659                    }
5660                },
5661            },
5662            _ => panic!("expected hetzner subcommand"),
5663        }
5664    }
5665
5666    #[cfg(feature = "secrets")]
5667    #[test]
5668    fn parses_secrets_diag_command() {
5669        let cli = Cli::parse_from(["xbp", "secrets", "diag"]);
5670
5671        match cli.command {
5672            Some(Commands::Secrets(secrets_cmd)) => {
5673                assert!(matches!(secrets_cmd.command, Some(SecretsSubCommand::Diag)));
5674                assert_eq!(secrets_cmd.environment, "xbp-dev");
5675            }
5676            _ => panic!("expected secrets command"),
5677        }
5678    }
5679
5680    #[cfg(feature = "secrets")]
5681    #[test]
5682    fn parses_secrets_environment_override() {
5683        let cli = Cli::parse_from(["xbp", "secrets", "--environment", "xbp-prod", "push"]);
5684
5685        match cli.command {
5686            Some(Commands::Secrets(secrets_cmd)) => {
5687                assert_eq!(secrets_cmd.environment, "xbp-prod");
5688                assert!(matches!(
5689                    secrets_cmd.command,
5690                    Some(SecretsSubCommand::Push(_))
5691                ));
5692            }
5693            _ => panic!("expected secrets command"),
5694        }
5695    }
5696
5697    #[cfg(feature = "secrets")]
5698    #[test]
5699    fn parses_secrets_dev_vars_sync_flags() {
5700        let cli = Cli::parse_from([
5701            "xbp",
5702            "secrets",
5703            "--environment",
5704            "xbp-prod",
5705            "pull",
5706            "--include-dev-vars",
5707            "--dev-vars-output",
5708            ".dev.vars",
5709        ]);
5710
5711        match cli.command {
5712            Some(Commands::Secrets(secrets_cmd)) => {
5713                assert_eq!(secrets_cmd.environment, "xbp-prod");
5714                match secrets_cmd.command {
5715                    Some(SecretsSubCommand::Pull(pull_cmd)) => {
5716                        assert!(pull_cmd.include_dev_vars);
5717                        assert_eq!(pull_cmd.dev_vars_output.as_deref(), Some(".dev.vars"));
5718                    }
5719                    _ => panic!("expected pull command"),
5720                }
5721            }
5722            _ => panic!("expected secrets command"),
5723        }
5724    }
5725
5726    #[test]
5727    fn parses_version_discover_command() {
5728        let cli = Cli::parse_from(["xbp", "version", "discover", "--dry-run"]);
5729
5730        match cli.command {
5731            Some(Commands::Version(version_cmd)) => match version_cmd.command {
5732                Some(super::VersionSubCommand::Discover(discover_cmd)) => {
5733                    assert!(discover_cmd.dry_run);
5734                    assert!(!discover_cmd.no_register);
5735                }
5736                _ => panic!("expected version discover subcommand"),
5737            },
5738            _ => panic!("expected version command"),
5739        }
5740    }
5741
5742    #[cfg(feature = "secrets")]
5743    #[test]
5744    fn parses_secrets_providers_command() {
5745        let cli = Cli::parse_from(["xbp", "secrets", "providers"]);
5746
5747        match cli.command {
5748            Some(Commands::Secrets(secrets_cmd)) => {
5749                assert!(matches!(
5750                    secrets_cmd.command,
5751                    Some(SecretsSubCommand::Providers)
5752                ));
5753                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Github);
5754            }
5755            _ => panic!("expected secrets command"),
5756        }
5757    }
5758
5759    #[cfg(feature = "secrets")]
5760    #[test]
5761    fn parses_cloudflare_secret_store_create() {
5762        let cli = Cli::parse_from([
5763            "xbp",
5764            "secrets",
5765            "--provider",
5766            "cloudflare",
5767            "stores",
5768            "create",
5769            "--name",
5770            "prod",
5771        ]);
5772
5773        match cli.command {
5774            Some(Commands::Secrets(secrets_cmd)) => {
5775                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Cloudflare);
5776                match secrets_cmd.command {
5777                    Some(SecretsSubCommand::Stores(stores_cmd)) => {
5778                        assert!(matches!(
5779                            stores_cmd.command,
5780                            SecretsStoresSubCommand::Create(_)
5781                        ));
5782                    }
5783                    _ => panic!("expected stores subcommand"),
5784                }
5785            }
5786            _ => panic!("expected secrets command"),
5787        }
5788    }
5789
5790    #[cfg(feature = "secrets")]
5791    #[test]
5792    fn parses_cloudflare_secret_duplicate() {
5793        let cli = Cli::parse_from([
5794            "xbp",
5795            "secrets",
5796            "--provider",
5797            "cloudflare",
5798            "secrets",
5799            "duplicate",
5800            "--store-id",
5801            "store_1",
5802            "--secret-id",
5803            "secret_1",
5804            "--name",
5805            "COPY",
5806        ]);
5807
5808        match cli.command {
5809            Some(Commands::Secrets(secrets_cmd)) => match secrets_cmd.command {
5810                Some(SecretsSubCommand::Secrets(secrets_cmd)) => {
5811                    assert!(matches!(
5812                        secrets_cmd.command,
5813                        CloudflareSecretsSubCommand::Duplicate(_)
5814                    ));
5815                }
5816                _ => panic!("expected cloudflare secrets subcommand"),
5817            },
5818            _ => panic!("expected secrets command"),
5819        }
5820    }
5821
5822    #[test]
5823    fn parses_workers_secret_put_from_stdin_command() {
5824        let cli = Cli::parse_from([
5825            "xbp",
5826            "workers",
5827            "secrets",
5828            "--environment",
5829            "production",
5830            "put",
5831            "--name",
5832            "API_KEY",
5833            "--from-stdin",
5834        ]);
5835
5836        let Some(Commands::Workers(workers_cmd)) = cli.command else {
5837            panic!("expected workers command");
5838        };
5839
5840        match workers_cmd.command {
5841            super::WorkersSubCommand::Secrets(secrets_cmd) => {
5842                assert_eq!(
5843                    secrets_cmd.target.environment.as_deref(),
5844                    Some("production")
5845                );
5846                match secrets_cmd.command {
5847                    super::WorkersSecretsSubCommand::Put(put_cmd) => {
5848                        assert_eq!(put_cmd.name, "API_KEY");
5849                        assert!(put_cmd.from_stdin);
5850                        assert_eq!(put_cmd.value, None);
5851                    }
5852                    _ => panic!("expected workers secret put"),
5853                }
5854            }
5855            _ => panic!("expected workers secrets command"),
5856        }
5857    }
5858
5859    #[test]
5860    fn parses_workers_d1_migrations_local_command() {
5861        let cli = Cli::parse_from([
5862            "xbp",
5863            "workers",
5864            "d1",
5865            "migrations",
5866            "apply",
5867            "DB",
5868            "--local",
5869            "--environment",
5870            "preview",
5871        ]);
5872
5873        let Some(Commands::Workers(workers_cmd)) = cli.command else {
5874            panic!("expected workers command");
5875        };
5876
5877        match workers_cmd.command {
5878            super::WorkersSubCommand::D1(d1_cmd) => match d1_cmd.command {
5879                super::WorkersD1SubCommand::Migrations(migrations_cmd) => {
5880                    match migrations_cmd.command {
5881                        super::WorkersD1MigrationsSubCommand::Apply(apply_cmd) => {
5882                            assert_eq!(apply_cmd.database, "DB");
5883                            assert!(apply_cmd.local);
5884                            assert!(!apply_cmd.remote);
5885                            assert_eq!(apply_cmd.target.environment.as_deref(), Some("preview"));
5886                        }
5887                    }
5888                }
5889            },
5890            _ => panic!("expected workers d1 command"),
5891        }
5892    }
5893
5894    #[test]
5895    fn parses_workers_wrangler_passthrough_for_r2_queues_and_secrets_store() {
5896        let cli = Cli::parse_from([
5897            "xbp",
5898            "workers",
5899            "wrangler",
5900            "run",
5901            "--",
5902            "r2",
5903            "bucket",
5904            "cors",
5905            "set",
5906            "r2-bucket",
5907            "--file",
5908            "cors.json",
5909        ]);
5910
5911        let Some(Commands::Workers(workers_cmd)) = cli.command else {
5912            panic!("expected workers command");
5913        };
5914
5915        match workers_cmd.command {
5916            super::WorkersSubCommand::Wrangler(wrangler_cmd) => match wrangler_cmd.command {
5917                super::WorkersWranglerSubCommand::Run(run_cmd) => assert_eq!(
5918                    run_cmd.args,
5919                    [
5920                        "r2",
5921                        "bucket",
5922                        "cors",
5923                        "set",
5924                        "r2-bucket",
5925                        "--file",
5926                        "cors.json"
5927                    ]
5928                ),
5929                _ => panic!("expected Wrangler run command"),
5930            },
5931            _ => panic!("expected workers wrangler command"),
5932        }
5933    }
5934
5935    #[test]
5936    fn parses_workers_deploy_ci_version_upload_command() {
5937        let cli = Cli::parse_from(["xbp", "workers", "deploy", "ci", "--version-upload"]);
5938
5939        let Some(Commands::Workers(workers_cmd)) = cli.command else {
5940            panic!("expected workers command");
5941        };
5942
5943        match workers_cmd.command {
5944            super::WorkersSubCommand::Deploy(deploy_cmd) => match deploy_cmd.command {
5945                super::WorkersDeploySubCommand::Ci(ci_cmd) => {
5946                    assert!(ci_cmd.version_upload);
5947                }
5948                _ => panic!("expected workers deploy ci command"),
5949            },
5950            _ => panic!("expected workers deploy command"),
5951        }
5952    }
5953
5954    #[test]
5955    fn parses_workers_list_alias_command() {
5956        let cli = Cli::parse_from(["xbp", "workers", "ls", "--all"]);
5957
5958        let Some(Commands::Workers(workers_cmd)) = cli.command else {
5959            panic!("expected workers command");
5960        };
5961
5962        match workers_cmd.command {
5963            super::WorkersSubCommand::List(list_cmd) => {
5964                assert!(list_cmd.all);
5965                assert!(!list_cmd.json);
5966            }
5967            _ => panic!("expected workers list command"),
5968        }
5969    }
5970
5971    #[test]
5972    fn parses_workers_logs_follow_and_build_flags() {
5973        let cli = Cli::parse_from([
5974            "xbp",
5975            "workers",
5976            "logs",
5977            "-f",
5978            "--build",
5979            "--failed",
5980            "xbp-production",
5981        ]);
5982
5983        let Some(Commands::Workers(workers_cmd)) = cli.command else {
5984            panic!("expected workers command");
5985        };
5986
5987        match workers_cmd.command {
5988            super::WorkersSubCommand::Logs(logs_cmd) => {
5989                assert!(logs_cmd.follow);
5990                assert!(logs_cmd.build);
5991                assert!(logs_cmd.failed);
5992                assert_eq!(logs_cmd.script_name.as_deref(), Some("xbp-production"));
5993            }
5994            _ => panic!("expected workers logs command"),
5995        }
5996    }
5997
5998    #[test]
5999    fn parses_workers_logs_worker_and_wait_flags() {
6000        let cli = Cli::parse_from([
6001            "xbp",
6002            "workers",
6003            "logs",
6004            "--worker",
6005            "suits-formations",
6006            "--json",
6007            "--wait",
6008            "--wait-seconds",
6009            "60",
6010        ]);
6011
6012        let Some(Commands::Workers(workers_cmd)) = cli.command else {
6013            panic!("expected workers command");
6014        };
6015
6016        match workers_cmd.command {
6017            super::WorkersSubCommand::Logs(logs_cmd) => {
6018                assert_eq!(logs_cmd.target.worker.as_deref(), Some("suits-formations"));
6019                assert!(logs_cmd.json);
6020                assert!(logs_cmd.wait);
6021                assert_eq!(logs_cmd.wait_seconds, 60);
6022            }
6023            _ => panic!("expected workers logs command"),
6024        }
6025    }
6026
6027    #[test]
6028    fn parses_workers_logs_output_and_filter_flags() {
6029        let cli = Cli::parse_from([
6030            "xbp",
6031            "workers",
6032            "logs",
6033            "--build",
6034            "-n",
6035            "200",
6036            "-o",
6037            "build.log",
6038            "-g",
6039            "error",
6040            "--errors-only",
6041            "--list-builds",
6042            "--build-index",
6043            "1",
6044        ]);
6045
6046        let Some(Commands::Workers(workers_cmd)) = cli.command else {
6047            panic!("expected workers command");
6048        };
6049
6050        match workers_cmd.command {
6051            super::WorkersSubCommand::Logs(logs_cmd) => {
6052                assert!(logs_cmd.build);
6053                assert_eq!(logs_cmd.lines, Some(200));
6054                assert_eq!(
6055                    logs_cmd.output.as_deref().and_then(|path| path.to_str()),
6056                    Some("build.log")
6057                );
6058                assert_eq!(logs_cmd.grep.as_deref(), Some("error"));
6059                assert!(logs_cmd.errors_only);
6060                assert!(logs_cmd.list_builds);
6061                assert_eq!(logs_cmd.build_index, Some(1));
6062            }
6063            _ => panic!("expected workers logs command"),
6064        }
6065    }
6066
6067    #[test]
6068    fn parses_workers_list_filter_and_limit_flags() {
6069        let cli = Cli::parse_from([
6070            "xbp", "workers", "list", "--failed", "-n", "5", "--sort", "modified",
6071        ]);
6072
6073        let Some(Commands::Workers(workers_cmd)) = cli.command else {
6074            panic!("expected workers command");
6075        };
6076
6077        match workers_cmd.command {
6078            super::WorkersSubCommand::List(list_cmd) => {
6079                assert!(list_cmd.failed);
6080                assert_eq!(list_cmd.limit, Some(5));
6081                assert_eq!(list_cmd.sort, "modified");
6082            }
6083            _ => panic!("expected workers list command"),
6084        }
6085    }
6086
6087    #[test]
6088    fn parses_worker_alias_command() {
6089        let cli = Cli::parse_from(["xbp", "worker", "env", "--show-values"]);
6090
6091        let Some(Commands::Workers(workers_cmd)) = cli.command else {
6092            panic!("expected workers command through alias");
6093        };
6094
6095        match workers_cmd.command {
6096            super::WorkersSubCommand::Env(env_cmd) => {
6097                assert!(env_cmd.show_values);
6098            }
6099            _ => panic!("expected workers env command"),
6100        }
6101    }
6102
6103    #[test]
6104    fn parses_dns_providers_command() {
6105        let cli = Cli::parse_from(["xbp", "dns", "providers"]);
6106
6107        match cli.command {
6108            Some(Commands::Dns(dns_cmd)) => {
6109                assert!(matches!(dns_cmd.command, DnsSubCommand::Providers));
6110            }
6111            _ => panic!("expected dns command"),
6112        }
6113    }
6114
6115    #[test]
6116    fn dns_zone_list_defaults_provider_to_cloudflare() {
6117        let cli = Cli::parse_from(["xbp", "dns", "zones", "list"]);
6118
6119        let Some(Commands::Dns(dns_cmd)) = cli.command else {
6120            panic!("expected dns command");
6121        };
6122
6123        match dns_cmd.command {
6124            DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
6125                DnsZonesSubCommand::List(list_cmd) => {
6126                    assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
6127                }
6128                _ => panic!("expected zones list command"),
6129            },
6130            _ => panic!("expected zones command"),
6131        }
6132    }
6133
6134    #[test]
6135    fn dns_record_list_defaults_provider_to_cloudflare() {
6136        let cli = Cli::parse_from(["xbp", "dns", "records", "list", "--zone-id", "zone_123"]);
6137
6138        let Some(Commands::Dns(dns_cmd)) = cli.command else {
6139            panic!("expected dns command");
6140        };
6141
6142        match dns_cmd.command {
6143            DnsSubCommand::Records(records_cmd) => match records_cmd.command {
6144                super::DnsRecordsSubCommand::List(list_cmd) => {
6145                    assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
6146                    assert_eq!(list_cmd.zone_id, "zone_123");
6147                }
6148                _ => panic!("expected records list command"),
6149            },
6150            _ => panic!("expected records command"),
6151        }
6152    }
6153
6154    #[test]
6155    fn dns_help_includes_descriptions_and_examples() {
6156        let err = Cli::try_parse_from(["xbp", "dns", "-h"]).expect_err("help");
6157        let rendered = err.to_string();
6158
6159        assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
6160        assert!(rendered.contains("Manage DNS providers, zones, records, DNSSEC, and settings"));
6161        assert!(rendered.contains("List supported DNS providers and current implementation status"));
6162        assert!(rendered.contains("xbp dns records create"));
6163    }
6164
6165    #[test]
6166    fn dns_providers_help_includes_discovery_note() {
6167        let err = Cli::try_parse_from(["xbp", "dns", "providers", "-h"]).expect_err("help");
6168        let rendered = err.to_string();
6169
6170        assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
6171        assert!(rendered.contains("Implemented providers are wired into `xbp dns` today."));
6172    }
6173
6174    #[test]
6175    fn dns_records_without_subcommand_displays_help_screen() {
6176        let err = Cli::try_parse_from(["xbp", "dns", "records"]).expect_err("missing subcommand");
6177        let rendered = err.to_string();
6178
6179        assert!(matches!(
6180            err.kind(),
6181            clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
6182                | clap::error::ErrorKind::MissingSubcommand
6183        ));
6184        assert!(rendered.contains("List, create, edit, import, export, and batch DNS records"));
6185        assert!(rendered.contains("Create a new DNS record"));
6186        assert!(rendered.contains("xbp dns records import"));
6187    }
6188
6189    #[test]
6190    fn parses_dns_zone_list_command() {
6191        let cli = Cli::parse_from([
6192            "xbp",
6193            "dns",
6194            "zones",
6195            "list",
6196            "--provider",
6197            "cloudflare",
6198            "--account-name-op",
6199            "contains",
6200            "--type",
6201            "full,partial",
6202        ]);
6203
6204        match cli.command {
6205            Some(Commands::Dns(dns_cmd)) => match dns_cmd.command {
6206                DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
6207                    DnsZonesSubCommand::List(list_cmd) => {
6208                        assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
6209                        assert_eq!(list_cmd.account_name_op.as_deref(), Some("contains"));
6210                        assert_eq!(list_cmd.zone_types, vec!["full", "partial"]);
6211                    }
6212                    _ => panic!("expected dns zones list"),
6213                },
6214                _ => panic!("expected dns zones"),
6215            },
6216            _ => panic!("expected dns command"),
6217        }
6218    }
6219
6220    #[test]
6221    fn parses_domains_search_command() {
6222        let cli = Cli::parse_from([
6223            "xbp",
6224            "domains",
6225            "search",
6226            "--query",
6227            "xbp",
6228            "--extension",
6229            "com",
6230        ]);
6231
6232        match cli.command {
6233            Some(Commands::Domains(domains_cmd)) => {
6234                assert_eq!(domains_cmd.provider, DomainsProviderKind::Cloudflare);
6235                assert!(matches!(domains_cmd.command, DomainsSubCommand::Search(_)));
6236            }
6237            _ => panic!("expected domains command"),
6238        }
6239    }
6240
6241    #[test]
6242    fn parses_cloudflare_config_account_id_command() {
6243        let cli = Cli::parse_from(["xbp", "config", "cloudflare", "set-account-id", "acc_123"]);
6244
6245        match cli.command {
6246            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
6247                Some(super::ConfigProviderCmd::Cloudflare(cloudflare_cmd)) => {
6248                    assert!(matches!(
6249                        cloudflare_cmd.action,
6250                        Some(CloudflareConfigAction::SetAccountId { .. })
6251                    ));
6252                }
6253                _ => panic!("expected cloudflare config provider"),
6254            },
6255            _ => panic!("expected config command"),
6256        }
6257    }
6258
6259    #[test]
6260    fn parses_runners_groups_update_command() {
6261        let cli = Cli::parse_from([
6262            "xbp",
6263            "runners",
6264            "groups",
6265            "update",
6266            "--organization-id",
6267            "org_123",
6268            "--name",
6269            "linux-builders",
6270            "--visibility",
6271            "selected",
6272            "--restricted-to-workflows",
6273        ]);
6274
6275        let Some(Commands::Runners(runners_cmd)) = cli.command else {
6276            panic!("expected runners command");
6277        };
6278
6279        match runners_cmd.command {
6280            super::RunnersSubCommand::Groups(groups_cmd) => match groups_cmd.command {
6281                super::RunnersGroupsSubCommand::Update(update_cmd) => {
6282                    assert_eq!(update_cmd.organization_id, "org_123");
6283                    assert_eq!(update_cmd.name, "linux-builders");
6284                    assert_eq!(update_cmd.visibility.as_deref(), Some("selected"));
6285                    assert!(update_cmd.restricted_to_workflows);
6286                }
6287                _ => panic!("expected runners groups update command"),
6288            },
6289            _ => panic!("expected runners groups command"),
6290        }
6291    }
6292
6293    #[test]
6294    fn parses_api_runners_group_repositories_create_command() {
6295        let cli = Cli::parse_from([
6296            "xbp",
6297            "api",
6298            "runners",
6299            "group-repositories-create",
6300            "group_123",
6301            "--repository-owner",
6302            "xylex-group",
6303            "--repository-name",
6304            "xbp",
6305            "--repository-full-name",
6306            "xylex-group/xbp",
6307            "--is-private",
6308        ]);
6309
6310        let Some(Commands::Api(api_cmd)) = cli.command else {
6311            panic!("expected api command");
6312        };
6313
6314        match api_cmd.command {
6315            super::ApiSubCommand::Runners(runners_cmd) => match runners_cmd.command {
6316                super::ApiRunnersSubCommand::GroupRepositoriesCreate(create_cmd) => {
6317                    assert_eq!(create_cmd.runner_group_id, "group_123");
6318                    assert_eq!(create_cmd.repository_owner, "xylex-group");
6319                    assert_eq!(create_cmd.repository_name, "xbp");
6320                    assert_eq!(create_cmd.repository_full_name, "xylex-group/xbp");
6321                    assert!(create_cmd.is_private);
6322                }
6323                _ => panic!("expected api runners group repositories create command"),
6324            },
6325            _ => panic!("expected api runners command"),
6326        }
6327    }
6328
6329    #[test]
6330    fn parses_runners_host_preflight_command() {
6331        let cli = Cli::parse_from([
6332            "xbp",
6333            "runners",
6334            "hosts",
6335            "preflight",
6336            "--runner-host-id",
6337            "host_123",
6338            "--write-api",
6339        ]);
6340
6341        let Some(Commands::Runners(runners_cmd)) = cli.command else {
6342            panic!("expected runners command");
6343        };
6344
6345        match runners_cmd.command {
6346            super::RunnersSubCommand::Hosts(hosts_cmd) => match hosts_cmd.command {
6347                super::RunnersHostsSubCommand::Preflight(preflight_cmd) => {
6348                    assert_eq!(preflight_cmd.runner_host_id, "host_123");
6349                    assert!(preflight_cmd.write_api);
6350                }
6351                _ => panic!("expected runners hosts preflight command"),
6352            },
6353            _ => panic!("expected runners hosts command"),
6354        }
6355    }
6356
6357    #[test]
6358    fn parses_runners_agent_serve_command() {
6359        let cli = Cli::parse_from([
6360            "xbp",
6361            "runners",
6362            "agent",
6363            "serve",
6364            "--runner-host-id",
6365            "host_123",
6366            "--interval-seconds",
6367            "5",
6368            "--once",
6369        ]);
6370
6371        let Some(Commands::Runners(runners_cmd)) = cli.command else {
6372            panic!("expected runners command");
6373        };
6374
6375        match runners_cmd.command {
6376            super::RunnersSubCommand::Agent(agent_cmd) => match agent_cmd.command {
6377                super::RunnersAgentSubCommand::Serve(serve_cmd) => {
6378                    assert_eq!(serve_cmd.runner_host_id, "host_123");
6379                    assert_eq!(serve_cmd.interval_seconds, 5);
6380                    assert!(serve_cmd.once);
6381                }
6382            },
6383            _ => panic!("expected runners agent command"),
6384        }
6385    }
6386
6387    #[test]
6388    fn parses_api_runners_host_preflight_upsert_command() {
6389        let cli = Cli::parse_from([
6390            "xbp",
6391            "api",
6392            "runners",
6393            "hosts-preflights-upsert",
6394            "--runner-host-id",
6395            "host_123",
6396            "--platform",
6397            "linux",
6398            "--status",
6399            "synced",
6400            "--service-manager",
6401            "systemd",
6402        ]);
6403
6404        let Some(Commands::Api(api_cmd)) = cli.command else {
6405            panic!("expected api command");
6406        };
6407
6408        match api_cmd.command {
6409            super::ApiSubCommand::Runners(runners_cmd) => match runners_cmd.command {
6410                super::ApiRunnersSubCommand::HostsPreflightsUpsert(upsert_cmd) => {
6411                    assert_eq!(upsert_cmd.runner_host_id, "host_123");
6412                    assert_eq!(upsert_cmd.platform, "linux");
6413                    assert_eq!(upsert_cmd.status, "synced");
6414                    assert_eq!(upsert_cmd.service_manager.as_deref(), Some("systemd"));
6415                }
6416                _ => panic!("expected api runners host preflight upsert command"),
6417            },
6418            _ => panic!("expected api runners command"),
6419        }
6420    }
6421}