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