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