Skip to main content

xbp_cli/cli/
commands.rs

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