Skip to main content

xbp_cli/cli/
commands.rs

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