Skip to main content

xbp_cli/cli/
commands.rs

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