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