Skip to main content

xbp_cli/cli/
commands.rs

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