Skip to main content

gunnar_common/
cli.rs

1//! The `gunnar` command line, in clap derive.
2//!
3//! Both halves live in one enum with `#[cfg]` on the variants, so
4//! `--no-default-features --features server` produces a binary whose `--help`
5//! genuinely has no `clone` in it, rather than one that offers a verb and then
6//! fails at runtime.
7
8use std::path::PathBuf;
9
10#[cfg(feature = "server")]
11use clap::ArgGroup;
12#[cfg(any(feature = "server", feature = "control"))]
13use clap::ValueEnum;
14use clap::{Parser, Subcommand};
15
16/// gunnar — a git server and a git client, in Rust, over SSH and smart HTTP.
17#[derive(Debug, Parser)]
18#[command(
19    name = "gunnar",
20    // Not the bare crate version. `gunnar 0.1.0` names a manifest field, not a
21    // build — and on 2026-08-04/05 several benchmark runs had to identify the
22    // binary under test by file size and mtime because nothing on it said which
23    // commit it was; one silently measured a ten-hour-old build. The git
24    // description and build date come from `build.rs`, which degrades to
25    // `unknown` rather than failing when there is no `.git`.
26    version = concat!(
27        env!("CARGO_PKG_VERSION"),
28        " (",
29        env!("GUNNAR_BUILD_REF"),
30        ", built ",
31        env!("GUNNAR_BUILD_DATE"),
32        ")"
33    ),
34    about = "a git server and a git client, in Rust, over SSH and smart HTTP",
35    long_about = "gunnar serves git over SSH (publickey auth, no shell) and, when built with \
36                  `--features http` and started with `--http-bind`, over smart HTTP. It \
37                  controls itself over gRPC. The client half is a normal git client.\n\n\
38                  Two git transports, one implementation: smart HTTP carries the same \
39                  pkt-line conversation SSH carries, through the same authorisation check \
40                  against the same key table and the same ACL, and an access token is the \
41                  HTTP credential where a public key is the SSH one. What is refused is \
42                  refused on both.\n\n\
43                  What gunnar does NOT serve: git:// ever, being unauthenticated by \
44                  construction; the dumb HTTP protocol, which is refused by name; and no \
45                  git or ssh subprocess anywhere in the server, on any transport."
46)]
47pub struct Cli {
48    /// Log at `debug` rather than `info`. `RUST_LOG` still wins.
49    #[arg(long, short = 'v', global = true)]
50    pub verbose: bool,
51
52    /// Where the server keeps its control repository and its repositories.
53    #[cfg(feature = "server")]
54    #[arg(
55        long,
56        global = true,
57        env = "GUNNAR_DATA_ROOT",
58        default_value_os_t = default_data_root(),
59        value_name = "DIR"
60    )]
61    pub data_root: PathBuf,
62
63    #[command(subcommand)]
64    pub command: Command,
65}
66
67/// `$GUNNAR_DATA_ROOT`, else `$HOME/.local/share/gunnar`, else `./gunnar-data`.
68#[cfg(feature = "server")]
69pub fn default_data_root() -> PathBuf {
70    match std::env::var_os("HOME") {
71        Some(home) if !home.is_empty() => PathBuf::from(home).join(".local/share/gunnar"),
72        _ => PathBuf::from("gunnar-data"),
73    }
74}
75
76#[derive(Debug, Subcommand)]
77pub enum Command {
78    // ── server ─────────────────────────────────────────────────────────────
79    /// Start the listeners: SSH for git, gRPC for the control plane, and smart
80    /// HTTP for git as well when `--http-bind` asks for it.
81    // Boxed: `ServeArgs` is several times the size of every other verb.
82    #[cfg(feature = "server")]
83    Serve(Box<ServeArgs>),
84
85    /// Namespaces: the `<ns>` in `git@host:<ns>/<repo>.git`.
86    #[cfg(feature = "server")]
87    #[command(subcommand)]
88    Namespace(NamespaceCommand),
89
90    /// Repositories.
91    #[cfg(feature = "server")]
92    #[command(subcommand)]
93    Repo(RepoCommand),
94
95    /// Principals. Keys and grants hang off these.
96    #[cfg(feature = "server")]
97    #[command(subcommand)]
98    Principal(PrincipalCommand),
99
100    /// **The server's own certificate issuer** — identity from a certificate.
101    ///
102    /// With one of these, creating a user *is* issuing their certificate:
103    /// there is no external PKI to run and no second system to keep in step.
104    ///
105    /// Present in a CONTROL-only build too, where it carries exactly one verb:
106    /// `issuer show --at <addr>`. That is not a convenience. `Pki::read_only`
107    /// exists so this read "works on a client-shaped binary", and the box
108    /// whose CA anyone actually needs to check is the appliance — which has no
109    /// shell, so the asking is always done from somewhere else.
110    #[cfg(any(feature = "server", feature = "control"))]
111    #[command(subcommand)]
112    Issuer(IssuerCommand),
113
114    /// Public keys.
115    #[cfg(feature = "server")]
116    #[command(subcommand)]
117    Key(KeyCommand),
118
119    /// Mint and revoke the access tokens smart HTTP authenticates with.
120    #[cfg(feature = "server")]
121    #[command(subcommand)]
122    Token(TokenCommand),
123
124    /// **The transparency log** over the push attestations.
125    ///
126    /// The attestation log is append-only by discipline; these verbs are what
127    /// make it append-only by evidence. `head` signs what the log currently
128    /// holds, `audit` checks every signed head against the records on disk, and
129    /// `prove` hands out the inclusion proof a client checks offline.
130    #[cfg(feature = "server")]
131    #[command(subcommand)]
132    Attest(AttestCommand),
133
134    /// **Walk the chain backwards from a line of source.**
135    ///
136    /// Which commit wrote the line, whether that commit is signed by a key this
137    /// server knows, which push landed it, and whether that push sits in the
138    /// transparency log under a signed tree head.
139    ///
140    /// Every link is reported as proven, broken or **unproven**, and the three
141    /// are different: an unsigned commit is not a failed signature, and a push
142    /// with no attestation is not a push that was refused. A chain with an
143    /// unproven link is INCOMPLETE, never green.
144    #[cfg(feature = "server")]
145    Verify(VerifyArgs),
146
147    /// **Who wrote each line of a file** (`verify --line` for the whole file).
148    ///
149    /// For every line of `<path>` at a revision, the commit that last touched
150    /// it and that commit's author — the same line→commit attribution `verify`
151    /// closes as link 1, done once per line instead of once for a single line.
152    #[cfg(feature = "server")]
153    Blame(BlameArgs),
154
155    /// **Find a literal string** across the latest revision of a repository.
156    ///
157    /// Walks the tree at a ref and scans each text file, returning `path:line`
158    /// matches. Bounded by a file-size and a match ceiling; it is a live walk,
159    /// not a persistent index.
160    #[cfg(feature = "server")]
161    Search(SearchArgs),
162
163    /// **Build provenance**: record what was built and from what (roadmap
164    /// phase 3, links 5–6).
165    #[cfg(feature = "server")]
166    #[command(subcommand)]
167    Build(BuildCommand),
168
169    /// **The forge**: issues, reviews and merges as signed events (roadmap
170    /// phase 1).
171    #[cfg(feature = "server")]
172    #[command(subcommand)]
173    Forge(ForgeCommand),
174
175    /// **The artifact registry** (roadmap phase 4).
176    #[cfg(feature = "server")]
177    #[command(subcommand)]
178    Registry(RegistryCommand),
179
180    /// Give a principal access.
181    #[cfg(feature = "server")]
182    Grant(GrantArgs),
183
184    /// Take a principal's access away.
185    #[cfg(feature = "server")]
186    Revoke(RevokeArgs),
187
188    /// Repack a repository's objects.
189    #[cfg(feature = "server")]
190    Gc(GcArgs),
191
192    /// Read a repository's reference history, and optionally seal it.
193    #[cfg(feature = "server")]
194    Reflog(ReflogArgs),
195
196    /// WireGuard identity: mint the keypair an appliance is seeded with.
197    #[cfg(feature = "server")]
198    #[command(subcommand)]
199    Wg(WgCommand),
200
201    // ── client ─────────────────────────────────────────────────────────────
202    /// Clone a repository over SSH into a working tree.
203    #[cfg(feature = "client")]
204    Clone(CloneArgs),
205
206    /// Fetch from an SSH remote into an existing repository.
207    #[cfg(feature = "client")]
208    Fetch(FetchArgs),
209
210    /// List the references an SSH remote advertises, without cloning it.
211    ///
212    /// The cheapest way to ask whether a remote is reachable, whether the key
213    /// authenticates, and whether the repository holds what you expect. Prints
214    /// `git ls-remote`'s lines — `<oid>\t<ref>`, `HEAD` first, an annotated tag
215    /// followed by its peeled `^{}` line — because scripts parse them.
216    #[cfg(feature = "client")]
217    #[command(name = "ls-remote")]
218    LsRemote(LsRemoteArgs),
219
220    /// Push to an SSH remote (send-pack, implemented here; gix has none).
221    #[cfg(feature = "client")]
222    Push(PushArgs),
223
224    /// Pull-mirror a remote repository.
225    #[cfg(feature = "client")]
226    #[command(subcommand)]
227    Mirror(MirrorCommand),
228
229    /// Encrypted repositories: the server holds ciphertext only, and only the
230    /// members' SSH keys open it.
231    ///
232    /// Each repository has one key ring, wrapped to every member's SSH PUBLIC
233    /// key (`ssh-ed25519`, `ssh-rsa`) in `.gunnar-crypt/keys`. git's clean and
234    /// smudge filter seal and open file content on this machine; the server
235    /// never sees a key. File names, commit messages, sizes and the shape of
236    /// history stay visible (Level 1). See `.nornir/encrypted-repos.md`.
237    #[cfg(feature = "client")]
238    #[command(subcommand)]
239    Crypt(CryptCommand),
240
241    /// **A repository's settings on a forge** (owner, 2026-10-14): show it,
242    /// make it public or private, or change its default branch — over the
243    /// forge's SSH door, signed in with your SSH key, the way git and `crypt`
244    /// reach an installed Gunnar. The server decides who may (its own rules:
245    /// the account admin for visibility, a repository admin for the branch).
246    ///
247    /// `gunnar settings nordisk/knut`,
248    /// `gunnar settings nordisk/knut --visibility public`,
249    /// `gunnar settings nordisk/knut --default-branch main`.
250    #[cfg(feature = "client")]
251    Settings(SettingsArgs),
252
253    /// **Create an empty repository on a forge** in the object format you
254    /// choose (sha256 unless `--sha1`), over the forge's SSH door: a repository
255    /// made by a first push can only be sha1. `gunnar push` does this by itself
256    /// for a sha256 repository whose remote does not exist yet.
257    #[cfg(feature = "client")]
258    Create(CreateArgs),
259
260    /// Show the commit log.
261    #[cfg(feature = "client")]
262    Log(LogArgs),
263
264    /// Show the working tree status.
265    #[cfg(feature = "client")]
266    Status(RepoArgs),
267
268    /// Commit everything in the working tree.
269    #[cfg(feature = "client")]
270    Commit(CommitArgs),
271
272    /// Branches.
273    #[cfg(feature = "client")]
274    Branch(BranchArgs),
275
276    /// Tags.
277    #[cfg(feature = "client")]
278    Tag(TagArgs),
279
280    // ── the control plane, over the wire ───────────────────────────────────
281    /// Configure a RUNNING gunnar over its gRPC control plane.
282    ///
283    /// The first call needs no credential at all; every one after it is
284    /// authenticated with an ssh key. `gunnar control status` says which of
285    /// those a server is in.
286    ///
287    /// These are the ONLINE verbs. `gunnar principal`, `gunnar key` and
288    /// `gunnar grant` write the control repository directly and therefore need
289    /// the server to be DOWN; these do the same work against one that is up.
290    #[cfg(feature = "control")]
291    #[command(subcommand)]
292    Control(ControlCommand),
293
294    /// **Log this terminal in with your passkey, through a console.**
295    ///
296    /// Mints a fresh ed25519 key here, shows a code, and waits: open the
297    /// console's `/login/device` page, type the code and touch your passkey.
298    /// Gunnar enrols THIS key on you, expiring (at most a day); its private
299    /// half never leaves this machine. Use it with `-i <path>` (printed).
300    /// The other CLI way in is a FIDO2 `sk-ssh-ed25519` key in ssh-agent.
301    #[cfg(feature = "control")]
302    Login(LoginArgs),
303
304    // ── systemd ────────────────────────────────────────────────────────────
305    /// Manage gunnar as a systemd service. Off unless built with `--features systemd`.
306    #[cfg(feature = "systemd")]
307    #[command(subcommand)]
308    Service(ServiceCommand),
309}
310
311/// `gunnar login`, see [`Command::Login`].
312#[cfg(feature = "control")]
313#[derive(Debug, clap::Args)]
314pub struct LoginArgs {
315    /// The console's address, e.g. `https://console.example.com`.
316    pub console: String,
317    /// How long the key lives, in hours (the console allows at most 24).
318    #[arg(long, default_value_t = 12)]
319    pub ttl: u64,
320    /// What the approval page calls this terminal (default: `user@host`).
321    #[arg(long)]
322    pub label: Option<String>,
323    /// Where the key is written (default: `~/.config/gunnar/login`).
324    #[arg(long)]
325    pub dir: Option<PathBuf>,
326}
327
328// ───────────────────────────────────────────────────────────────────────────
329// server
330// ───────────────────────────────────────────────────────────────────────────
331
332/// `--anonymous`, see [`ServeArgs::anonymous`].
333#[cfg(feature = "server")]
334#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
335pub enum AnonymousArg {
336    /// Serve anonymous reads of public repositories on this box.
337    Allow,
338    /// Refuse them, naming `--browse-host` when given.
339    Refuse,
340}
341
342/// `gunnar serve --role`.
343#[cfg(feature = "server")]
344#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
345pub enum ServeRole {
346    /// Serves git; frames every transaction for its twin.
347    Primary,
348    /// Serves no git; holds the primary's log.
349    Twin,
350}
351
352/// `gunnar serve`.
353#[cfg(feature = "server")]
354#[derive(Debug, clap::Args, Clone)]
355pub struct ServeArgs {
356    /// Where the SSH listener binds. Publickey auth, so binding publicly is the
357    /// intended configuration.
358    #[arg(long, default_value = "0.0.0.0:2222", value_name = "ADDR")]
359    pub ssh_bind: std::net::SocketAddr,
360
361    /// Where the gRPC control plane binds — `<addr>:<port>`, or
362    /// `<interface>:<port>` for an address learned at boot.
363    ///
364    /// **Loopback by default and it should stay there.** gRPC now requires an
365    /// `sshsig` credential from a key in gunnar's identity table and a grant from
366    /// the same ACL as SSH (the allow-all hole was closed 2026-08-03), but the
367    /// loopback default is defence in depth and costs nothing: every real
368    /// deployment reaches this port over an SSH tunnel or from the same host
369    /// anyway.
370    ///
371    /// **`0.0.0.0` is not the way to reach it from another host.** A cloud
372    /// appliance has a PUBLIC interface and a private one, and a wildcard
373    /// binds both — which is how this control plane ended up answering
374    /// `repo list` and `repo create` to an uncredentialled caller on the open
375    /// internet (measured 2026-08-31). Name the private interface instead:
376    /// `--grpc-bind eth1:50051`. Its address is not knowable when the medium
377    /// is mastered — DHCP decides it, per box, per lease — and the interface
378    /// is. See [`gunnar_common::bindspec::BindSpec`].
379    #[arg(long, default_value = "127.0.0.1:50051", value_name = "ADDR|IFACE:PORT")]
380    pub grpc_bind: crate::bindspec::BindSpec,
381
382    /// An OpenSSH public key line, or `@<path>` to read one from a file, to
383    /// install as the bootstrap admin when the identity table is empty.
384    ///
385    /// Without it, an empty table makes gunnar **refuse to serve** rather than
386    /// start unreachable.
387    #[arg(long, value_name = "KEY|@FILE")]
388    pub admin_key: Option<String>,
389
390    /// **A key this box may take a `Bootstrap.Restore` from**: an OpenSSH
391    /// public key line, or `@<path>` to read one. Repeat the flag for more;
392    /// `GUNNAR_RESTORE_PUBKEY` takes several separated by `,`.
393    ///
394    /// A restore stream decides a fresh box's KEK, its admin, its host key and
395    /// its issuer, and a fresh box has no identity table to check its sender
396    /// against, so it takes one only under a credential signed by a key it was
397    /// FED: this flag, or — on an installer-fed box — the fed `admin_pubkey`
398    /// and admin principals. A box with neither refuses every restore by name
399    /// (`restore-no-key`).
400    #[arg(long = "restore-pubkey", value_name = "KEY|@FILE", env = "GUNNAR_RESTORE_PUBKEY", value_delimiter = ',')]
401    pub restore_pubkeys: Vec<String>,
402
403    /// Do not start the gRPC listener. The SSH listener still runs.
404    #[arg(long)]
405    pub no_grpc: bool,
406
407    /// **WireGuard-only** (owner, 2026-10-04): every box-to-box connection
408    /// runs over the estate's overlay. The control plane's bound address (the
409    /// utility network, `eth1:50051`) serves nothing; the control plane and the
410    /// replication listener answer only on the tunnel's address, and SSH drops
411    /// a connection that arrives at the utility address. A plain-IP connection
412    /// between two boxes is refused.
413    #[arg(long = "wg-only", env = "GUNNAR_WG_ONLY")]
414    pub wg_only: bool,
415
416    /// **What this box is.** `primary` (the default) serves git and frames
417    /// every transaction. `twin` serves NO git from its first boot (the forge
418    /// listener is bound to loopback only), waits for `Bootstrap.BecomeTwin`,
419    /// and then follows its primary's log (`.nornir/twin-transaction-log.md`).
420    /// **A twin is not a different medium.** Until 2026-09-17 a `gunnar-twin`
421    /// ISO welded `--role twin` into its `korp.args=`, which made the role part
422    /// of a medium's identity and — because that medium was cloned from the
423    /// KEYLESS row — made gunnar.rs's twin an UNMETERED binary that would have
424    /// served every suspended tenant the moment it was promoted. Since 2026-09-22
425    /// the medium does not say what a box is at all (no `role` install parameter,
426    /// no `GUNNAR_ROLE` env read): every box installs as a fresh appliance and a
427    /// twin is made one over the wire by `Bootstrap.BecomeTwin`, which writes
428    /// `twin.json` — the durable role `stores.is_twin()` reads. This argv flag
429    /// stays for a directly started server (tests, benches); an installed box
430    /// never passes it.
431    #[arg(long, value_enum, default_value_t = ServeRole::Primary)]
432    pub role: ServeRole,
433
434    /// Refuse unauthenticated callers entirely, even for repositories marked
435    /// `--public`.
436    ///
437    /// By default a public repository can be cloned with no key at all, over
438    /// `ssh://anonymous@host/ns/repo.git`. This closes that door at the
439    /// listener, before any repository is resolved, for an appliance that has
440    /// to be able to state it has never served a stranger. It is **not** what
441    /// protects private repositories: those are protected by the ACL and are
442    /// unreachable anonymously either way.
443    #[arg(long)]
444    pub no_anonymous: bool,
445
446    /// **Where anonymous reads are served** — the browse replica. `allow`
447    /// (the default) serves a stranger's clone of a public repository here.
448    /// `refuse` sends every anonymous request away with a line that names the
449    /// browse replica (`--browse-host`) it is served by: strangers cost CPU and
450    /// bandwidth on the box that also takes the authenticated writes, and the
451    /// browse replica is where that cost belongs. Unlike `--no-anonymous`, which
452    /// merely fails the stranger's authentication, `refuse` tells them where
453    /// to go. A box whose role is BROWSE serves anonymous reads whatever this
454    /// says — it is the box the knob points at.
455    #[arg(long, value_enum, default_value_t = AnonymousArg::Allow, env = "GUNNAR_ANONYMOUS")]
456    pub anonymous: AnonymousArg,
457
458    /// The browse replica's `host[:port]`, named in the `--anonymous refuse`
459    /// line (`clone <repo> from <host>`). Optional: without it the refusal
460    /// says only that anonymous reads are not served here.
461    #[arg(long, value_name = "HOST[:PORT]", env = "GUNNAR_BROWSE_HOST")]
462    pub browse_host: Option<String>,
463
464    /// **How much of the store root's disk must stay free** before a push or
465    /// a `Repos.Create` is refused — the community plan gate, the "free lunch"
466    /// (ruled 2026-09-04). Terms separated by commas: a percentage (`5%`)
467    /// and/or a byte count (`2GiB`, `500M`, `4096`); the LARGER of the two is
468    /// the floor. The default keeps 5 % or 2 GiB, whichever is more: 5 % is the
469    /// reserve the box needs to keep operating (gc scratch, control-store
470    /// commits, `replication.json` rewrites) and mirrors ext4's own reserved
471    /// blocks; 2 GiB is the absolute floor for a small disk where 5 % is less
472    /// than one pack. A push under the floor is refused BEFORE any pack byte,
473    /// naming the free bytes and the floor; reads are never refused. Measured
474    /// with `statvfs`, no `df`. `0%,0` is refused by name: a gate that can never
475    /// refuse is not a gate.
476    #[arg(long, value_name = "SPEC", default_value = "5%,2GiB", env = "GUNNAR_DISK_FLOOR")]
477    pub disk_floor: String,
478    /// **Do not enumerate a push's object ids for the attestation.**
479    ///
480    /// Enumeration waits for the indexer to absorb the pack, which is a drain
481    /// on the push path, so an operator who does not need the ids in the record
482    /// can decline them. Declining does NOT restore silence: the attestation
483    /// then carries the reason the set is absent
484    /// (`PushAttestation.new_object_oids_unknown`), because an absence that
485    /// renders as an empty list is the defect this flag exists beside, not the
486    /// behaviour it restores.
487    ///
488    /// Named in the negative on purpose: the complete attestation is what you
489    /// get by forgetting, and the cheap one is what you opt into. A push that a
490    /// policy gate reads is enumerated either way — the gate needs the set.
491    #[arg(long)]
492    pub no_attest_objects: bool,
493
494    /// How many git transfers may be in flight across the whole server (the
495    /// admission gate). Default: DERIVED from the box — cores x 16, capped by
496    /// the descriptors the handle cache has not spoken for, floor 8, ceiling
497    /// 256.
498    ///
499    /// The server says what it derived, and what from, in one INFO row at
500    /// start: `transfers 64 · open repos 204 · git memory 3.9G (cores 4,
501    /// fds 1048576, MemAvailable 7.8G)`. Setting this replaces the derivation
502    /// exactly — the number is never raised and never lowered.
503    #[arg(long, value_name = "N", env = "GUNNAR_MAX_TRANSFERS")]
504    pub max_concurrent_transfers: Option<usize>,
505    /// The memory budget for git operations in flight, in bytes — the
506    /// admission gate's second budget. Default: half of `MemAvailable` at
507    /// start.
508    ///
509    /// The ledger charges each transfer a small entry cost and then what its
510    /// pack actually moves, so this is a budget rather than a second
511    /// concurrency cap. It used to be a compiled-in 8 GiB against a flat
512    /// 320 MiB per push, which made "25 concurrent pushes" the real ceiling on
513    /// every box regardless of its RAM.
514    #[arg(long, value_name = "BYTES", env = "GUNNAR_GIT_MEMORY_BYTES")]
515    pub git_memory_bytes: Option<u64>,
516
517    /// How long a transfer may WAIT for a slot before it is refused, in
518    /// milliseconds. Default 2000; `0` refuses on the spot.
519    ///
520    /// A refused client reconnects, and a reconnect is a whole SSH handshake —
521    /// more than the wait costs. The wait is bounded, counted, and is what the
522    /// delay governor is driven by.
523    #[arg(long, value_name = "MS", env = "GUNNAR_ADMISSION_WAIT_MS")]
524    pub admission_wait_ms: Option<u64>,
525
526    /// What one identity is **guaranteed** at once, and its share of a
527    /// contended server. Default 32.
528    ///
529    /// A floor, not a wall: on a server that is not near saturation a single
530    /// identity may use every slot `--max-concurrent-transfers` allows. Once
531    /// the server is contended each active identity gets an equal share of it,
532    /// never below this number.
533    #[arg(long, value_name = "N")]
534    pub max_transfers_per_principal: Option<usize>,
535
536    /// How many git reads are served **without a key** at once.
537    /// Default 5.
538    ///
539    /// The companion to `--anonymous`: that decides whether the door is open,
540    /// this decides how wide. Unlike `--max-transfers-per-principal` it is a
541    /// wall and not a floor — it binds on an idle server, because the caller it
542    /// bounds cannot be named, revoked or telephoned, and "the box was quiet so
543    /// I let a stranger have all of it" is not a trade an operator would make
544    /// deliberately.
545    ///
546    /// Raise it on a box whose *purpose* is public serving (a browse replica);
547    /// `0` refuses every anonymous read while leaving the door itself open,
548    /// which is the arm to use when you want the refusal to say so rather than
549    /// have `--anonymous refuse` deny the repository exists.
550    #[arg(long, value_name = "N", env = "GUNNAR_MAX_ANONYMOUS_TRANSFERS")]
551    pub max_anonymous_transfers: Option<usize>,
552
553    /// **The usage share's budget**: bytes (in + out) a namespace may move per
554    /// window at the ordinary per-principal share. Past it the namespace is
555    /// served at the ANONYMOUS ceiling (`--max-anonymous-transfers`) and
556    /// refused fast with its own reason — never queued. `none` turns the
557    /// share off (PassThrough: admission decides as a build without it).
558    /// SI or IEC sizes: `100 GB`, `5 GiB`, or bytes. Default 100 GB.
559    ///
560    /// Only in a build with the `admit-share` feature.
561    #[cfg(feature = "admit-share")]
562    #[arg(long, value_name = "SIZE|none", env = "GUNNAR_ADMIT_LIMIT")]
563    pub admit_limit: Option<String>,
564
565    /// The usage share's rolling window, in UTC days, today included.
566    /// Default 30.
567    ///
568    /// Only in a build with the `admit-share` feature.
569    #[cfg(feature = "admit-share")]
570    #[arg(long, value_name = "DAYS", env = "GUNNAR_ADMIT_WINDOW_DAYS")]
571    pub admit_window_days: Option<u32>,
572
573    /// The largest object `Refs.ReadObject` will read, in bytes.
574    /// Default 67108864 (64 MiB); `0` means no ceiling.
575    ///
576    /// Reading any part of an object costs the WHOLE object in memory — a
577    /// prefix of a delta-encoded object needs its entire base chain — so
578    /// `max_bytes` bounds the answer and not the work. This bounds the work.
579    /// The default is a 512 MiB budget divided by the 8 control-plane workers
580    /// that can be inflating at once; raise it on a box whose repositories hold
581    /// large blobs and whose control port is trusted, and note that the git
582    /// clone path is untouched by this and streams either way.
583    #[arg(long, value_name = "BYTES")]
584    pub max_object_bytes: Option<u64>,
585
586    /// **The metered appliance's key**: monetize-server's Ed25519 public key,
587    /// either spelled inline as its 64 hexadecimal characters or as the path of
588    /// a file holding it (32 raw bytes, 64 hex characters, or
589    /// `ed25519:<base64>`). Every `Entitlement.Set` is verified against it
590    /// before a plan moves. Only a `metered` build honours it; any other build
591    /// REFUSES to start when it is set, by name — an operator who configured a
592    /// metered appliance on a binary that cannot meter must find out at boot,
593    /// not from a tenant who was never suspended.
594    ///
595    /// **This flag is NOT compiled out of the open build, and that is the
596    /// point.** It is the one piece of the monetize seam an open gunnar must
597    /// still RECOGNISE, because a flag clap has never heard of is refused as
598    /// "unexpected argument" — a parser complaint that says nothing about why
599    /// this binary cannot do what was asked. Kept, the open build answers with
600    /// the sentence in `gunnar_server::dispatch` instead: this build contains
601    /// no entitlement code at all and would serve every namespace Free.
602    ///
603    /// **Which of the two it is, is decided by SHAPE — there is no second
604    /// flag.** A value with no `/` in it that is either all hexadecimal or
605    /// exactly 64 characters long is the KEY; anything else is a PATH. So
606    /// `/data/monetize.pub` and `monetize.pub` are files, as they always were,
607    /// and `43cb…8428` is the key itself. A near-miss (63 characters, or a
608    /// typo that is not a hex digit) is refused as a malformed key naming what
609    /// was wrong, rather than silently becoming a filename nothing will find.
610    ///
611    /// Inline is what an appliance actually needs. The installer only stages
612    /// files onto `/data` under `korp.reinstall=wipe`, the ESP is read-only at
613    /// runtime, and so on a keep-upgrade the kernel command line is the only
614    /// writable channel into a booting gunnar — a path there names a file
615    /// nothing was allowed to create. An Ed25519 *public* key is safe to carry
616    /// that way; a private key never would be.
617    #[arg(long, value_name = "HEX_OR_PATH", env = "GUNNAR_MONETIZE_PUBKEY")]
618    pub monetize_pubkey: Option<crate::monetize_key::MonetizePubkey>,
619
620    // ── WHY THIS ONE IS COMPILED OUT AND `--monetize-pubkey` IS NOT ────────
621    //
622    // Kept as a plain comment rather than a doc comment ON PURPOSE: clap turns
623    // `///` into `--help`, and the explanation below is for whoever reads this
624    // file, not for an operator of the open build — writing it as documentation
625    // would reintroduce, in prose, exactly the vocabulary this gate removes.
626    //
627    // gunnar is the OPEN half of the split: it reports `Usage`, it receives a
628    // signed `Entitlement`, and it takes no money ever. The private monetize
629    // half is the only thing that knows what a byte is worth. So an open
630    // `gunnar-server --help` must not advertise a flag whose whole noun is a
631    // payment page — and until this `cfg`, it did, for a field the open binary
632    // has no code to honour.
633    //
634    // `--monetize-pubkey` three declarations up is deliberately NOT gated, and
635    // the asymmetry is the argument, not an oversight. That flag's PRESENCE
636    // changes what the box would do — meter, or silently serve every namespace
637    // Free — so an open build has to recognise it in order to refuse it by
638    // name at boot. This flag cannot even be given without it: `requires`
639    // sees to that, so an open build handed both stops on the pubkey's
640    // sentence either way, and one handed this alone was already going to be
641    // refused. Compiling it out therefore costs no refusal that the pubkey's
642    // own does not already carry, and buys a `--help` with no money in it.
643    //
644    // And there is NO DEFAULT, here or at the use site. The fallback used to
645    // live in `gunnar_server::dispatch` as Vetra's own commercial URL, which
646    // meant anybody building a metered gunnar and forgetting this flag pointed
647    // their tenants at somebody else's payment page. A metered build given
648    // `--monetize-pubkey` without this now refuses at boot; see
649    // `gunnar_server::entitlement::MeteredConfig::load`.
650    /// **Where a lapsed tenant goes to restore its plan**, as a template with
651    /// `{tenant}` for the namespace (without the placeholder, `/{tenant}` is
652    /// appended, so the link always names the tenant). Printed in every
653    /// refusal and Grace warning. Requires `--monetize-pubkey`, and a
654    /// `metered` build that is given that flag without this one refuses to
655    /// start: a page belonging to whoever built the binary is not a default
656    /// anybody else's tenants should be sent to.
657    #[cfg(feature = "metered")]
658    //
659    // No clap `requires = "monetize_pubkey"` (2026-09-24): the key may come from the
660    // installer's door (`gunnar_server::feed`) instead of this argv, so a one-medium
661    // metered box carries the pay page and no key. The same refusal — this flag with
662    // no key from EITHER source — is made in `gunnar_server::dispatch`.
663    #[arg(long, value_name = "URL", env = "GUNNAR_BILLING_URL")]
664    pub billing_url: Option<String>,
665
666    /// **Is monetize VITAL to this box?** Owner ruling 2026-09-22. `true`, the default: stored
667    /// entitlement facts that do not verify at boot (a main restored under another monetize key)
668    /// raise the `Admin.Health` alarm `entitlement.key_mismatch`, naming the namespaces, and the
669    /// box reports status `maintenance` until monetize pushes a fact that verifies. The box
670    /// still starts and serves either way, and gunnar never dials monetize. `false`: the same
671    /// refusal is only logged.
672    #[cfg(feature = "metered")]
673    #[arg(long, value_name = "BOOL", env = "GUNNAR_MONETIZE_VITAL", default_value_t = true, action = clap::ArgAction::Set)]
674    pub monetize_vital: bool,
675
676    /// Make `--max-transfers-per-principal` an unconditional ceiling.
677    ///
678    /// For a host where an identity is a *tenant* and the cap is how the
679    /// box is shared out. On an appliance — one team, a handful of keys — this only
680    /// throttles the owner, which is why it is off by default.
681    #[arg(long)]
682    pub hard_per_principal_limit: bool,
683
684    /// Serve the **counters** on this address, as Prometheus text.
685    ///
686    /// Off unless asked for, and there is no default address: nothing binds
687    /// until you name one. `curl http://<addr>/metrics` then returns the same
688    /// nineteen numbers the `gunnar::metrics` heartbeat writes to the log —
689    /// but on demand, at whatever rate you ask, which is the only way
690    /// `gunnar_transfers_in_flight` tells a slow transfer from a stuck one
691    /// while it is still running.
692    ///
693    /// **It has no authentication.** The body is nineteen integers about this
694    /// process — no repository names, no principals, no references — so it is
695    /// not worth a credential, but anyone who can reach the port learns how
696    /// busy this server is. Bind it to `127.0.0.1` and let the scraper come
697    /// over a tunnel unless you mean otherwise; a non-loopback address is
698    /// accepted and logged as a `warn` at start-up.
699    #[arg(long, value_name = "ADDR")]
700    pub metrics_bind: Option<std::net::SocketAddr>,
701
702    /// **Watch this primary and TAKE OVER if it stops answering** — I11's
703    /// passive heartbeat, the standby half of failover.
704    ///
705    /// Off unless you name an address, and that is the design rather than
706    /// caution: naming a new primary is a decision, so a box never makes it
707    /// because nobody configured it not to. Give the PRIMARY's control-plane
708    /// address; this box probes it every `--watch-probe-ms` and, after
709    /// `--watch-promote-after` **consecutive** failures, does to itself exactly
710    /// what `gunnar control replication promote` does — persists `role:
711    /// primary` and thaws the write freeze. One reachable probe resets the run,
712    /// so a flapping link cannot add up to a takeover.
713    ///
714    /// It does nothing on a box whose role is `primary`: the watch re-reads the
715    /// role every tick and stops when this box is not a standby, so putting the
716    /// flag on BOTH boxes of a pair is safe and is what one unit file wants.
717    ///
718    /// **What the probe proves:** a TCP connect to that address, i.e. *"is
719    /// something listening"* — not *"is it serving git"*. A primary that is
720    /// wedged but still listening reads as alive and this box will NOT promote.
721    /// That is the safe direction to be wrong in: the cost is a manual
722    /// `replication promote`, where the other way round is two writable boxes.
723    ///
724    /// **The address still has to move.** Taking over makes this box writable;
725    /// it does not repoint the front (DNS / the UpCloud DNAT). That is the one
726    /// infrastructure step failover has always had.
727    #[arg(long, value_name = "ADDR")]
728    pub watch_primary: Option<std::net::SocketAddr>,
729
730    /// Milliseconds between probes of `--watch-primary`. Ignored without it.
731    #[arg(long, value_name = "MS", default_value_t = 5_000)]
732    pub watch_probe_ms: u64,
733
734    /// Consecutive failed probes before this box promotes itself. Ignored
735    /// without `--watch-primary`; a zero is read as one.
736    ///
737    /// The default is six, which at the default interval is **thirty seconds of
738    /// continuous unreachability** — longer than a restart of the primary, and
739    /// shorter than a coffee break.
740    #[arg(long, value_name = "N", default_value_t = 6)]
741    pub watch_promote_after: u32,
742
743    /// Milliseconds one connect attempt may take before it counts as a failure.
744    /// Ignored without `--watch-primary`.
745    ///
746    /// Kept below `--watch-probe-ms` deliberately: a connect that outlives the
747    /// interval lets probes overlap, and one dead primary becomes a growing
748    /// pile of pending sockets.
749    #[arg(long, value_name = "MS", default_value_t = 2_000)]
750    pub watch_connect_ms: u64,
751
752    /// **Auto-fix** (5.4 resilience): when this box comes up with its data set
753    /// dead or empty while a healthy twin exists, recharge it from the twin by
754    /// itself. OFF by default: without it the box only SAYS `data lost; recharge
755    /// from twin available`, and an admin's [Recharge from twin] or a signed
756    /// guardian order does it. The estate's opt-in, like `--watch-primary`.
757    #[arg(long, env = "GUNNAR_REPAIR_AUTO")]
758    pub repair_auto: bool,
759
760    /// The OpenSSH private key this box signs repair calls with (reading the
761    /// twin's log, `Bootstrap.Restore` into a fresh main, fencing a primary).
762    /// Default: this box's twin key, when it has one. A fresh main takes a
763    /// restore only from a key it was fed (`--restore-pubkey`).
764    #[arg(long, value_name = "PATH", env = "GUNNAR_REPAIR_KEY")]
765    pub repair_key: Option<std::path::PathBuf>,
766
767    /// The twin the boot check reviews and recharges from (`host:port` of its
768    /// control plane). Default: the twin the installer's re-admit named; a main
769    /// that grows also asks the twin in the door's peer list.
770    #[arg(long, value_name = "ADDR", env = "GUNNAR_REPAIR_TWIN")]
771    pub repair_twin: Option<String>,
772
773    /// **The pull channel**: monetize's `Fleet` endpoint this box reports to and
774    /// pulls signed repair orders from (`https://…`). Off when absent. Monetize
775    /// never calls into the box; every order is verified under the monetize key.
776    #[arg(long, value_name = "URL", env = "GUNNAR_FLEET")]
777    pub fleet: Option<String>,
778
779    /// PEM of the CA monetize's `Fleet` certificate is signed by.
780    #[arg(long, value_name = "PATH", env = "GUNNAR_FLEET_CA")]
781    pub fleet_ca: Option<std::path::PathBuf>,
782
783    /// The estate (monetize instance) this box belongs to. Required with `--fleet`.
784    #[arg(long, value_name = "NAME", env = "GUNNAR_FLEET_INSTANCE")]
785    pub fleet_instance: Option<String>,
786
787    /// The role this box pulls as (`main` / `twin`). Default: what the box is.
788    #[arg(long, value_name = "ROLE", env = "GUNNAR_FLEET_ROLE")]
789    pub fleet_role: Option<String>,
790
791    /// The longest pause between two reports, in seconds.
792    #[arg(long, value_name = "S", default_value_t = 60)]
793    pub fleet_every_s: u64,
794
795    /// Also serve **smart HTTP** on this address.
796    ///
797    /// Off unless asked for. `git clone http://host/ns/repo.git` then works,
798    /// and so does `push`, with an access token as the HTTP Basic password
799    /// (`gunnar token add`). The same repositories, the same grants and the
800    /// same `--no-anonymous` switch as SSH: this is a second transport, not a
801    /// second server.
802    #[cfg(feature = "http")]
803    #[arg(long, value_name = "ADDR")]
804    pub http_bind: Option<std::net::SocketAddr>,
805
806    /// Accept an access token over a **plain** HTTP connection.
807    ///
808    /// gunnar terminates no TLS. By default it therefore refuses to read a
809    /// credential unless a terminator in front of it says the client spoke TLS
810    /// (`X-Forwarded-Proto: https`), because a token in an `Authorization`
811    /// header on a cleartext connection is a password on the wire. This turns
812    /// that off, for loopback, a test, or a link you vouch for.
813    ///
814    /// **Anonymous read is unaffected either way**: it presents no credential.
815    #[cfg(feature = "http")]
816    #[arg(long)]
817    pub http_allow_cleartext_credentials: bool,
818
819    /// Terminate **TLS on the smart-HTTP listener itself**, with a certificate
820    /// gunnar mints from its own issuer at start — the single-executable
821    /// deployment: no proxy, no PEM files, `git clone https://host/ns/repo.git`
822    /// against one binary. Clients pin the issuer (`gunnar issuer show --pem`)
823    /// exactly as they do for the client certificates it already mints.
824    ///
825    /// ALPN offers HTTP/2 then HTTP/1.1, so a client that multiplexes gets to.
826    /// Mutually exclusive with `--http-tls-cert`/`--http-tls-key`, which is the
827    /// bring-your-own-certificate form of the same listener.
828    #[cfg(feature = "http")]
829    #[arg(long, conflicts_with_all = ["http_tls_cert", "http_tls_key"])]
830    pub http_tls_self: bool,
831
832    /// Terminate TLS on the smart-HTTP listener with this certificate chain
833    /// (PEM, leaf first). Needs `--http-tls-key`.
834    #[cfg(feature = "http")]
835    #[arg(long, value_name = "PEM", requires = "http_tls_key")]
836    pub http_tls_cert: Option<std::path::PathBuf>,
837
838    /// The private key for `--http-tls-cert` (PEM).
839    #[cfg(feature = "http")]
840    #[arg(long, value_name = "PEM", requires = "http_tls_cert")]
841    pub http_tls_key: Option<std::path::PathBuf>,
842
843    /// Require a **client certificate** on the gRPC control plane, and mint the
844    /// listener's own identity from this server's issuer at start.
845    ///
846    /// This is what makes the control plane safe to bind off loopback. Two
847    /// things change together: a peer whose certificate this issuer did not
848    /// sign never finishes a handshake, and a certificate that does get through
849    /// is looked up in the identity key table — so one the issuer signed and nobody
850    /// registered is still refused, by the gate, with an audit record.
851    ///
852    /// Clients get their certificate from `gunnar control bootstrap` (which
853    /// writes `<name>.cert.pem`, `<name>.key.pem` and `gunnar-issuer.pem` to
854    /// `~/.config/gunnar/<server>/`, or `--out`) and
855    /// present it with `--client-cert`/`--client-key`/`--ca`.
856    ///
857    /// Off by default: turning it on rewrites how every existing client
858    /// connects, and a default that breaks a working deployment gets turned
859    /// back off everywhere.
860    ///
861    /// **It cannot be the first thing a server does.** A mutual listener
862    /// refuses every peer with no certificate, and on a server that has never
863    /// been enrolled there is no certificate anywhere — including for
864    /// `Bootstrap.Enroll`, the one RPC served without a credential, which is
865    /// behind the same handshake. `gunnar serve` REFUSES to start in that
866    /// state and names the enrolment steps. Every other unhappy state is a
867    /// warning naming the principals that lose access; run
868    /// `gunnar issuer readiness` first to see them before the restart, and
869    /// `gunnar control health` afterwards to confirm the flip took.
870    #[arg(long)]
871    pub grpc_tls_self: bool,
872
873    /// **The mutual replication listener a twin follows this box through.**
874    ///
875    /// Mutual TLS 1.3 only: a peer must present a certificate this server's
876    /// issuer signed (`gunnar control twin certify` gets a twin one). Unset, it is
877    /// DERIVED — the `--grpc-bind` address, port + 1, whenever that address is
878    /// off loopback — so the appliance serves it with no flag on its medium, and
879    /// a loopback control plane serves none.
880    #[arg(long, value_name = "ADDR")]
881    pub grpc_replication_bind: Option<std::net::SocketAddr>,
882    /// **Accepted and ignored since 5.3.3.** The method-blind
883    /// `gunnar-sshsig-v1` credential is refused on every listener, always
884    /// (GUN-001; OWNER 2026-10-10), so there is nothing left for this to turn
885    /// on — and nothing that turns it off. Kept, hidden, only so a unit file
886    /// that still passes it starts instead of failing to parse.
887    #[arg(long, hide = true)]
888    pub grpc_require_bound_credentials: bool,
889
890    /// **A WebAuthn relying party this server answers for**:
891    /// `<rp_id>=<origin>[,<origin>…]`, repeatable (stinky row 13, 2026-10-10).
892    ///
893    /// Without one, a passkey ceremony's `rp_id` and `origin` are the relay's
894    /// word, as before. With any, a ceremony for another relying party, or from
895    /// an origin not listed, is refused — so a relay cannot run one for a
896    /// relying party it merely names.
897    #[arg(long = "web-relying-party", value_name = "RP_ID=ORIGIN[,ORIGIN]")]
898    pub web_relying_party: Vec<String>,
899
900    /// **Every passkey ceremony must verify the user** (a PIN or a biometric,
901    /// not a touch): web logins and the passkey after a company sign-in alike.
902    /// The server decides; a relay may ask for more, never for less (A34-12).
903    /// Off: a touch is enough unless the relay asks.
904    #[arg(long = "web-require-user-verification")]
905    pub web_require_user_verification: bool,
906
907    /// **Build every push, in this process, hermetically** (roadmap phase 3).
908    ///
909    /// The runner subscribes to the same event bus a push publishes on, so the
910    /// build happens *because* the push landed and not some time after it. This
911    /// is the golden path's fourth step, the one that is nothing at all.
912    ///
913    /// Needs `--runner-workspace` and `--runner-builder`.
914    #[arg(long, requires_all = ["runner_workspace", "runner_builder"])]
915    pub runner: bool,
916
917    /// Where the runner puts checkouts and build output.
918    #[arg(long, value_name = "PATH")]
919    pub runner_workspace: Option<std::path::PathBuf>,
920
921    /// The identity-table principal the runner attributes every build record to.
922    #[arg(long, value_name = "PRINCIPAL")]
923    pub runner_builder: Option<String>,
924
925    /// Let the runner's builds reach the network.
926    ///
927    /// Off by default, and the server **refuses to start** with the runner on
928    /// and no sandbox available rather than building non-hermetically and
929    /// signing records that say otherwise. Turning this on is recorded in every
930    /// record the runner produces.
931    #[arg(long)]
932    pub runner_allow_network: bool,
933
934    /// Do not offer `filter`, so this server serves no partial clones.
935    ///
936    /// Removes `filter` from the advertisement **and** makes the parser refuse
937    /// the argument, in both protocol versions, so a client cannot get a
938    /// filtered pack by asking for one anyway. `git clone --filter=blob:none`
939    /// then fails with *"this server does not offer the filter capability"*
940    /// rather than quietly serving the whole thing.
941    ///
942    /// For an appliance that has to be able to state it never serves arbitrary
943    /// blob subsets. Everything else about a clone is unchanged.
944    #[arg(long)]
945    pub no_filter: bool,
946
947    /// Which object ids a client may `want`. Default `reachable`.
948    ///
949    /// `advertised-tips` is the strictest and **breaks partial clone**: a
950    /// `blob:none` clone's lazy fetch asks for bare blob ids that were never
951    /// advertised, so every `git checkout` in one becomes "the remote end hung
952    /// up unexpectedly". Pair it with `--no-filter` or not at all. `any` makes
953    /// every object ever pushed fetchable by whoever can name it, including on
954    /// deleted branches.
955    #[arg(long, value_name = "POLICY", value_enum)]
956    pub want_policy: Option<WantPolicyArg>,
957
958    /// Ceiling on **objects emitted** while answering one fetch.
959    ///
960    /// An operator's explicit cap on the size of a clone. It is no longer what
961    /// bounds gunnar's memory — `--max-fetch-walk-bytes` is, in the unit a
962    /// machine is actually sized in — and the reason is that an object count
963    /// does not convert to bytes at a fixed rate: MEASURED across gunnar's
964    /// corpus on 2026-08-05 the same walk cost between 393.8 and 1 572.9 bytes
965    /// per emitted object, a 4x spread set by the repository's shape.
966    ///
967    /// There is no "off" value. `0` would refuse every fetch rather than allow
968    /// every fetch — the walk stops when it reaches the budget — so it is
969    /// rejected at startup instead of being accepted as an unbounded server
970    /// that in fact serves nothing.
971    #[arg(long, value_name = "N")]
972    pub fetch_budget: Option<usize>,
973
974    /// Bytes of object bookkeeping **one fetch** may hold. `0` removes the
975    /// ceiling. Default 8589934592 (8 GiB).
976    ///
977    /// Charged at a measured 384 bytes per object the walk is holding, so the
978    /// default admits about 22.4 million objects. That is deliberately past the
979    /// linux kernel's 13 816 503: MEASURED 2026-08-05, a 14 042 000-object
980    /// repository with no `.bitmap` clones in 5 460 816 kB of peak RSS and 64
981    /// seconds, and the object-counted ceiling it shipped with refused it.
982    ///
983    /// This is a **per-request** number and on its own it is not a bound on the
984    /// server: multiply it by `--max-concurrent-transfers`. See
985    /// `--max-walk-bytes-total`.
986    #[arg(long, value_name = "BYTES")]
987    pub max_fetch_walk_bytes: Option<usize>,
988
989    /// Bytes of object bookkeeping **the whole server** may hold at once,
990    /// shared by every transfer in flight. `0` removes the ceiling. Defaults to
991    /// half of this machine's `MemTotal`.
992    ///
993    /// The ceiling that actually bounds the box. `--max-concurrent-transfers`
994    /// counts transfers, which says nothing about memory: 256 concurrent clones
995    /// of a kernel-sized repository need well over a terabyte between them, and
996    /// what the shipped server did about that was let the OOM killer choose.
997    /// A transfer that cannot get its reservation is refused with a sentence
998    /// naming both numbers, and the client can retry.
999    ///
1000    /// A walk holding under 8 MiB never touches the pool, so ordinary clones
1001    /// neither queue nor draw on it.
1002    #[arg(long, value_name = "BYTES")]
1003    pub max_walk_bytes_total: Option<usize>,
1004
1005    /// Bytes of pkt-line payload held for one peer-supplied request. `0`
1006    /// removes the ceiling. Default 8388608 (8 MiB).
1007    ///
1008    /// The size is the **peer's** to choose and costs it about what it costs
1009    /// the server, which is why this is an absolute number.
1010    #[arg(long, value_name = "BYTES")]
1011    pub max_fetch_request_bytes: Option<usize>,
1012
1013    /// Bytes of a single object gunnar will put in a pack. `0` removes the
1014    /// ceiling. Default 1073741824 (1 GiB), the same number `gunnar-pack`
1015    /// applies on the way in.
1016    #[arg(long, value_name = "BYTES")]
1017    pub max_fetch_object_bytes: Option<usize>,
1018
1019    /// Largest LFS object this server will store, in bytes. Default
1020    /// 8589934592 (8 GiB).
1021    ///
1022    /// **There is no "off" value**, and `0` is refused at startup. Unlike the
1023    /// two fetch ceilings above, `gunnar_lfs::Limits` holds a plain `u64` and
1024    /// not an `Option`, so `0` means "refuse every upload" rather than "accept
1025    /// any size". A finite default is deliberate: "no limit" means one client
1026    /// decides how much disk the server has.
1027    #[arg(long, value_name = "BYTES")]
1028    pub max_lfs_object_bytes: Option<u64>,
1029
1030    /// Most objects one LFS `batch` request may name. Default 10000. `0` is
1031    /// refused, for the same reason as above.
1032    #[arg(long, value_name = "N")]
1033    pub max_lfs_batch_objects: Option<usize>,
1034
1035    /// Bytes of a refused LFS upload that will be read and discarded to keep
1036    /// the channel in sync before the session is dropped. Default 131056
1037    /// (two pkt-line payloads). `0` is refused.
1038    ///
1039    /// A refusal cannot simply stop reading: the client is already writing the
1040    /// object into the channel and the framing only ends at its flush-pkt, so a
1041    /// server that answered 413 and then read the next *command* would read
1042    /// object bytes instead.
1043    #[arg(long, value_name = "BYTES")]
1044    pub max_lfs_drain_bytes: Option<u64>,
1045
1046    /// Bind, report, and exit rather than serving. For smoke tests and for
1047    /// checking a configuration without holding the ports.
1048    #[arg(long)]
1049    pub check: bool,
1050}
1051
1052/// `--want-policy`, one arm per [`gunnar_wire::WantPolicy`].
1053#[cfg(feature = "server")]
1054#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1055pub enum WantPolicyArg {
1056    /// Only ids that appeared in the advertisement. Breaks partial clone.
1057    AdvertisedTips,
1058    /// Anything reachable from a reference. The default.
1059    Reachable,
1060    /// Anything in the store, reachable or not.
1061    Any,
1062}
1063
1064#[cfg(feature = "server")]
1065impl WantPolicyArg {
1066    /// The wire's own enum. One translation, in one place, so the flag and the
1067    /// behaviour cannot drift (one implementation).
1068    pub fn to_wire(self) -> gunnar_wire::WantPolicy {
1069        match self {
1070            WantPolicyArg::AdvertisedTips => gunnar_wire::WantPolicy::AdvertisedTips,
1071            WantPolicyArg::Reachable => gunnar_wire::WantPolicy::ReachableFromRefs,
1072            WantPolicyArg::Any => gunnar_wire::WantPolicy::Any,
1073        }
1074    }
1075}
1076
1077/// Namespace verbs.
1078#[cfg(feature = "server")]
1079#[derive(Debug, Subcommand)]
1080pub enum NamespaceCommand {
1081    /// Create a namespace. Nested names (`team/sub`) are legal; grants do NOT
1082    /// inherit between them.
1083    Create {
1084        /// The namespace name.
1085        name: String,
1086    },
1087    /// List namespaces.
1088    List,
1089    /// Delete a namespace.
1090    Delete {
1091        /// The namespace name.
1092        name: String,
1093        /// Also delete every repository in it. Never implicit.
1094        #[arg(long)]
1095        delete_repos: bool,
1096    },
1097    /// Set this account's read-cache budget.
1098    #[command(alias = "set-quota")]
1099    SetBudget {
1100        /// The namespace name.
1101        name: String,
1102        /// **The exploded read cache each of this account's repositories may
1103        /// hold**: `8G`, `512M`, `unlimited`, or `default` to go back to the
1104        /// server's own (10 % of /data, capped at 8 GiB).
1105        ///
1106        /// A bigger cache means more objects are already inflated and reads
1107        /// are fast; a smaller one means more reads re-inflate their own delta
1108        /// chain out of the pack, ~0.5–2 ms an object. It is never a refusal
1109        /// and never an error: an account at its budget serves everything it
1110        /// has, correctly, more slowly. Nothing it bounds can be lost — every
1111        /// byte of it is re-derivable from the packs, which it does not bound.
1112        #[arg(long, value_name = "BYTES")]
1113        explode_budget: Option<String>,
1114    },
1115}
1116
1117/// Repository verbs.
1118#[cfg(feature = "server")]
1119#[derive(Debug, Subcommand)]
1120pub enum RepoCommand {
1121    /// Create a repository. Its namespace must already exist.
1122    Create {
1123        /// `<namespace>/<name>`, with or without a `.git` suffix.
1124        path: String,
1125        /// sha256 is the default for new repositories.
1126        #[arg(long, value_enum, default_value_t = HashArg::Sha256)]
1127        hash: HashArg,
1128        /// The default branch, as a short name or a full ref.
1129        #[arg(long, value_name = "BRANCH")]
1130        default_branch: Option<String>,
1131        /// **Readable by anyone, with no key**: `git clone
1132        /// ssh://anonymous@host/ns/repo.git` works for a stranger. Writing
1133        /// still needs a grant. Private is the default.
1134        #[arg(long)]
1135        public: bool,
1136        /// A one-line description.
1137        #[arg(long, default_value = "")]
1138        description: String,
1139        /// **Take over a store already on disk at this name** instead of
1140        /// refusing it. Only for a matching hash (the store's `OBJECT_FORMAT`
1141        /// marker). Without this a create never adopts a directory it did not
1142        /// make: that is how a deleted repository came back (2026-08-30).
1143        #[arg(long)]
1144        adopt: bool,
1145    },
1146    /// List repositories.
1147    List {
1148        /// Only this namespace.
1149        #[arg(long, value_name = "NS")]
1150        namespace: Option<String>,
1151    },
1152    /// **Delete a repository. Without `--purge` this is a DATED TOMBSTONE, not
1153    /// a reclaim.**
1154    ///
1155    /// The catalog row goes, so the name stops working and nothing can be
1156    /// pushed to it — but the bytes stay, for `--retention-days` (30 by
1157    /// default), and `repo undelete <path>` puts it back exactly as it was.
1158    /// A background sweep reclaims them when the window closes.
1159    ///
1160    /// Why the window exists: `--purge` returns the bytes in about two seconds
1161    /// — 181 GiB to 33 MiB, measured — and a live mirror does not help,
1162    /// because the delete replicates to the standby too. Deleting the wrong
1163    /// repository is the mistake that will actually happen.
1164    ///
1165    /// **A tombstone still costs storage.** Its bytes are on the account until
1166    /// the window closes; `repo list-deleted` and `Admin.StorageInfo` report
1167    /// them, so the space a tombstone holds is never a surprise.
1168    Delete {
1169        /// `<namespace>/<name>`.
1170        path: String,
1171        /// **Reclaim the bytes NOW**, with no window and no undo.
1172        #[arg(long)]
1173        purge: bool,
1174        /// How long the bytes are kept. `0` means at the next sweep — an
1175        /// operator freeing space meant it.
1176        #[arg(long, value_name = "DAYS")]
1177        retention_days: Option<u32>,
1178    },
1179    /// **Put a soft-deleted repository back**, inside its retention window.
1180    ///
1181    /// It comes back as it was — the recorded hash kind above all. A
1182    /// repository recreated as sha256 over a sha1 store serves refs no client
1183    /// can read, which is why this is a verb and not `repo create --adopt`.
1184    Undelete {
1185        /// `<namespace>/<name>`.
1186        path: String,
1187    },
1188    /// **End a retention window early**: reclaim a soft-deleted repository's
1189    /// bytes now. There is no undo after it.
1190    Purge {
1191        /// `<namespace>/<name>`.
1192        path: String,
1193    },
1194    /// What is deleted and not yet purged, when the window closes, and the
1195    /// bytes each is still costing.
1196    ListDeleted {},
1197    /// **Store directories with no catalog row and no soft-delete tombstone —
1198    /// bytes nothing else on this box can see.**
1199    ///
1200    /// `repo list` reads the catalog and an orphan is not in it. `repo
1201    /// list-deleted`, `repo undelete`, `repo purge` and the retention sweep
1202    /// all key off a tombstone and an orphan has none. So a directory with
1203    /// neither was reachable by nothing at all — while `repo create` refused
1204    /// its name *because the directory was there*.
1205    ///
1206    /// MEASURED on the appliance 2026-08-30: ~25-39 GB standing at
1207    /// `alice/linux`, the wreckage of a failed kernel mirror, with the name
1208    /// permanently unusable and no verb able to touch the bytes.
1209    ///
1210    /// With `repo list` and `repo list-deleted` this closes the set: every
1211    /// `<name>.git` directory is in exactly one of the three.
1212    ListOrphans {
1213        /// Only this namespace.
1214        #[arg(long, value_name = "NS")]
1215        namespace: Option<String>,
1216    },
1217    /// **Reclaim an orphan's bytes.** There is no undo.
1218    ///
1219    /// Refuses anything that is not an orphan by name: a catalogued repository
1220    /// is `repo delete --purge`'s and a soft-deleted one is `repo undelete`'s
1221    /// or `repo purge`'s. To KEEP an orphan's objects instead, run `repo
1222    /// create <path> --adopt --hash <the OBJECT_FORMAT that list-orphans
1223    /// reports>`.
1224    Reclaim {
1225        /// `<namespace>/<name>`.
1226        path: String,
1227    },
1228    /// **One repository's stored bytes, out to ONE FILE.**
1229    ///
1230    /// The file is self-describing and copyable anywhere — another appliance,
1231    /// object storage, a laptop. It carries the verbatim pack bytes, the
1232    /// durable extent journal and the ref log; **no index travels**, because an
1233    /// index is a derivation and the receiver builds its own.
1234    ///
1235    /// `--from-generation` is a WATERMARK, not a diff: the pack is append-only,
1236    /// so everything above a byte offset is new and nothing below it changed.
1237    /// Pass a previous run's `to_generation` for an incremental archive.
1238    ///
1239    /// Safe while the server is running: the cut is taken at one length, and
1240    /// every byte below it is already fsynced.
1241    ArchiveOut {
1242        /// `<namespace>/<name>`.
1243        path: String,
1244        /// Where to write the archive.
1245        #[arg(long, value_name = "FILE")]
1246        to: std::path::PathBuf,
1247        /// Start above this `objects.pack` offset. 0 = a full archive.
1248        #[arg(long, default_value_t = 0, value_name = "N")]
1249        from_generation: u64,
1250    },
1251    /// **Put an archive back**, into a repository this server does not have.
1252    ///
1253    /// Restores the bytes and then puts the catalog row back (adopting the
1254    /// store it just wrote), so a restored repository is servable without a
1255    /// second command. An incremental archive is applied on top of its base and
1256    /// refuses by name if the base is not exactly what it was cut against.
1257    ArchiveIn {
1258        /// The archive file.
1259        file: std::path::PathBuf,
1260        /// `<namespace>/<name>` to restore it as. Its namespace must exist.
1261        #[arg(long = "as", value_name = "NS/NAME")]
1262        as_path: String,
1263        /// Restore the bytes only; do not touch the catalog.
1264        #[arg(long)]
1265        no_catalog: bool,
1266    },
1267}
1268
1269/// `gunnar issuer …` — the server's certificate authority.
1270///
1271/// **Exactly one issuer.** Not a list, not a default among several. A second
1272/// issuer is a second answer to *who may mint a user*, and the failure mode of
1273/// two is that nobody can say which one signed a given client. `create` on a
1274/// server that has one is refused; replacing it invalidates every certificate
1275/// in the field and is therefore a different verb.
1276///
1277/// The trusted-CA **list** is a separate thing and stays a list: a rotation
1278/// needs the old CA and the new one trusted at the same instant, or every
1279/// client is locked out at the swap. `trust`/`untrust` are that list.
1280/// `gunnar registry …` — the artifact registry (roadmap phase 4).
1281///
1282/// One rule, enforced where bytes become pullable: **no artifact without valid,
1283/// logged provenance**. Everything else here is plumbing around it.
1284#[cfg(feature = "server")]
1285#[derive(Debug, Subcommand)]
1286pub enum RegistryCommand {
1287    /// **Land a manifest under a tag** — if the image it names was built and
1288    /// attested, and a signed tree head covers the record.
1289    ///
1290    /// Refuses in four distinguishable ways, because the operator's next action
1291    /// differs for each. The commonest is the fourth: run
1292    /// `gunnar attest head --builds` first.
1293    Push {
1294        /// The image reference's name, e.g. `team/app`.
1295        #[arg(long, value_name = "NAME")]
1296        name: String,
1297        /// The tag to point at it.
1298        #[arg(long, value_name = "TAG", default_value = "latest")]
1299        tag: String,
1300        /// The manifest's digest, as `gunnar build image` printed it.
1301        #[arg(long, value_name = "DIGEST")]
1302        digest: String,
1303    },
1304    /// What a tag points at.
1305    Resolve {
1306        #[arg(long, value_name = "NAME")]
1307        name: String,
1308        #[arg(long, value_name = "TAG", default_value = "latest")]
1309        tag: String,
1310    },
1311    /// **Pull a manifest and verify the whole chain behind it.**
1312    ///
1313    /// The registry's acceptance test in one command: resolve the tag, read the
1314    /// manifest back out of content-addressed storage, and walk the chain from
1315    /// its digest down to the source.
1316    Pull {
1317        #[arg(long, value_name = "NAME")]
1318        name: String,
1319        #[arg(long, value_name = "TAG", default_value = "latest")]
1320        tag: String,
1321        /// `<path>:<line>` — check the chain reaches this line of source.
1322        #[arg(long, value_name = "PATH:LINE")]
1323        line: Option<String>,
1324    },
1325}
1326
1327/// `gunnar forge …` — issues, reviews and merges, as signed events (roadmap
1328/// phase 1).
1329///
1330/// Every verb that changes anything **signs** with the actor's own key, and the
1331/// event goes into an append-only log the same transparency machinery covers.
1332/// An approval is therefore evidence anybody can check later, without trusting
1333/// the server that stored it — which a row in a table saying `approved_by` is
1334/// not.
1335///
1336/// **This is the review half only.** The pull request itself — its number, its
1337/// refs, its state — is `PullRequests`' record under the control store's CAS,
1338/// and there is no CLI for it yet: open and merge one over gRPC.
1339///
1340/// The same events are reachable over gRPC as `Forge.*` — `Append` takes an
1341/// event the CALLER signed, verifies it against the identity key table and appends it,
1342/// because a server that could sign an approval could manufacture one. What is
1343/// deliberately not here yet is the console views, which live in the separate
1344/// `gunnar-UI` repository.
1345#[cfg(feature = "server")]
1346#[derive(Debug, Subcommand)]
1347pub enum ForgeCommand {
1348    /// Comment on a pull request.
1349    Comment {
1350        #[arg(long, value_name = "REPO")]
1351        repo: String,
1352        /// The pull request number, as `PullRequests.Create` assigned it.
1353        #[arg(long, value_name = "N")]
1354        pull: u64,
1355        #[arg(long, value_name = "TEXT")]
1356        body: String,
1357        /// The identity-table principal the event is attributed to.
1358        #[arg(long, value_name = "PRINCIPAL")]
1359        actor: String,
1360        /// The private key to sign with.
1361        #[arg(long, short = 'i', value_name = "PATH")]
1362        identity: std::path::PathBuf,
1363    },
1364    /// **Approve a specific commit.**
1365    ///
1366    /// The commit is part of what is signed, so a force-push after the review
1367    /// leaves an approval that visibly names the commit that was reviewed — and
1368    /// not the one that is there now. `PullRequests.Merge` refuses on exactly
1369    /// that, so this is a gate and not a display.
1370    Approve {
1371        #[arg(long, value_name = "REPO")]
1372        repo: String,
1373        #[arg(long, value_name = "N")]
1374        pull: u64,
1375        #[arg(long, value_name = "OID")]
1376        commit: String,
1377        #[arg(long, value_name = "PRINCIPAL")]
1378        actor: String,
1379        #[arg(long, short = 'i', value_name = "PATH")]
1380        identity: std::path::PathBuf,
1381    },
1382    /// **Withdraw an approval**, naming the commit it covered and why.
1383    ///
1384    /// Appended, never deleted: the log is append-only, so what the fold shows
1385    /// is that this actor no longer approves — and the withdrawal itself stays
1386    /// readable, which is the fact an auditor wants.
1387    Reject {
1388        #[arg(long, value_name = "REPO")]
1389        repo: String,
1390        #[arg(long, value_name = "N")]
1391        pull: u64,
1392        #[arg(long, value_name = "OID")]
1393        commit: String,
1394        #[arg(long, value_name = "TEXT")]
1395        reason: String,
1396        #[arg(long, value_name = "PRINCIPAL")]
1397        actor: String,
1398        #[arg(long, short = 'i', value_name = "PATH")]
1399        identity: std::path::PathBuf,
1400    },
1401    /// Show a pull request's review: its comments, and who approves what.
1402    ///
1403    /// The review only. The pull request itself — its title, its refs, whether
1404    /// it is open — belongs to `PullRequests`, and a second copy printed here
1405    /// would be a second answer that can disagree.
1406    Show {
1407        #[arg(long, value_name = "REPO")]
1408        repo: String,
1409        #[arg(long, value_name = "N")]
1410        pull: u64,
1411    },
1412    /// **Issues**: open, comment, close, list and show — as signed events.
1413    ///
1414    /// An issue is not a pull request and has no record behind it: it IS the
1415    /// fold of its own signed events, so opening one and closing one are events
1416    /// here (a review has neither — those are the pull request record's). A
1417    /// signed opening is the property GitHub, GitLab and Gitea have no
1418    /// equivalent for: who filed it is the author's own key.
1419    #[command(subcommand)]
1420    Issue(IssueCommand),
1421}
1422
1423/// `gunnar forge issue …` — issues as signed events.
1424#[cfg(feature = "server")]
1425#[derive(Debug, Subcommand)]
1426pub enum IssueCommand {
1427    /// **Open an issue.** Signs the title and body with the author's own key
1428    /// and appends it; the number is assigned by the fold and printed back.
1429    Open {
1430        #[arg(long, value_name = "REPO")]
1431        repo: String,
1432        #[arg(long, value_name = "TEXT")]
1433        title: String,
1434        #[arg(long, value_name = "TEXT", default_value = "")]
1435        body: String,
1436        /// The identity-table principal the issue is attributed to.
1437        #[arg(long, value_name = "PRINCIPAL")]
1438        actor: String,
1439        /// The private key to sign with.
1440        #[arg(long, short = 'i', value_name = "PATH")]
1441        identity: std::path::PathBuf,
1442    },
1443    /// Comment on an issue.
1444    Comment {
1445        #[arg(long, value_name = "REPO")]
1446        repo: String,
1447        /// The issue number, as `issue open` (or the fold) assigned it.
1448        #[arg(long, value_name = "N")]
1449        number: u64,
1450        #[arg(long, value_name = "TEXT")]
1451        body: String,
1452        #[arg(long, value_name = "PRINCIPAL")]
1453        actor: String,
1454        #[arg(long, short = 'i', value_name = "PATH")]
1455        identity: std::path::PathBuf,
1456    },
1457    /// **Close an issue**, naming why. Appended, never deleted — the close
1458    /// stays readable.
1459    Close {
1460        #[arg(long, value_name = "REPO")]
1461        repo: String,
1462        #[arg(long, value_name = "N")]
1463        number: u64,
1464        #[arg(long, value_name = "TEXT", default_value = "")]
1465        reason: String,
1466        #[arg(long, value_name = "PRINCIPAL")]
1467        actor: String,
1468        #[arg(long, short = 'i', value_name = "PATH")]
1469        identity: std::path::PathBuf,
1470    },
1471    /// List every issue in a repository, folded.
1472    List {
1473        #[arg(long, value_name = "REPO")]
1474        repo: String,
1475    },
1476    /// Show one issue: its state and its whole thread.
1477    Show {
1478        #[arg(long, value_name = "REPO")]
1479        repo: String,
1480        #[arg(long, value_name = "N")]
1481        number: u64,
1482    },
1483}
1484
1485/// `gunnar build …` — recording what was built, and from what.
1486///
1487/// The verbs a runner calls. There is no `run` here yet: executing a build
1488/// hermetically is roadmap phase 3's sandbox, and this is the half that makes
1489/// its output *evidence* — a signed record, in a log, joinable to the push that
1490/// landed the source.
1491#[cfg(feature = "server")]
1492#[derive(Debug, Subcommand)]
1493pub enum BuildCommand {
1494    /// **Record a build**: sign what was made, from what, by whom, and append
1495    /// it to the build log.
1496    ///
1497    /// Every artifact is named by the SHA-256 of its bytes, computed here from
1498    /// the file — never taken on trust from an argument, because a digest a
1499    /// caller supplies is a digest a caller can choose.
1500    Attest {
1501        /// `<namespace>/<repo>` the source came from.
1502        #[arg(long, value_name = "REPO")]
1503        repo: String,
1504        /// The commit that was built, hex.
1505        #[arg(long, value_name = "OID")]
1506        commit: String,
1507        /// What the build was asked to make: a recipe, an atom, a variant.
1508        #[arg(long, value_name = "NAME")]
1509        recipe: String,
1510        /// The toolchain, as the builder resolved it.
1511        #[arg(long, value_name = "TEXT", default_value = "")]
1512        toolchain: String,
1513        /// The identity-table principal that ran the build.
1514        #[arg(long, value_name = "PRINCIPAL")]
1515        builder: String,
1516        /// Say that the build **had** network access.
1517        ///
1518        /// Off by default and named in the positive, so a hermetic build is the
1519        /// one nobody has to remember a flag for — and a build that reached the
1520        /// network has to say so out loud.
1521        #[arg(long)]
1522        network: bool,
1523        /// A file the build consumed. Repeatable.
1524        #[arg(long = "material", value_name = "PATH")]
1525        materials: Vec<std::path::PathBuf>,
1526        /// A file the build produced. Repeatable, and at least one is required:
1527        /// a build record with no subject attests to nothing.
1528        #[arg(long = "artifact", value_name = "PATH", required = true)]
1529        artifacts: Vec<std::path::PathBuf>,
1530    },
1531    /// **A CycloneDX SBOM for an artifact**, out of the `Cargo.lock` that was
1532    /// locked when it was built.
1533    ///
1534    /// The lockfile and not `cargo metadata`: the lock is what the build
1535    /// resolved and is in the commit, so the document is a function of the
1536    /// source and can be reproduced from it. `cargo metadata` re-resolves
1537    /// against the index as it stands now, which describes a build that may
1538    /// never have happened.
1539    Sbom {
1540        /// The `Cargo.lock` the build used.
1541        #[arg(long, value_name = "PATH")]
1542        lock: std::path::PathBuf,
1543        /// The artifact the document is about.
1544        #[arg(long, value_name = "PATH")]
1545        artifact: std::path::PathBuf,
1546    },
1547    /// **Ask the registry's one question**: may these bytes land?
1548    ///
1549    /// No artifact without valid, logged provenance. Exits zero only when a
1550    /// build record names the artifact, verifies, sits in the build log, and is
1551    /// covered by a signed tree head — and names which of the four is missing
1552    /// when it refuses.
1553    Admit {
1554        /// The artifact to ask about.
1555        #[arg(long, value_name = "PATH")]
1556        artifact: std::path::PathBuf,
1557    },
1558    /// **Build a commit and attest to what came out.**
1559    ///
1560    /// Materialises exactly that commit from the store, builds it in a network
1561    /// namespace of its own, hashes the artifacts and signs a record naming the
1562    /// commit — which is the join `gunnar verify --artifact` walks.
1563    ///
1564    /// Refuses rather than building when the sandbox cannot be entered: a
1565    /// record that says `network: false` has to mean it on every machine.
1566    Run {
1567        /// `<namespace>/<repo>`.
1568        #[arg(long, value_name = "REPO")]
1569        repo: String,
1570        /// The commit to build. A ref name or a hex oid; defaults to the
1571        /// repository's HEAD.
1572        #[arg(long, default_value = "HEAD", value_name = "REV")]
1573        at: String,
1574        /// Where checkouts and build output go.
1575        #[arg(long, value_name = "PATH")]
1576        workspace: std::path::PathBuf,
1577        /// The identity-table principal the record is attributed to.
1578        #[arg(long, value_name = "PRINCIPAL")]
1579        builder: String,
1580        /// **Build with the network up.** Off by default, and recorded in the
1581        /// signed record when it is on, so a non-hermetic build is never
1582        /// mistaken for a hermetic one.
1583        #[arg(long)]
1584        allow_network: bool,
1585    },
1586    /// **Watch for pushes and build them.**
1587    ///
1588    /// The step the golden path calls "nothing at all": a push lands, this
1589    /// builds it hermetically and signs a record, and nobody types anything.
1590    ///
1591    /// Runs in the serving process — `gunnar serve --runner` is the same thing
1592    /// wired to the same bus. One build at a time, on purpose.
1593    Watch {
1594        /// Where checkouts and build output go.
1595        #[arg(long, value_name = "PATH")]
1596        workspace: std::path::PathBuf,
1597        /// The identity-table principal every record is attributed to.
1598        #[arg(long, value_name = "PRINCIPAL")]
1599        builder: String,
1600        /// Only build these refs. Repeatable; empty means every branch.
1601        #[arg(long = "ref", value_name = "REF")]
1602        refs: Vec<String>,
1603        /// Build with the network up. Recorded in every record it produces.
1604        #[arg(long)]
1605        allow_network: bool,
1606        /// Stop after this many builds, instead of watching for ever.
1607        #[arg(long, value_name = "N")]
1608        max_builds: Option<usize>,
1609    },
1610    /// **Build an OCI image around an attested artifact.**
1611    ///
1612    /// `FROM scratch`, one layer, one file — so "zero CVEs" is a statement
1613    /// about surface area rather than about diligence, and the file count is
1614    /// printed so it stays a number.
1615    ///
1616    /// The artifact must already be admitted: an image built from a binary
1617    /// nobody attested to would be an image whose chain stops one link short.
1618    /// The image's own manifest digest is then attested in turn, which is what
1619    /// lets the registry take it.
1620    Image {
1621        /// The binary to put in the image.
1622        #[arg(long, value_name = "PATH")]
1623        artifact: std::path::PathBuf,
1624        /// Where it lands inside the image, and what it is run as.
1625        #[arg(long, value_name = "PATH", default_value = "/gunnar")]
1626        entrypoint: String,
1627        /// The identity-table principal the image's record is attributed to.
1628        #[arg(long, value_name = "PRINCIPAL")]
1629        builder: String,
1630    },
1631    /// List what this server has attested to building.
1632    List,
1633    /// The public key a verifier pins to check build records.
1634    Key,
1635}
1636
1637/// `gunnar verify` — the chain, walked backwards (roadmap phase 2).
1638#[cfg(feature = "server")]
1639#[derive(Debug, clap::Args)]
1640pub struct VerifyArgs {
1641    /// `<namespace>/<repo>`. Omit it when `--artifact` is given: the build
1642    /// record names the repository the artifact was built from, and taking it
1643    /// from the command line instead would let a caller ask about a different
1644    /// one than the record claims.
1645    #[arg(value_name = "REPO", required_unless_present = "artifact")]
1646    pub repo: Option<String>,
1647    /// **Start at an artifact**, by its lowercase-hex SHA-256.
1648    ///
1649    /// The direction a verifier actually stands in: it holds a binary and wants
1650    /// the source. The build record that claims to have produced it supplies
1651    /// the repository and the commit, and the walk continues from there.
1652    #[arg(long, value_name = "SHA256", conflicts_with = "at")]
1653    pub artifact: Option<String>,
1654    /// The revision to start from: a branch, a tag, `HEAD`, or a hex oid.
1655    #[arg(long, default_value = "HEAD", value_name = "REV")]
1656    pub at: String,
1657    /// `<path>:<line>`, one-based — the way an editor and `git blame` count.
1658    ///
1659    /// Without it the chain starts at the revision's own commit and link 1 is
1660    /// reported as unchecked rather than as passed.
1661    #[arg(long, value_name = "PATH:LINE")]
1662    pub line: Option<String>,
1663}
1664
1665/// `gunnar blame <ns>/<repo> <path> [--rev REV]`.
1666#[cfg(feature = "server")]
1667#[derive(Debug, clap::Args)]
1668pub struct BlameArgs {
1669    /// `<namespace>/<repo>`.
1670    #[arg(value_name = "REPO")]
1671    pub repo: String,
1672    /// The file to blame, as a path within the repository's tree.
1673    #[arg(value_name = "PATH")]
1674    pub path: String,
1675    /// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
1676    #[arg(long, default_value = "HEAD", value_name = "REV")]
1677    pub rev: String,
1678}
1679
1680/// `gunnar search <ns>/<repo> <pattern> [--rev REV] [--max-*]`.
1681#[cfg(feature = "server")]
1682#[derive(Debug, clap::Args)]
1683pub struct SearchArgs {
1684    /// `<namespace>/<repo>`.
1685    #[arg(value_name = "REPO")]
1686    pub repo: String,
1687    /// The literal string to look for. Not a regular expression — see the verb's
1688    /// help for why the appliance does not link one yet.
1689    #[arg(value_name = "PATTERN")]
1690    pub pattern: String,
1691    /// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only this
1692    /// one revision is searched, not the history behind it.
1693    #[arg(long, default_value = "HEAD", value_name = "REV")]
1694    pub rev: String,
1695    /// Skip files larger than this many bytes (they are never read).
1696    #[arg(long, default_value_t = 1 << 20, value_name = "BYTES")]
1697    pub max_file_bytes: u64,
1698    /// Stop after this many matches.
1699    #[arg(long, default_value_t = 1000, value_name = "N")]
1700    pub max_matches: usize,
1701}
1702
1703/// The transparency log's verbs, over the push attestations.
1704///
1705/// The log itself is `attestations.jsonl` and is written by every push. These
1706/// verbs do not write records; they sign, check and prove over the records that
1707/// are already there — which is why none of them can change what the log says.
1708#[cfg(feature = "server")]
1709#[derive(Debug, Subcommand)]
1710pub enum AttestCommand {
1711    /// **Sign what the log holds right now**, and append the head.
1712    ///
1713    /// Issue one whenever a claim about the log needs to be checkable later —
1714    /// after a release, on a timer, before an audit. Each head pins every
1715    /// record beneath it, so a rewrite of an old line is caught by every head
1716    /// issued since, including by somebody who never saw the original line.
1717    Head {
1718        /// Head the **build** log instead of the push-attestation log.
1719        ///
1720        /// Two logs, two heads: a head over one says nothing about the other,
1721        /// and signing both under one root would make a build record's proof
1722        /// depend on a push it has nothing to do with.
1723        #[arg(long)]
1724        builds: bool,
1725    },
1726    /// **Check every signed head against the records on disk.**
1727    ///
1728    /// The one verb that can say the log was tampered with. It keeps three
1729    /// failures apart on purpose: a head that does not verify (somebody wrote a
1730    /// head they could not sign, or the key rotated), a head that verifies over
1731    /// a root the records no longer produce (the records were changed), and a
1732    /// head covering more records than exist (lines were removed).
1733    Audit {
1734        /// Audit the **build** log instead of the push-attestation log.
1735        #[arg(long)]
1736        builds: bool,
1737    },
1738    /// The public key a verifier pins to check this server's tree heads.
1739    ///
1740    /// An OpenSSH public-key line, so `ssh-keygen -Y verify` can check a head
1741    /// with no gunnar involved.
1742    Key,
1743    /// **The inclusion proof for one push**, by the pack it landed.
1744    ///
1745    /// What a client checks offline: the record's position, the tree size and
1746    /// the sibling hashes. With a signed head it proves the push is in the log
1747    /// without trusting the server that served the proof.
1748    Prove {
1749        /// The `pack_hash` the attestation carries.
1750        #[arg(value_name = "PACK-HASH")]
1751        pack_hash: String,
1752    },
1753}
1754
1755/// **The certificate issuer.** Every verb but `show` writes, and writing needs
1756/// the storage half; `show` is a read and is here in either build.
1757#[cfg(any(feature = "server", feature = "control"))]
1758#[derive(Debug, Subcommand)]
1759pub enum IssuerCommand {
1760    /// Mint this server's issuer. **Refused if it already has one.**
1761    #[cfg(feature = "server")]
1762    Create {
1763        /// The subject common name the CA certificate carries.
1764        #[arg(long, default_value = "gunnar issuer")]
1765        subject: String,
1766        /// How long it is good for.
1767        #[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_ISSUER_DAYS)]
1768        days: u32,
1769    },
1770    /// Adopt an issuer that already exists. **Refused if this server has one.**
1771    ///
1772    /// Both halves are checked against each other before anything is stored: a
1773    /// certificate that is not this key's is refused, rather than accepted and
1774    /// discovered on the first `principal add`.
1775    #[cfg(feature = "server")]
1776    Import {
1777        /// The CA certificate, PEM.
1778        #[arg(long, value_name = "PATH")]
1779        cert: std::path::PathBuf,
1780        /// Its private key, PEM. It goes straight into the sealed secret store
1781        /// and never into the control database.
1782        #[arg(long, value_name = "PATH")]
1783        key: std::path::PathBuf,
1784    },
1785    /// What is live: the issuer, and every CA this server trusts.
1786    ///
1787    /// **One verb, two places to stand.** With no `--at` it reads the data
1788    /// root under your feet, as it always has. With `--at <addr>` it asks a
1789    /// RUNNING server the same question over the control plane
1790    /// (`Admin.IssuerInfo`, server-wide `Read`) and prints the same lines.
1791    ///
1792    /// There is no second verb for the remote read, and that is a decision.
1793    /// `gunnar namespace` and `gunnar control namespace` are split because
1794    /// they are WRITES with different preconditions — the offline one needs
1795    /// the server stopped. This is a read of one document with no
1796    /// precondition at all: the answer is identical from either side, out of
1797    /// the same `pki_readonly` seam, and two spellings of one question would
1798    /// be two renderings to keep in step.
1799    ///
1800    /// # Why the wire half exists
1801    ///
1802    /// The appliance has no shell, so `--data-root` is unreachable on the box
1803    /// where it matters. The issuer certificate was otherwise handed out by
1804    /// exactly one RPC, `Bootstrap.Enroll`, whose window shuts for good on the
1805    /// first enrol — so a RESTORED appliance could never again tell anyone
1806    /// which CA it mints with, which is precisely when "is this still the CA
1807    /// my clients pinned?" needs an answer.
1808    Show {
1809        /// Print the issuer certificate itself, so it can be piped to a client.
1810        #[arg(long)]
1811        pem: bool,
1812        /// Ask a running server instead of this host's data root.
1813        ///
1814        /// No default and no environment variable on purpose: `issuer show`
1815        /// must not quietly start answering about a different machine because
1816        /// `GUNNAR_CONTROL_ADDR` was exported in the shell.
1817        #[arg(long, value_name = "ADDR", long_help = CONTROL_ADDR_HELP)]
1818        at: Option<String>,
1819        /// The private key to sign the call with. Only with `--at`.
1820        #[arg(long, short = 'i', value_name = "PATH", requires = "at")]
1821        identity: Option<PathBuf>,
1822    },
1823    /// Trust a CA this server does not mint with — the other half of a rotation.
1824    #[cfg(feature = "server")]
1825    Trust {
1826        /// A label. Never part of the credential.
1827        #[arg(long, default_value = "")]
1828        label: String,
1829        /// The CA certificate, PEM.
1830        #[arg(long, value_name = "PATH")]
1831        cert: std::path::PathBuf,
1832    },
1833    /// Stop trusting a CA. Refuses to remove the live issuer's own entry.
1834    #[cfg(feature = "server")]
1835    Untrust {
1836        /// The `SHA256:` fingerprint `issuer show` prints.
1837        fingerprint: String,
1838    },
1839    /// **Can this server be moved to `serve --grpc-tls-self`, and who does it
1840    /// cost?**
1841    ///
1842    /// Mutual TLS on the control plane refuses every peer that cannot present
1843    /// a certificate this server's issuer signed. That is the point of it, and
1844    /// it is also a cliff: a principal that holds only an SSH key can
1845    /// authenticate today and cannot complete a handshake afterwards. This
1846    /// prints, per principal, which side of the cliff they are on, and ends
1847    /// with the verdict `gunnar serve` itself will reach — it is the same
1848    /// function, asked in advance, so the two cannot disagree.
1849    ///
1850    /// Offline and local: it reads this host's identity table, because
1851    /// `--grpc-tls-self` is a serve flag and whoever flips it is already here.
1852    /// It changes nothing.
1853    #[cfg(feature = "server")]
1854    Readiness,
1855}
1856
1857/// Principal verbs.
1858#[cfg(feature = "server")]
1859#[derive(Debug, Subcommand)]
1860pub enum PrincipalCommand {
1861    /// Add a principal, and optionally issue its certificate.
1862    Add {
1863        /// A stable identifier. Grants name it, and it is the `CN` the
1864        /// certificate carries.
1865        id: String,
1866        /// A display name. Defaults to the id.
1867        #[arg(long)]
1868        name: Option<String>,
1869        /// What sort of actor this is.
1870        #[arg(long, value_enum, default_value_t = PrincipalKindArg::User)]
1871        kind: PrincipalKindArg,
1872        /// **Issue from a certificate signing request. This is the strong
1873        /// form.**
1874        ///
1875        /// The private key is generated on the machine that will use it and
1876        /// never leaves it, so this server is structurally incapable of
1877        /// impersonating the principal. Whatever subject the request claims is
1878        /// discarded: the certificate is issued under `id`.
1879        #[arg(long, value_name = "PATH", conflicts_with = "server_generated_key")]
1880        csr: Option<std::path::PathBuf>,
1881        /// **Generate the private key here and hand it back.** The convenient
1882        /// form, and the weaker one.
1883        ///
1884        /// The key existed on this server and travelled over the wire, so the
1885        /// server could have kept a copy and nothing in the protocol proves it
1886        /// did not — *"only alice could have signed this"* stops being true. It
1887        /// is spelled out rather than being what you get by not thinking about
1888        /// it, because a machine account being bootstrapped genuinely needs it.
1889        #[arg(long)]
1890        server_generated_key: bool,
1891        /// Where the issued `<id>_cert.pem` (and, for
1892        /// `--server-generated-key`, `<id>_key.pem`) are written.
1893        #[arg(long, value_name = "DIR", default_value = ".")]
1894        out: std::path::PathBuf,
1895        /// How long the issued certificate is good for.
1896        #[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_LEAF_DAYS)]
1897        days: u32,
1898    },
1899    /// List principals.
1900    List,
1901    /// Remove a principal, with its keys and grants.
1902    Remove {
1903        /// The principal id.
1904        id: String,
1905    },
1906}
1907
1908/// Key verbs.
1909#[cfg(feature = "server")]
1910#[derive(Debug, Subcommand)]
1911pub enum TokenCommand {
1912    /// Mint an access token for a principal, and **print it once**.
1913    ///
1914    /// The secret is not stored: only its sha-256 is. Nothing in gunnar can
1915    /// print it again, which is why this is the only place it appears.
1916    Add {
1917        /// The principal the token authenticates as. It gets exactly that
1918        /// principal's grants and nothing else.
1919        principal: String,
1920        /// A label — which laptop, which CI job. Never part of the credential.
1921        #[arg(long, default_value = "")]
1922        label: String,
1923        /// Stop accepting it after this many days.
1924        #[arg(long, value_name = "DAYS")]
1925        expires_in: Option<u64>,
1926    },
1927    /// List tokens by their hash, which is what `revoke` takes.
1928    List {
1929        /// Only this principal's.
1930        #[arg(long)]
1931        principal: Option<String>,
1932    },
1933    /// Revoke a token by the hash `list` prints.
1934    Revoke {
1935        /// The token hash.
1936        hash: String,
1937    },
1938}
1939
1940/// WireGuard verbs.
1941///
1942/// One verb, and it exists because provisioning an OS-less fleet needs a key
1943/// and `wg genkey` is a SHELL — a thing this product does not have, on the
1944/// appliance or in its build. The curve is already linked into this binary for
1945/// the tunnel itself, so minting a key costs nothing that was not already
1946/// there.
1947#[cfg(feature = "server")]
1948#[derive(Debug, Subcommand)]
1949pub enum WgCommand {
1950    /// Mint a fresh x25519 keypair — what `wg genkey | wg pubkey` prints,
1951    /// without a shell.
1952    Keygen {
1953        /// Write the PRIVATE key here (one base64 line, mode 0600) instead of
1954        /// printing it, and print only the public half.
1955        ///
1956        /// This is the `wg0.key` an appliance is seeded with. An existing file
1957        /// is REFUSED rather than overwritten: a regenerated identity is a
1958        /// silent fleet-wide outage — every hub still allowlists the old public
1959        /// key, and a peer whose key is unknown gets silence, not an error.
1960        #[arg(long, value_name = "PATH")]
1961        private: Option<std::path::PathBuf>,
1962    },
1963}
1964
1965/// Public-key verbs.
1966#[cfg(feature = "server")]
1967#[derive(Debug, Subcommand)]
1968pub enum KeyCommand {
1969    /// Register an OpenSSH public key line to a principal.
1970    Add {
1971        /// The principal id.
1972        principal: String,
1973        /// An OpenSSH public key line, or `@<path>` to read one from a file.
1974        ///
1975        /// Omit when registering a certificate with `--cert`.
1976        #[arg(required_unless_present = "cert")]
1977        key: Option<String>,
1978        /// Register an X.509 certificate instead, from a PEM or DER file.
1979        ///
1980        /// The mTLS credential. It lands in the same table as an SSH key and
1981        /// under the same principal, because a fingerprint is all gunnar knows
1982        /// about a credential — only how it is presented differs.
1983        #[arg(long, conflicts_with = "key", value_name = "PATH")]
1984        cert: Option<std::path::PathBuf>,
1985    },
1986    /// List registered keys.
1987    List {
1988        /// Only this principal's keys.
1989        #[arg(long)]
1990        principal: Option<String>,
1991    },
1992    /// Revoke a key by its `SHA256:` fingerprint.
1993    Revoke {
1994        /// The fingerprint, exactly as `ssh-keygen -lf` prints it.
1995        fingerprint: String,
1996    },
1997}
1998
1999/// `gunnar grant`.
2000#[cfg(feature = "server")]
2001#[derive(Debug, clap::Args)]
2002#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
2003pub struct GrantArgs {
2004    /// The principal id.
2005    pub principal: String,
2006    /// How much access.
2007    #[arg(value_enum)]
2008    pub level: LevelArg,
2009    /// Scope the grant to the whole server (the bootstrap admin's scope).
2010    #[arg(long)]
2011    pub server: bool,
2012    /// Scope the grant to one namespace, matched EXACTLY: `team` does not reach
2013    /// `team/sub`.
2014    #[arg(long, value_name = "NS")]
2015    pub namespace: Option<String>,
2016    /// Scope the grant to one repository, `<namespace>/<name>`.
2017    #[arg(long, value_name = "NS/NAME")]
2018    pub repo: Option<String>,
2019}
2020
2021/// `gunnar revoke`.
2022#[cfg(feature = "server")]
2023#[derive(Debug, clap::Args)]
2024#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
2025pub struct RevokeArgs {
2026    /// The principal id.
2027    pub principal: String,
2028    /// The server-wide grant.
2029    #[arg(long)]
2030    pub server: bool,
2031    /// A namespace grant.
2032    #[arg(long, value_name = "NS")]
2033    pub namespace: Option<String>,
2034    /// A repository grant.
2035    #[arg(long, value_name = "NS/NAME")]
2036    pub repo: Option<String>,
2037}
2038
2039/// `gunnar reflog`.
2040///
2041/// Reads the archive `gunnar-store`'s compaction has always written and until
2042/// now nothing read. It opens **no store**, so it works against a repository a
2043/// running server holds the exclusive lock on — which is the only kind of
2044/// repository anyone wants the history of.
2045#[cfg(feature = "server")]
2046#[derive(Debug, clap::Args)]
2047pub struct ReflogArgs {
2048    /// The repository, `<namespace>/<name>`. Omit for every one.
2049    pub path: Option<String>,
2050
2051    /// Write the sealed history to this path instead of listing it.
2052    ///
2053    /// One Arrow IPC stream, one batch, the push boundary carried as a
2054    /// `push_seq` column rather than as a frame boundary — measured at 6.68x
2055    /// smaller per push than the live log it seals. With more than one
2056    /// repository this is treated as a **directory** and one artifact is
2057    /// written per repository, because a single file would silently keep only
2058    /// the last.
2059    #[arg(long, value_name = "PATH")]
2060    pub seal: Option<std::path::PathBuf>,
2061
2062    /// Print at most this many of the most recent transactions per repository.
2063    #[arg(long, default_value_t = 20, value_name = "N")]
2064    pub limit: usize,
2065
2066    /// Print every transaction, ignoring `--limit`.
2067    #[arg(long, conflicts_with = "limit")]
2068    pub all: bool,
2069}
2070
2071/// `gunnar gc`.
2072///
2073/// `Default` exists so that a caller — the tests, and any future programmatic
2074/// `gc` — can name the one field it cares about and inherit the verb's real
2075/// defaults for the rest. Spelling every flag out at each call site is how a
2076/// test ends up pinned to `Preserve` because that was the easiest literal to
2077/// write, and then guards nothing.
2078///
2079/// **It is hand-written, and it has to be.** A derive gives `0` for every
2080/// integer, and `--window 0`/`--depth 0` is a delta search that can never find
2081/// a base: every `GcArgs { .. , ..Default::default() }` in the tests would
2082/// silently stop deltifying and every one of them would still pass, because a
2083/// pack of whole objects is a valid pack. `gc_default_matches_the_command_line`
2084/// asserts the two agree, field by field.
2085#[cfg(feature = "server")]
2086#[derive(Debug, clap::Args)]
2087pub struct GcArgs {
2088    /// The repository to maintain, `<namespace>/<name>`. Omit for every one.
2089    pub path: Option<String>,
2090
2091    /// Also drop objects nothing reaches.
2092    ///
2093    /// Off unless asked, because reachability is computed from the refs as they
2094    /// are at this instant and a push landing mid-run is a race the grace
2095    /// window below exists to cover.
2096    #[arg(long)]
2097    pub prune: bool,
2098
2099    /// How long an unreachable object is kept before `--prune` may drop it.
2100    #[arg(long, value_name = "DURATION", default_value = "2w", value_parser = parse_prune_grace)]
2101    pub prune_grace: std::time::Duration,
2102}
2103
2104/// `now`, `<N>`, or `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
2105///
2106/// Hand-written rather than pulling in a date parser: the dependency budget is
2107/// a guarded number (`xtask guard`) and this is eleven lines. It deliberately
2108/// does **not** accept git's full `approxidate` grammar (`2.weeks.ago`), because
2109/// half-supporting a grammar is how `--prune=yesterday` ends up silently
2110/// meaning zero.
2111#[cfg(feature = "server")]
2112fn parse_prune_grace(raw: &str) -> std::result::Result<std::time::Duration, String> {
2113    parse_duration_words(raw, "grace period", "2w")
2114}
2115
2116/// `--max-silence` on `control twin replay`: the same grammar as [`parse_prune_grace`].
2117#[cfg(feature = "control")]
2118fn parse_max_silence(raw: &str) -> std::result::Result<std::time::Duration, String> {
2119    parse_duration_words(raw, "silence", "24h")
2120}
2121
2122/// The grammar of [`parse_prune_grace`], naming `what` and an `example` in its refusals.
2123#[cfg(any(feature = "server", feature = "control"))]
2124fn parse_duration_words(raw: &str, what: &str, example: &str) -> std::result::Result<std::time::Duration, String> {
2125    let raw = raw.trim();
2126    if raw.eq_ignore_ascii_case("now") {
2127        return Ok(std::time::Duration::ZERO);
2128    }
2129    let (digits, unit) = match raw.as_bytes().last() {
2130        Some(b) if b.is_ascii_digit() => (raw, 1u64),
2131        Some(b's') => (&raw[..raw.len() - 1], 1),
2132        Some(b'm') => (&raw[..raw.len() - 1], 60),
2133        Some(b'h') => (&raw[..raw.len() - 1], 60 * 60),
2134        Some(b'd') => (&raw[..raw.len() - 1], 24 * 60 * 60),
2135        Some(b'w') => (&raw[..raw.len() - 1], 7 * 24 * 60 * 60),
2136        _ => {
2137            return Err(format!(
2138                "{raw:?} is not a {what}; write `now`, a number of seconds, or a number \
2139                 with a s/m/h/d/w suffix such as `{example}`"
2140            ))
2141        }
2142    };
2143    let n: u64 = digits.parse().map_err(|_| {
2144        format!(
2145            "{raw:?} is not a {what}: {digits:?} is not a number. Write `now`, a number \
2146             of seconds, or a number with a s/m/h/d/w suffix such as `{example}`"
2147        )
2148    })?;
2149    n.checked_mul(unit)
2150        .map(std::time::Duration::from_secs)
2151        .ok_or_else(|| format!("{raw:?} is not a {what}: it does not fit in a duration"))
2152}
2153
2154#[cfg(feature = "server")]
2155impl Default for GcArgs {
2156    fn default() -> Self {
2157        GcArgs {
2158            path: None,
2159            prune: false,
2160            // The same value as `default_value = "2w"` above;
2161            // `gc_default_matches_the_command_line` fails if they drift.
2162            prune_grace: std::time::Duration::from_secs(14 * 24 * 60 * 60),
2163        }
2164    }
2165}
2166
2167#[cfg(feature = "server")]
2168impl GcArgs {}
2169
2170/// The hash kinds gunnar knows.
2171#[cfg(any(feature = "server", feature = "control"))]
2172#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2173pub enum HashArg {
2174    /// Git's original hash.
2175    Sha1,
2176    /// The default for new repositories.
2177    Sha256,
2178}
2179
2180#[cfg(feature = "server")]
2181impl From<HashArg> for gunnar_core::HashKind {
2182    fn from(arg: HashArg) -> Self {
2183        match arg {
2184            HashArg::Sha1 => gunnar_core::HashKind::Sha1,
2185            HashArg::Sha256 => gunnar_core::HashKind::Sha256,
2186        }
2187    }
2188}
2189
2190/// Grant levels, ordered `read < write < admin`.
2191///
2192/// Shared by the offline `gunnar grant` and the online `gunnar control grant`,
2193/// so `--level admin` means one thing whichever side of the wire it is spelled
2194/// on (one implementation).
2195#[cfg(any(feature = "server", feature = "control"))]
2196#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2197pub enum LevelArg {
2198    /// `git-upload-pack`: clone and fetch.
2199    Read,
2200    /// `git-receive-pack`: push. Implies read.
2201    Write,
2202    /// Administer. Implies write.
2203    Admin,
2204}
2205
2206#[cfg(feature = "control")]
2207impl LevelArg {
2208    /// The proto enum this level is on the wire.
2209    pub fn to_proto(self) -> gunnar_proto::GrantLevel {
2210        match self {
2211            LevelArg::Read => gunnar_proto::GrantLevel::Read,
2212            LevelArg::Write => gunnar_proto::GrantLevel::Write,
2213            LevelArg::Admin => gunnar_proto::GrantLevel::Admin,
2214        }
2215    }
2216}
2217
2218#[cfg(feature = "server")]
2219impl From<LevelArg> for gunnar_core::Level {
2220    fn from(arg: LevelArg) -> Self {
2221        match arg {
2222            LevelArg::Read => gunnar_core::Level::Read,
2223            LevelArg::Write => gunnar_core::Level::Write,
2224            LevelArg::Admin => gunnar_core::Level::Admin,
2225        }
2226    }
2227}
2228
2229/// The three kinds of principal in the identity table.
2230#[cfg(any(feature = "server", feature = "control"))]
2231#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2232pub enum PrincipalKindArg {
2233    /// A human.
2234    User,
2235    /// CI, a mirror, a robot with its own identity.
2236    Machine,
2237    /// A key scoped to one repository.
2238    Deploy,
2239    /// A group of principals (punkt 9): holds no key, is granted access like
2240    /// any principal, and its members inherit those grants. Prefer
2241    /// `gunnar control group add`, which is this with a name for it.
2242    Group,
2243}
2244
2245#[cfg(feature = "server")]
2246impl From<PrincipalKindArg> for gunnar_core::PrincipalKind {
2247    fn from(arg: PrincipalKindArg) -> Self {
2248        match arg {
2249            PrincipalKindArg::User => gunnar_core::PrincipalKind::User,
2250            PrincipalKindArg::Machine => gunnar_core::PrincipalKind::Machine,
2251            PrincipalKindArg::Deploy => gunnar_core::PrincipalKind::Deploy,
2252            PrincipalKindArg::Group => gunnar_core::PrincipalKind::Group,
2253        }
2254    }
2255}
2256
2257// ───────────────────────────────────────────────────────────────────────────
2258// the control plane, over the wire
2259// ───────────────────────────────────────────────────────────────────────────
2260
2261/// Where a `gunnar control` verb sends its call.
2262///
2263/// Its own arg rather than a global one, because it must appear in the
2264/// `--help` of each verb that uses it: a first-time user reading
2265/// `gunnar control bootstrap --help` has to see where it is about to send an
2266/// unauthenticated credential-granting call.
2267///
2268/// Declared in the SERVER build too, because `gunnar issuer show --at` is:
2269/// that binary must still parse the flag in order to refuse it BY NAME —
2270/// telling an operator "this binary carries no control client" rather than
2271/// "unexpected argument" — which is the same asymmetry `--monetize-pubkey`
2272/// has in the open build. It is a `&str`; it pulls in nothing.
2273#[cfg(any(feature = "control", feature = "server"))]
2274pub const CONTROL_ADDR_HELP: &str =
2275    "Where the control plane is. gunnar binds it to LOOPBACK by default, so the default here is \
2276     this host's server. To reach another host, tunnel to its loopback \
2277     (`ssh -L 50051:127.0.0.1:50051 <host>`) rather than binding it publicly. An estate is \
2278     reached through its site, `--at https://<site>`: its front carries the call, signed with \
2279     your key, to the main (GUNNAR_SITE_CA names the site's own CA, if a public one did not \
2280     issue its certificate).";
2281
2282/// `gunnar control admit …`
2283#[cfg(feature = "control")]
2284#[derive(Debug, Subcommand)]
2285pub enum AdmitCommand {
2286    /// How each kind of transfer for one namespace would be admitted
2287    /// (`Usage.AdmitPlan`).
2288    Show {
2289        #[arg(value_name = "NAMESPACE")]
2290        namespace: String,
2291        #[arg(long, short = 'i', value_name = "PATH")]
2292        identity: Option<PathBuf>,
2293        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2294        at: String,
2295    },
2296}
2297
2298/// `gunnar control …`
2299#[cfg(feature = "control")]
2300#[derive(Debug, Subcommand)]
2301pub enum ControlCommand {
2302    /// Ask a server whether it has been enrolled. **Needs no credential.**
2303    ///
2304    /// It is the one question a server that nobody holds a key for can answer,
2305    /// and an unenrolled server is exactly that.
2306    Status {
2307        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2308        at: String,
2309    },
2310
2311    /// **Step 1: claim a fresh server.** Creates the first user, who is
2312    /// gunnar-admin. Needs NO credential — and works exactly once, ever.
2313    ///
2314    /// The window is open only while no identity has ever been enrolled. It
2315    /// closes permanently on the first success, and deleting every user does
2316    /// not reopen it.
2317    Bootstrap {
2318        /// Your name. It is also your ACCOUNT: you are always admin of
2319        /// `<host>/<name>/<repo>`, and that is not a grant anybody can revoke.
2320        name: String,
2321        /// An OpenSSH public key line, or `@<path>`. Defaults to the public
2322        /// half of the key `-i` names, so neither flag is usually needed.
2323        #[arg(long, value_name = "KEY")]
2324        key: Option<String>,
2325        /// The private key whose public half to enrol, and to authenticate
2326        /// with afterwards.
2327        #[arg(long, short = 'i', value_name = "PATH")]
2328        identity: Option<PathBuf>,
2329        /// Where the client certificate the server issues (`<name>.cert.pem`,
2330        /// `<name>.key.pem`, `gunnar-issuer.pem`) is written. Defaults to a
2331        /// directory of your own, `~/.config/gunnar/<server>/`, created 0700 —
2332        /// never the current directory, which is a git checkout more often
2333        /// than not, and a private key in a checkout is one `git add` from a
2334        /// remote.
2335        #[arg(long, value_name = "DIR")]
2336        out: Option<PathBuf>,
2337        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2338        at: String,
2339    },
2340
2341    /// **Step 3: log in** with your ssh key, and see what you are.
2342    ///
2343    /// Writes nothing. gunnar keeps no session on either side: every call signs
2344    /// a fresh single-use credential, so there is no state for `bootstrap` to
2345    /// have left behind and none for this to create.
2346    Login {
2347        #[arg(long, short = 'i', value_name = "PATH")]
2348        identity: Option<PathBuf>,
2349        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2350        at: String,
2351    },
2352
2353    /// **Step 4: users.** Authenticated, and creating one needs gunnar-admin.
2354    #[command(subcommand)]
2355    User(ControlUserCommand),
2356
2357    /// **Groups: grant access to a team, not one person at a time** (punkt 9).
2358    ///
2359    /// A group is a principal that holds no key. Grant it access with the
2360    /// ordinary `gunnar control grant <group> …`, add people to it, and they
2361    /// inherit the grant; remove them and they lose it. Managing a group needs
2362    /// gunnar-admin, exactly like a grant.
2363    #[command(subcommand)]
2364    Group(ControlGroupCommand),
2365
2366    /// Repositories, against a running server.
2367    ///
2368    /// You may always create one in your OWN account with no grant at all;
2369    /// somebody else's needs a grant on their namespace.
2370    #[command(subcommand)]
2371    Repo(ControlRepoCommand),
2372
2373    /// **Is the record of what happened on this box still complete?**
2374    /// (`Admin.AuditTrail`, server-wide `Read`.)
2375    ///
2376    /// The control plane has written a tamper-evident hash chain of its
2377    /// decisions since GUN-044, and until 2026-09-21 there was no way to read
2378    /// it except as root on the box — which an appliance does not have. This
2379    /// prints the chain's shape: how many entries, the tip hash, whether the
2380    /// chain still verifies, and whether it is replicated to the twin.
2381    ///
2382    /// It prints no entry. Who did what is a read of the file; this verb
2383    /// answers completeness.
2384    ///
2385    /// # After a restore
2386    ///
2387    /// A restored box keeps appending, so its TIP has moved by the time you
2388    /// ask. Give `--at-seq <N>` the last sequence the old primary reported and
2389    /// compare the `hash at seq` line: because every entry's hash commits to
2390    /// its `prev`, one matching hash proves the whole prefix came back
2391    /// unaltered.
2392    Audit {
2393        /// Ask for the hash this chain carried at this sequence number — the
2394        /// prefix proof a restore is checked with.
2395        #[arg(long, value_name = "N")]
2396        at_seq: Option<u64>,
2397        #[arg(long, short = 'i', value_name = "PATH")]
2398        identity: Option<PathBuf>,
2399        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2400        at: String,
2401    },
2402
2403    /// Namespaces, against a running server.
2404    ///
2405    /// The offline `gunnar namespace` writes the control repository directly
2406    /// and therefore needs the server **stopped**. These do not.
2407    #[command(subcommand)]
2408    Namespace(ControlNamespaceCommand),
2409
2410    /// **Accounts: lock a user out, and empty their account for good.**
2411    ///
2412    /// The per-USER half of what `repo` does per repository. `repo delete
2413    /// --purge` reaches one repository at a time; an account can hold hundreds
2414    /// of them plus soft-delete tombstones and orphan stores that no other
2415    /// listing shows, and there was no verb whose unit was a *user*.
2416    ///
2417    /// Every one of these needs **gunnar-admin** — Admin at server scope, not
2418    /// on the namespace. A namespace check would be self-granting: you are
2419    /// unconditionally admin of your own account, so it would let any user
2420    /// hard-purge themselves with one call and no undo.
2421    #[command(subcommand)]
2422    Account(ControlAccountCommand),
2423
2424    /// **Clans: ordinary accounts grouped, each giving part of its own room.**
2425    ///
2426    /// A clan has a namespace of its own (private, like every account's) and
2427    /// an admin — its creator — who invites accounts and administers what is
2428    /// made in it: visibility, who else may reach it, deletion. Its members
2429    /// read, write and create private repositories there. Its room is the sum
2430    /// of what its members give out of their own; a contribution goes down
2431    /// only as far as what the clan stores, and leaving gives it back.
2432    /// Every verb is YOURS: gunnar decides from the clan who may.
2433    #[command(subcommand)]
2434    Clan(ControlClanCommand),
2435
2436    /// **Pull mirroring: where this server's copy comes FROM.**
2437    ///
2438    /// The pull direction, and the exact mirror of `replication` below: that one
2439    /// says where a second copy of gunnar's repositories is pushed TO, this one
2440    /// says which other forge a repository here is pulled from. One service on
2441    /// the wire, because "where does this repository's history also live" is one
2442    /// question asked from two sides.
2443    ///
2444    /// `import` is the door: it lists a whole GitHub/Codeberg/GitLab/Forgejo
2445    /// account over that forge's REST API and mirrors what you choose, which is
2446    /// how a person moves in without a migration project.
2447    ///
2448    /// Not to be confused with `gunnar mirror` (no `control`), which keeps a
2449    /// copy in a data root on the machine you typed it on. This one talks to a
2450    /// server.
2451    #[command(subcommand)]
2452    Mirror(ControlMirrorCommand),
2453
2454    /// **Replication: where a second copy of everything lives.**
2455    ///
2456    /// The appliance has no shell. `<data-root>/replication.json` is the truth
2457    /// and it survives a reboot, but on an OS-less image running as PID 1 there
2458    /// is no editor to write it with — so these are how a standby is named, and
2459    /// they are the only way.
2460    ///
2461    /// A target is configurable BEFORE any repository exists: replication is
2462    /// scoped to the server, a namespace or a repository, and nothing is
2463    /// looked up when you set one.
2464    #[command(subcommand)]
2465    Replication(ControlReplicationCommand),
2466
2467    /// **The twin: the box that holds every transaction, and the restore
2468    /// from it.**
2469    ///
2470    /// A twin follows a primary's transaction log — every pack, ref edit,
2471    /// control-database write, secret and LFS object, in the order the
2472    /// primary was told — into a data root of its own, and never serves. On
2473    /// a gap it fetches the missing range first; when the primary no longer
2474    /// retains what it needs it stops and says `snapshot-needed`. `restore`
2475    /// replays a twin's log into a fresh data root.
2476    #[command(subcommand)]
2477    Twin(ControlTwinCommand),
2478
2479    /// **The WireGuard tunnel address: handed over at bootstrap, never baked
2480    /// into the medium.** `enrol` allocates the next free address of the
2481    /// site's pool (keyed by the box's minted public key), hands it to the box
2482    /// over `Bootstrap.AssignTunnelAddress`, and registers the box's `[Peer]`
2483    /// in the hub's WireGuard config.
2484    #[command(subcommand)]
2485    Tunnel(ControlTunnelCommand),
2486
2487    /// **Outbound events: send them to an external URL (webhooks).**
2488    ///
2489    /// A webhook is a subscriber to gunnar's event stream that POSTs each
2490    /// matching event to a URL — the way a CI/CD pipeline is triggered when a
2491    /// ref moves, without a process camped on `Events.Watch`. Every delivery is
2492    /// SIGNED with the server's key (an `sshsig`, no shared secret), so the
2493    /// receiver verifies it came from this gunnar with `ssh-keygen -Y verify`.
2494    ///
2495    /// Configured over the control plane and durable in
2496    /// `<data-root>/webhooks.json`, for the same reason replication is: the
2497    /// appliance has no shell.
2498    #[command(subcommand)]
2499    Webhook(ControlWebhookCommand),
2500
2501    /// **The pre-receive gate: branch protection and ref-name policy.**
2502    ///
2503    /// A merge already needs a verified signed approval naming the exact
2504    /// commit. Nothing stopped a DIRECT push to the same branch, because the
2505    /// only surface that could was reachable from neither this CLI nor the
2506    /// console — while the server had been serving `Policy.*` out of
2507    /// `refs/gunnar/policy` the whole time. These are that surface.
2508    #[command(subcommand)]
2509    Policy(ControlPolicyCommand),
2510
2511    /// Give a principal access, against a running server — or `grant list`
2512    /// to see who holds what.
2513    ///
2514    /// `gunnar control grant <principal> <level> --repo <ns>/<r>` gives;
2515    /// `gunnar control grant list` reads the whole table back. The one name
2516    /// this shadows is a principal literally called `list`, which `user add`
2517    /// will let you create and this verb can then not grant to.
2518    #[command(args_conflicts_with_subcommands = true)]
2519    Grant {
2520        #[command(subcommand)]
2521        command: Option<ControlGrantCommand>,
2522        /// The principal id.
2523        principal: Option<String>,
2524        /// How much access.
2525        #[arg(value_enum)]
2526        level: Option<LevelArg>,
2527        /// Scope it to the whole server. This IS gunnar-admin at `admin`.
2528        #[arg(long)]
2529        server: bool,
2530        /// Scope it to one namespace.
2531        #[arg(long, value_name = "NAME")]
2532        namespace: Option<String>,
2533        /// Scope it to one repository, `<namespace>/<repo>`.
2534        #[arg(long, value_name = "NS/REPO")]
2535        repo: Option<String>,
2536        #[arg(long, short = 'i', value_name = "PATH")]
2537        identity: Option<PathBuf>,
2538        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2539        at: String,
2540    },
2541
2542    /// **Take a grant back**, against a running server.
2543    ///
2544    /// The mirror of `grant`, minus the level: a scope holds at most ONE
2545    /// grant per principal — granting on the same scope replaces — so the
2546    /// principal and the scope name the grant, and there is no level to
2547    /// choose. Revoking a grant that is not there is an ERROR, not a quiet
2548    /// success: "revoked" about a grant that never existed reads identically
2549    /// to "revoked" about the one you meant, and the difference is whether
2550    /// somebody still has access.
2551    ///
2552    /// A principal's OWN account is not a grant and cannot be revoked here;
2553    /// `user remove` takes the whole principal.
2554    Revoke {
2555        /// The principal id.
2556        principal: String,
2557        /// The server-wide grant. This is how gunnar-admin is taken back.
2558        #[arg(long)]
2559        server: bool,
2560        /// The grant on one namespace.
2561        #[arg(long, value_name = "NAME")]
2562        namespace: Option<String>,
2563        /// The grant on one repository, `<namespace>/<repo>`.
2564        #[arg(long, value_name = "NS/REPO")]
2565        repo: Option<String>,
2566        #[arg(long, short = 'i', value_name = "PATH")]
2567        identity: Option<PathBuf>,
2568        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2569        at: String,
2570    },
2571
2572    /// The server's own health, authenticated: version, uptime and both
2573    /// listener binds.
2574    ///
2575    /// `status` is the unauthenticated question ("is this box enrolled, does
2576    /// it gate callers"); this is the authenticated one — what a headless
2577    /// appliance would tell you on a console you cannot see.
2578    Health {
2579        #[arg(long, short = 'i', value_name = "PATH")]
2580        identity: Option<PathBuf>,
2581        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2582        at: String,
2583    },
2584
2585    /// **The public key this appliance signs actor tickets with**, as 64 hex
2586    /// characters — the string a monetize needs in `[products.<id>]
2587    /// appliance_key` before a tenant can renew their own subscription.
2588    ///
2589    /// `Billing.ApplianceIdentity` has served this since it was written, "so an
2590    /// operator can configure their monetize with it", and until 2026-09-19
2591    /// nothing but a test ever asked: there was no verb. MEASURED on gunnar.rs
2592    /// that day — every tenant's own renewal answered `501 unimplemented`,
2593    /// because the plugin had no key to check a ticket with and correctly
2594    /// refuses rather than trusting one it cannot verify.
2595    ///
2596    /// It is a PUBLIC key. Admin at server scope because it is operator
2597    /// configuration, not because the string is a secret — it VERIFIES tickets,
2598    /// it does not mint them.
2599    ///
2600    /// `metered` only, like the service it calls: `billing.proto` is not in
2601    /// codegen without the feature, and the community build "never mentions
2602    /// money at all" — an appliance that cannot sell a period carries no
2603    /// service that mints the permission to buy one, so a verb asking for one
2604    /// would be a promise the binary cannot keep.
2605    #[cfg(feature = "metered")]
2606    ApplianceIdentity {
2607        #[arg(long, short = 'i', value_name = "PATH")]
2608        identity: Option<PathBuf>,
2609        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2610        at: String,
2611    },
2612
2613    /// **A namespace's plan, as this box ENFORCES it** — and the one door that
2614    /// hands the box a fact monetize signed.
2615    ///
2616    /// `Entitlement.Get/List/Set` had no verb: the only caller of `Set` was
2617    /// monetize's own poller, so a bench, a restore drill or an operator after
2618    /// a rescue could not put a fact on a box or ask what it was enforcing
2619    /// without writing a gRPC client. The reads answer out of the snapshot the
2620    /// front door reads, so a fact refused at boot — the wrong
2621    /// `--monetize-pubkey` after a restore — reads as Free here too.
2622    ///
2623    /// `metered` only, like `appliance-identity`: `entitlement.proto` is not in
2624    /// codegen without it.
2625    #[cfg(feature = "metered")]
2626    #[command(subcommand)]
2627    Entitlement(ControlEntitlementCommand),
2628
2629    /// The server's counters: principals, keys, grants, repositories, the
2630    /// event bus, tombstones, replication and index work in flight.
2631    Stats {
2632        #[arg(long, short = 'i', value_name = "PATH")]
2633        identity: Option<PathBuf>,
2634        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2635        at: String,
2636    },
2637
2638    /// **"If a tenant's cap rose to N bytes, could you serve it?"** — the
2639    /// question to ask BEFORE adding a disk.
2640    ///
2641    /// Not "how much is free". The answer is a verdict measured off the same
2642    /// `statvfs` the push door refuses under, AND — separately — whether this
2643    /// data set can ever grow at all. On the appliance it cannot: `/data` is an
2644    /// md set laid once at install, gunnar has no runtime growth path, and
2645    /// gunnar is PID 1 with no shell, so a disk attached to it would be added for nothing
2646    /// and stay invisible. Both halves are printed, on the yes arm as well as
2647    /// the no arm.
2648    Capacity {
2649        /// The cap being contemplated, in bytes: `--bytes 107374182400` asks
2650        /// "could you serve a tenant capped at 100 GiB?".
2651        #[arg(long, value_name = "BYTES")]
2652        bytes: u64,
2653        /// Whose cap is rising. Bytes it ALREADY holds are already spent out
2654        /// of free space, so only the difference needs new room. Without it
2655        /// the harder question is asked: could you serve `--bytes` that are
2656        /// not here yet.
2657        #[arg(long, value_name = "NAMESPACE")]
2658        namespace: Option<String>,
2659        #[arg(long, short = 'i', value_name = "PATH")]
2660        identity: Option<PathBuf>,
2661        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2662        at: String,
2663    },
2664
2665    /// **Grow the data set WITHOUT a restart** (`DATA-SET-GROWTH-FLOW.md`
2666    /// §3): drain, unmount, wait for every member volume to come back
2667    /// larger, grow in place, mount, reopen. `status` needs no credential.
2668    #[command(subcommand)]
2669    Grow(ControlGrowCommand),
2670
2671    /// What is on disk: which backends are behind the two traits, hot and
2672    /// cold bytes, and one row per repository.
2673    Storage {
2674        /// Only this repository, `<namespace>/<repo>`. Without it, the
2675        /// server-wide totals and no per-repository rows.
2676        #[arg(long, value_name = "NS/REPO")]
2677        repo: Option<String>,
2678        #[arg(long, short = 'i', value_name = "PATH")]
2679        identity: Option<PathBuf>,
2680        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2681        at: String,
2682    },
2683
2684    /// **The estate's boxes, as this gunnar knows them** (`Admin.Topology`):
2685    /// this box first (primary or twin), then its twins and standbys with their
2686    /// live state, the consoles that call it and the monetize it registers
2687    /// with — one row per box, each with the sentence that says how gunnar
2688    /// knows. The same rows the console's Machine card draws.
2689    Topology {
2690        #[arg(long, short = 'i', value_name = "PATH")]
2691        identity: Option<PathBuf>,
2692        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2693        at: String,
2694    },
2695
2696    /// **The meters, per namespace: what is held and what moved.**
2697    ///
2698    /// `pack_bytes` (the packs and their indexes — the stored bytes),
2699    /// `cache_bytes` (the exploded read cache, re-derivable), `lfs_bytes`,
2700    /// `tombstoned_bytes` (soft-deleted, still held, still counted), `repos`,
2701    /// and `objects` (the indexed objects of stores open right now — the RAM in
2702    /// use).
2703    ///
2704    /// Since the flow ledger it also prints what MOVED today — `in`, `out`,
2705    /// `pushes`, `fetches`, `refused` — and when the namespace was last seen.
2706    /// Those are counts of events and not readings of a disk; `gunnar control
2707    /// activity <namespace>` is the same numbers day by day.
2708    ///
2709    /// Open source and always on: a self-hoster reads exactly the numbers a
2710    /// service provider meters from. Needs `read` on the namespace, or a
2711    /// server-wide `read` for every namespace.
2712    Usage {
2713        /// One namespace. Without it, every namespace the catalog holds.
2714        #[arg(value_name = "NAMESPACE")]
2715        namespace: Option<String>,
2716        #[arg(long, short = 'i', value_name = "PATH")]
2717        identity: Option<PathBuf>,
2718        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2719        at: String,
2720    },
2721
2722    /// **What MOVED through a namespace, day by day.**
2723    ///
2724    /// Every number `gunnar control usage` prints is a STOCK meter: it answers
2725    /// *how much is on the disk*, and it reads exactly the same for a namespace
2726    /// that was pushed to this morning and one that was last touched in March.
2727    /// This is the other kind — bytes in and out, pushes, fetches, refusals
2728    /// (with the reason), the keys that were seen, and when the namespace was
2729    /// last used. One row per UTC day that moved something; a day with no
2730    /// traffic has no row.
2731    ///
2732    /// Open source and always on, as `usage` is. Needs `read` on the namespace
2733    /// — a tenant's own traffic is a tenant's own business, so this takes the
2734    /// same grant `usage <namespace>` does and never a server-wide one.
2735    Activity {
2736        /// The namespace. Required: activity is per tenant, and there is no
2737        /// server-wide answer that would mean anything.
2738        #[arg(value_name = "NAMESPACE")]
2739        namespace: String,
2740        /// How many days back, ending today. The server keeps 400.
2741        #[arg(long, default_value_t = 30, value_name = "N")]
2742        days: u32,
2743        #[arg(long, short = 'i', value_name = "PATH")]
2744        identity: Option<PathBuf>,
2745        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2746        at: String,
2747    },
2748
2749    /// **Admission: what a namespace's next transfer would get, and why.**
2750    ///
2751    /// `gunnar control admit show <namespace>` prints the usage over the
2752    /// rolling window, the budget and what is left of it, and per kind of work
2753    /// (clone, fetch, push, request) the class the gate would give it, the
2754    /// learnt estimate and the gate's own sentence. Needs `read` on the
2755    /// namespace — a tenant may read their own budget.
2756    Admit {
2757        #[command(subcommand)]
2758        command: AdmitCommand,
2759    },
2760
2761    /// **Who wrote each line of a file, against a RUNNING server.**
2762    ///
2763    /// The offline `gunnar blame` opens the data root directly and takes an
2764    /// exclusive lock, so it cannot run while a `gunnar serve` holds the same
2765    /// root — which on a sealed appliance with no shell means it cannot run at
2766    /// all. This asks the running server to walk its own open store and answer
2767    /// over the control plane, so a headless box can finally be blamed. Needs
2768    /// `read` on the repository.
2769    Blame {
2770        /// `<namespace>/<repo>`.
2771        #[arg(value_name = "NS/REPO")]
2772        repo: String,
2773        /// The file to blame, as a path within the repository's tree.
2774        #[arg(value_name = "PATH")]
2775        path: String,
2776        /// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
2777        #[arg(long, default_value = "HEAD", value_name = "REV")]
2778        rev: String,
2779        #[arg(long, short = 'i', value_name = "PATH")]
2780        identity: Option<PathBuf>,
2781        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2782        at: String,
2783    },
2784
2785    /// **A literal string across one revision's files, against a RUNNING
2786    /// server.** The up-server twin of `gunnar search`, reachable on a
2787    /// no-shell appliance for the same reason `control blame` is. Needs `read`
2788    /// on the repository.
2789    Search {
2790        /// `<namespace>/<repo>`.
2791        #[arg(value_name = "NS/REPO")]
2792        repo: String,
2793        /// The literal string to look for. Not a regular expression.
2794        #[arg(value_name = "PATTERN")]
2795        pattern: String,
2796        /// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only
2797        /// this one revision, not the history behind it.
2798        #[arg(long, default_value = "HEAD", value_name = "REV")]
2799        rev: String,
2800        /// Skip files larger than this many bytes (they are never read). 0
2801        /// means the server default.
2802        #[arg(long, default_value_t = 0, value_name = "BYTES")]
2803        max_file_bytes: u64,
2804        /// Stop after this many matches. 0 means the server default.
2805        #[arg(long, default_value_t = 0, value_name = "N")]
2806        max_matches: u32,
2807        #[arg(long, short = 'i', value_name = "PATH")]
2808        identity: Option<PathBuf>,
2809        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2810        at: String,
2811    },
2812
2813    /// **Show a pull request's review, against a RUNNING server.**
2814    ///
2815    /// The comments and the approvals, with every approval re-checked against
2816    /// the identity key table right now — the same fold `gunnar forge show` prints,
2817    /// but reached over the control plane. The offline `gunnar forge show`
2818    /// opens the data root under an exclusive lock and so cannot run while a
2819    /// `gunnar serve` holds it, which on a sealed appliance with no shell means
2820    /// an operator has no other door to the review at all. Whether the pull
2821    /// request is open and what it merged at are `gunnar control pull`'s
2822    /// answers, deliberately not duplicated here. Needs `read` on the
2823    /// repository.
2824    Show {
2825        /// `<namespace>/<repo>`.
2826        #[arg(value_name = "NS/REPO")]
2827        repo: String,
2828        /// The pull request number, as `PullRequests.Create` assigned it.
2829        #[arg(value_name = "N")]
2830        pull: u64,
2831        #[arg(long, short = 'i', value_name = "PATH")]
2832        identity: Option<PathBuf>,
2833        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2834        at: String,
2835    },
2836
2837    /// **Merge a reviewed pull request, against a RUNNING server.**
2838    ///
2839    /// Fast-forward only, and gated: the server refuses unless a verified
2840    /// approval names the exact commit the target ref is about to move to, so a
2841    /// force-push after the review does not inherit it. This is the merge an
2842    /// operator on a sealed appliance runs — there is no other door to
2843    /// `PullRequests.Merge` from a shell-less box — and the gate's rules (the
2844    /// approval names the commit, forbid-self-approval, required-approvals) are
2845    /// the server's, applied unchanged; this is only the verb that reaches
2846    /// them. Needs `write` on the repository.
2847    Merge {
2848        /// `<namespace>/<repo>`.
2849        #[arg(value_name = "NS/REPO")]
2850        repo: String,
2851        /// The pull request number to merge.
2852        #[arg(value_name = "N")]
2853        pull: u64,
2854        #[arg(long, short = 'i', value_name = "PATH")]
2855        identity: Option<PathBuf>,
2856        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2857        at: String,
2858    },
2859
2860    /// **Who am I on this server?** (`Access.WhoAmI`.) The principal your key
2861    /// belongs to and the highest server-wide level it holds. Needs no grant.
2862    Whoami {
2863        #[arg(long, short = 'i', value_name = "PATH")]
2864        identity: Option<PathBuf>,
2865        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2866        at: String,
2867    },
2868
2869    /// **Pull requests, against a RUNNING server** (`PullRequests.*`): open,
2870    /// list, show and close one. Merging is `gunnar control merge`.
2871    #[command(subcommand)]
2872    Pr(ControlPrCommand),
2873
2874    /// **Issues, against a RUNNING server**, signed with YOUR key and appended
2875    /// with `Forge.Append` — the online twin of the offline `gunnar forge issue`.
2876    #[command(subcommand)]
2877    Issue(ControlIssueCommand),
2878
2879    /// **A pull request's review, against a RUNNING server**: read it, comment
2880    /// on it, approve a commit — the events signed with YOUR key.
2881    #[command(subcommand)]
2882    Review(ControlReviewCommand),
2883
2884    /// **The live event feed** (`Events.Watch`).
2885    #[command(subcommand)]
2886    Events(ControlEventsCommand),
2887
2888    /// **Invites: a pending passkey enrolment on an existing person**
2889    /// (`Access.CreateInvite`, Stage L). A server admin invites anybody; an
2890    /// account admin, the account's employees; a person, themselves.
2891    #[command(subcommand)]
2892    Invite(ControlInviteCommand),
2893}
2894
2895/// `gunnar control invite …`
2896#[cfg(feature = "control")]
2897#[derive(Debug, Subcommand)]
2898pub enum ControlInviteCommand {
2899    /// Create one; prints its id and its SECRET — shown here once, never again.
2900    Create {
2901        /// The person whose passkey it enrols.
2902        #[arg(value_name = "PRINCIPAL")]
2903        principal: String,
2904        /// Days until it expires (at most 30).
2905        #[arg(long, value_name = "DAYS", default_value_t = 7)]
2906        expires_in_days: u32,
2907        #[arg(long, value_name = "TEXT", default_value = "")]
2908        note: String,
2909        /// A server-wide grant the redemption applies: `read`, `write` or
2910        /// `admin` (a console's operator is `write`, its admin `admin`). Only a
2911        /// server admin's invite may carry one.
2912        #[arg(long, value_name = "LEVEL")]
2913        server_level: Option<String>,
2914        #[arg(long, short = 'i', value_name = "PATH")]
2915        identity: Option<PathBuf>,
2916        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2917        at: String,
2918    },
2919    /// The pending invites, never a secret: every one (a server admin), or
2920    /// one account's (its admin).
2921    List {
2922        #[arg(long, value_name = "ACCOUNT")]
2923        account: Option<String>,
2924        #[arg(long, short = 'i', value_name = "PATH")]
2925        identity: Option<PathBuf>,
2926        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2927        at: String,
2928    },
2929    /// Withdraw one, by the id `create` or `list` printed.
2930    Revoke {
2931        #[arg(value_name = "ID")]
2932        id: String,
2933        #[arg(long, short = 'i', value_name = "PATH")]
2934        identity: Option<PathBuf>,
2935        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2936        at: String,
2937    },
2938}
2939
2940/// `gunnar control pr …` — pull requests over the control plane.
2941#[cfg(feature = "control")]
2942#[derive(Debug, Subcommand)]
2943pub enum ControlPrCommand {
2944    /// Open a pull request from `--source` into `--target`. Needs `write`.
2945    Create {
2946        /// `<namespace>/<repo>`.
2947        #[arg(value_name = "NS/REPO")]
2948        repo: String,
2949        /// The full ref proposed, e.g. `refs/heads/topic`.
2950        #[arg(long, value_name = "REF")]
2951        source: String,
2952        /// The full ref it would merge into, e.g. `refs/heads/main`.
2953        #[arg(long, value_name = "REF")]
2954        target: String,
2955        #[arg(long, value_name = "TEXT")]
2956        title: String,
2957        #[arg(long, value_name = "TEXT", default_value = "")]
2958        body: String,
2959        #[arg(long, short = 'i', value_name = "PATH")]
2960        identity: Option<PathBuf>,
2961        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2962        at: String,
2963    },
2964    /// List a repository's pull requests. Needs `read`.
2965    List {
2966        #[arg(value_name = "NS/REPO")]
2967        repo: String,
2968        /// `open`, `merged`, `closed` or `all`.
2969        #[arg(long, value_name = "STATE", default_value = "all")]
2970        state: String,
2971        #[arg(long, short = 'i', value_name = "PATH")]
2972        identity: Option<PathBuf>,
2973        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2974        at: String,
2975    },
2976    /// Show one pull request. Needs `read`.
2977    Get {
2978        #[arg(value_name = "NS/REPO")]
2979        repo: String,
2980        #[arg(value_name = "N")]
2981        number: u64,
2982        #[arg(long, short = 'i', value_name = "PATH")]
2983        identity: Option<PathBuf>,
2984        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2985        at: String,
2986    },
2987    /// Close a pull request without merging it. Needs `write`.
2988    Close {
2989        #[arg(value_name = "NS/REPO")]
2990        repo: String,
2991        #[arg(value_name = "N")]
2992        number: u64,
2993        #[arg(long, short = 'i', value_name = "PATH")]
2994        identity: Option<PathBuf>,
2995        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2996        at: String,
2997    },
2998}
2999
3000/// `gunnar control issue …` — issues over the control plane, signed with the
3001/// caller's own key. The actor is who gunnar says the key is (`WhoAmI`).
3002#[cfg(feature = "control")]
3003#[derive(Debug, Subcommand)]
3004pub enum ControlIssueCommand {
3005    /// Open an issue; prints the number the fold assigned. Needs `write`.
3006    Open {
3007        #[arg(value_name = "NS/REPO")]
3008        repo: String,
3009        #[arg(long, value_name = "TEXT")]
3010        title: String,
3011        #[arg(long, value_name = "TEXT", default_value = "")]
3012        body: String,
3013        #[arg(long, short = 'i', value_name = "PATH")]
3014        identity: Option<PathBuf>,
3015        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3016        at: String,
3017    },
3018    /// Comment on an issue. Needs `write`.
3019    Comment {
3020        #[arg(value_name = "NS/REPO")]
3021        repo: String,
3022        #[arg(value_name = "N")]
3023        number: u64,
3024        #[arg(long, value_name = "TEXT")]
3025        body: String,
3026        #[arg(long, short = 'i', value_name = "PATH")]
3027        identity: Option<PathBuf>,
3028        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3029        at: String,
3030    },
3031    /// Close an issue, naming why. Needs `write`.
3032    Close {
3033        #[arg(value_name = "NS/REPO")]
3034        repo: String,
3035        #[arg(value_name = "N")]
3036        number: u64,
3037        #[arg(long, value_name = "TEXT", default_value = "")]
3038        reason: String,
3039        #[arg(long, short = 'i', value_name = "PATH")]
3040        identity: Option<PathBuf>,
3041        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3042        at: String,
3043    },
3044    /// List a repository's issues. Needs `read`.
3045    List {
3046        #[arg(value_name = "NS/REPO")]
3047        repo: String,
3048        #[arg(long, short = 'i', value_name = "PATH")]
3049        identity: Option<PathBuf>,
3050        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3051        at: String,
3052    },
3053    /// Show one issue and its whole thread. Needs `read`.
3054    Get {
3055        #[arg(value_name = "NS/REPO")]
3056        repo: String,
3057        #[arg(value_name = "N")]
3058        number: u64,
3059        #[arg(long, short = 'i', value_name = "PATH")]
3060        identity: Option<PathBuf>,
3061        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3062        at: String,
3063    },
3064}
3065
3066/// `gunnar control review …` — a pull request's review over the control
3067/// plane, the events signed with the caller's own key.
3068#[cfg(feature = "control")]
3069#[derive(Debug, Subcommand)]
3070pub enum ControlReviewCommand {
3071    /// The review: comments and who approves what, each approval re-checked by
3072    /// gunnar. The same as `gunnar control show`. Needs `read`.
3073    Get {
3074        #[arg(value_name = "NS/REPO")]
3075        repo: String,
3076        #[arg(value_name = "N")]
3077        pull: u64,
3078        #[arg(long, short = 'i', value_name = "PATH")]
3079        identity: Option<PathBuf>,
3080        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3081        at: String,
3082    },
3083    /// Comment on a pull request — on one line of one file with `--path`,
3084    /// `--line` and `--blob`, or on the whole pull request without. Needs
3085    /// `write`.
3086    Comment {
3087        #[arg(value_name = "NS/REPO")]
3088        repo: String,
3089        #[arg(value_name = "N")]
3090        pull: u64,
3091        #[arg(long, value_name = "TEXT")]
3092        body: String,
3093        /// The file the comment anchors to.
3094        #[arg(long, value_name = "PATH", requires_all = ["line", "blob"])]
3095        path: Option<String>,
3096        /// The line in that file (1-based).
3097        #[arg(long, value_name = "N")]
3098        line: Option<u32>,
3099        /// The blob the line is in, so the comment follows the code.
3100        #[arg(long, value_name = "OID")]
3101        blob: Option<String>,
3102        #[arg(long, short = 'i', value_name = "PATH")]
3103        identity: Option<PathBuf>,
3104        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3105        at: String,
3106    },
3107    /// Approve a specific commit — the one `gunnar control merge` will move
3108    /// the target to. Needs `write`.
3109    Approve {
3110        #[arg(value_name = "NS/REPO")]
3111        repo: String,
3112        #[arg(value_name = "N")]
3113        pull: u64,
3114        #[arg(long, value_name = "OID")]
3115        commit: String,
3116        #[arg(long, short = 'i', value_name = "PATH")]
3117        identity: Option<PathBuf>,
3118        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3119        at: String,
3120    },
3121}
3122
3123/// `gunnar control events …`
3124#[cfg(feature = "control")]
3125#[derive(Debug, Subcommand)]
3126pub enum ControlEventsCommand {
3127    /// **Print the live feed**, one line per event, until interrupted (or
3128    /// `--count` events). With a server-wide `read`: everything, narrowed by
3129    /// the flags. Without one: naming no `--namespace` watches your own
3130    /// accounts (and accounts whose events are public); naming some asks for
3131    /// exactly those, and gunnar refuses an account you may not watch.
3132    Watch {
3133        /// An account to watch. Repeatable.
3134        #[arg(long, value_name = "NS")]
3135        namespace: Vec<String>,
3136        /// One repository, `<namespace>/<repo>`. Repeatable.
3137        #[arg(long, value_name = "NS/REPO")]
3138        repo: Vec<String>,
3139        /// Only these kinds, e.g. `ref-updated`, `repo-created`,
3140        /// `pull-request-opened`. Repeatable.
3141        #[arg(long, value_name = "KIND")]
3142        kind: Vec<String>,
3143        /// Stop after this many events (the stream's own prologue not counted).
3144        #[arg(long, value_name = "N")]
3145        count: Option<u64>,
3146        /// Resume after this sequence number, as far as the server still holds.
3147        #[arg(long, value_name = "SEQ", default_value_t = 0)]
3148        from_seq: u64,
3149        #[arg(long, short = 'i', value_name = "PATH")]
3150        identity: Option<PathBuf>,
3151        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3152        at: String,
3153    },
3154}
3155
3156/// `gunnar control grant list` — who holds what.
3157#[cfg(feature = "control")]
3158#[derive(Debug, Clone, Subcommand)]
3159pub enum ControlGrantCommand {
3160    /// **Every grant on the server, per principal** — the answer to "what can
3161    /// this principal see?".
3162    ///
3163    /// One line per grant under the principal that holds it: the scope
3164    /// (`server`, `namespace:<name>`, `repo:<namespace>/<repo>`) and the
3165    /// level. A principal's OWN account does not appear: everyone is admin of
3166    /// `<their-name>/…` by construction, and that is not a grant anybody gave
3167    /// or can revoke.
3168    List {
3169        /// Only this principal's grants.
3170        #[arg(long, value_name = "ID")]
3171        principal: Option<String>,
3172        #[arg(long, short = 'i', value_name = "PATH")]
3173        identity: Option<PathBuf>,
3174        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3175        at: String,
3176    },
3177}
3178
3179/// `gunnar control tunnel …` — a box's WireGuard tunnel address.
3180#[cfg(feature = "control")]
3181#[derive(Debug, Clone, Subcommand)]
3182pub enum ControlTunnelCommand {
3183    /// What the box holds: its WireGuard public key, its tunnel address, and
3184    /// whether `wg0` is up (`Bootstrap.AssignTunnelAddress` with no address).
3185    Show {
3186        /// The box's control plane.
3187        #[arg(long, value_name = "HOST:PORT", conflicts_with = "ssh", required_unless_present = "ssh")]
3188        at: Option<String>,
3189        /// A SEALED box's SSH door instead (`serve --no-grpc`): the
3190        /// `gunnar-tunnel-address` verb, as a gunnar-admin (`-i`).
3191        #[arg(long, value_name = "HOST:PORT")]
3192        ssh: Option<String>,
3193        /// The gunnar-admin key for `--ssh`.
3194        #[arg(long, short = 'i', value_name = "PATH")]
3195        identity: Option<PathBuf>,
3196    },
3197    /// **Give a fresh box its tunnel address and register it on the hub.**
3198    /// Idempotent: the same box gets the same address back.
3199    Enrol {
3200        /// The box's control plane (gRPC). A fresh box takes the address on
3201        /// `Bootstrap.AssignTunnelAddress`, an enrolled one on
3202        /// `Admin.SetTunnelAddress` (needs `-i`).
3203        #[arg(long, value_name = "HOST:PORT", conflicts_with = "ssh", required_unless_present = "ssh")]
3204        at: Option<String>,
3205        /// A SEALED box instead (`serve --no-grpc --admin-key …`, no control
3206        /// plane): its SSH door, `gunnar-tunnel-address`, as the gunnar-admin
3207        /// whose key `-i` names.
3208        #[arg(long, value_name = "HOST:PORT")]
3209        ssh: Option<String>,
3210        /// The site's tunnel subnet, `a.b.c.d/prefix`. Its first host is the
3211        /// hub's and is never handed out.
3212        #[arg(long, value_name = "CIDR")]
3213        pool: String,
3214        /// The site's ledger of which box holds which address (created if absent).
3215        #[arg(long, value_name = "PATH")]
3216        ledger: PathBuf,
3217        /// The hub's WireGuard config; the box's `[Peer]` is appended once.
3218        #[arg(long, value_name = "PATH")]
3219        hub_peers: PathBuf,
3220        /// A name for the box in the ledger and the hub's comment (default: `--at`).
3221        #[arg(long)]
3222        label: Option<String>,
3223        /// A gunnar-admin key: for `--ssh`, and for a gRPC box PAST its
3224        /// bootstrap window (then the address goes over `Admin.SetTunnelAddress`).
3225        #[arg(long, short = 'i', value_name = "PATH")]
3226        identity: Option<PathBuf>,
3227    },
3228}
3229
3230/// `gunnar control twin …` — the twin, and the restore from it.
3231#[cfg(feature = "control")]
3232#[derive(Debug, Clone, Subcommand)]
3233pub enum ControlTwinCommand {
3234    /// **Make a FRESH box a twin** (`Bootstrap.BecomeTwin` — refused once
3235    /// anything is enrolled, and taken only under a credential from a key the
3236    /// box was FED: the installer's admin, an admin principal of its feed, or
3237    /// `--restore-pubkey`; h-bootstrap-open). Prints the twin's public key:
3238    /// enrol it on the primary as a machine in group `twin`.
3239    Join {
3240        /// The fresh box's control plane.
3241        #[arg(long, value_name = "HOST:PORT")]
3242        at: String,
3243        /// The primary's control plane, as the TWIN reaches it.
3244        #[arg(long, value_name = "HOST:PORT")]
3245        primary: String,
3246        /// A key the fresh box was fed (it signs the call).
3247        #[arg(long, short = 'i', value_name = "PATH")]
3248        identity: Option<PathBuf>,
3249    },
3250    /// **Give a twin its mutual-TLS credential** — the pairing step the one
3251    /// medium cannot carry. Asks the TWIN for a signing request (its key is
3252    /// minted there and never leaves), has the PRIMARY's issuer sign it for
3253    /// `--principal` (Admin; the principal must be in group `twin`), and hands
3254    /// the certificate and the issuer to pin back to the twin. Run it after the
3255    /// twin's key is enrolled on the primary. Run it again under the same
3256    /// issuer to RE-ISSUE it, without a shell on either box.
3257    Certify {
3258        /// The twin's control plane.
3259        #[arg(long, value_name = "HOST:PORT")]
3260        at: String,
3261        /// The primary's control plane, as THIS machine reaches it.
3262        #[arg(long, value_name = "HOST:PORT")]
3263        primary_at: String,
3264        /// The twin's principal on the primary.
3265        #[arg(long, default_value = "twin-box")]
3266        principal: String,
3267        /// An admin key on the primary.
3268        #[arg(long, short = 'i', value_name = "PATH")]
3269        identity: Option<PathBuf>,
3270    },
3271    /// **Restore a fresh primary from its twin, over the wire**: the twin's
3272    /// `Mirror.LogFetch` piped into the fresh box's `Bootstrap.Restore`
3273    /// (no credential there — it is fresh). Restart the box afterwards.
3274    Replay {
3275        /// The twin's control plane.
3276        #[arg(long, value_name = "HOST:PORT")]
3277        twin: String,
3278        /// The fresh box's control plane.
3279        #[arg(long, value_name = "HOST:PORT")]
3280        into: String,
3281        /// The key to read the twin's log with (an admin of the set).
3282        #[arg(long, short = 'i', value_name = "PATH")]
3283        identity: Option<PathBuf>,
3284        /// **Do not tell the box how much is coming, so it cannot refuse for room.**
3285        ///
3286        /// By default the replay announces the twin's log size and the box checks its own
3287        /// data set against it before the first byte lands, refusing by name when it is
3288        /// short — the restore-onto-smaller-hardware case. That check is deliberately
3289        /// conservative: it asks for twice the LOG, and a long-lived box's log is larger
3290        /// than the stores it builds (superseded documents, deleted packs — what
3291        /// `twin retain` exists to trim). So a legitimate restore of an old, busy box onto
3292        /// the same hardware can be refused.
3293        ///
3294        /// This says "I have looked at the numbers in the refusal and this set holds it".
3295        /// The restore then behaves as it always did: if the volume really does fill, the
3296        /// apply fails mid-stream, the box stays `restoring`, and the replay is resumable
3297        /// onto a bigger set. It is never a silent short restore either way.
3298        #[arg(long)]
3299        ignore_room: bool,
3300        /// **Restore from this twin even though it is not a current copy.**
3301        ///
3302        /// By default the replay asks the twin how current it is and REFUSES
3303        /// when the answer is not "level with its primary, as of moments ago":
3304        /// a twin that is behind, that is not following, or whose primary last
3305        /// answered longer ago than the heartbeat's silent bound. The refusal
3306        /// carries both numbers (applied / the primary's head) and the AGE, so
3307        /// the operator is refusing or overriding on facts.
3308        ///
3309        /// The twin exists for the worst case, and the worst case is exactly
3310        /// when nobody has time to notice that the copy stopped following a
3311        /// week ago. Restoring from a stale twin is sometimes still the right
3312        /// call — it is the only copy there is — so this says "I have read the
3313        /// numbers in the refusal and I want this copy anyway". It is never a
3314        /// silent stale restore either way.
3315        #[arg(long)]
3316        stale_ok: bool,
3317        /// **Take the replay although the twin no longer holds its oldest entries.** A twin
3318        /// that trimmed its log (`twin retain`) and keeps no snapshot of what it trimmed
3319        /// cannot give those entries back; by default the replay refuses by name, saying how
3320        /// many and what goes with them (DATA-SET-GROWTH-FLOW §2.1), before a byte streams.
3321        #[arg(long)]
3322        accept_lost: bool,
3323        /// **How long a twin that was LEVEL at its last answer may have heard nothing from its
3324        /// primary before plain replay refuses it** (`--stale-ok` then required). Default 24 h.
3325        ///
3326        /// From the twin, a primary that died and a network cut to a primary still alive and
3327        /// taking writes look the same: level, then silence. Minutes of that is main dying; a
3328        /// day of it is not something to assume. `now` refuses any silence; `<N>` seconds, or
3329        /// `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
3330        #[arg(long, value_name = "DURATION", default_value = "24h", value_parser = parse_max_silence)]
3331        max_silence: std::time::Duration,
3332        /// **The twin's old primary is fenced (or its machine stopped): replay beside it.**
3333        ///
3334        /// A twin that heard its primary within the last minute is following a box that is
3335        /// ALIVE; filling a fresh box from it would make two writable primaries, so plain
3336        /// replay refuses `not_fenced`. Fence the old primary first (`Mirror.SetRole` standby
3337        /// and `DemoteReplication`, or stop it) and say so here: a fenced primary still
3338        /// answers its twin, so the twin cannot tell. A primary re-laid at the same address
3339        /// reads `diverged` and needs no fence.
3340        #[arg(long)]
3341        primary_fenced: bool,
3342    },
3343    /// **Follow a primary.** Fetch every transaction it has that this data
3344    /// root has not, in order, apply it, keep polling. Needs server Admin on
3345    /// the primary: the log carries every sealed secret.
3346    Follow {
3347        /// The primary's control plane.
3348        #[arg(long, value_name = "HOST:PORT")]
3349        at: String,
3350        /// This twin's data root — its own copy of everything, plus the log.
3351        #[arg(long, value_name = "DIR")]
3352        data_root: PathBuf,
3353        /// The private key to authenticate with.
3354        #[arg(long, short = 'i', value_name = "PATH")]
3355        identity: Option<PathBuf>,
3356        /// Seconds between polls.
3357        #[arg(long, default_value_t = 5, value_name = "SECS")]
3358        poll_secs: u64,
3359        /// One poll, then exit — a cron, a bench, a hand check.
3360        #[arg(long)]
3361        once: bool,
3362    },
3363    /// **Restore: replay a twin's log into a fresh data root.** The restored
3364    /// primary continues the same sequence.
3365    Restore {
3366        /// The twin's data root (its `txlog/` is what is replayed).
3367        #[arg(long, value_name = "DIR")]
3368        from: PathBuf,
3369        /// The fresh data root to rebuild into.
3370        #[arg(long, value_name = "DIR")]
3371        into: PathBuf,
3372    },
3373    /// **What a box's log says**: head, oldest retained, and on a twin what
3374    /// it has applied and whether it is caught up.
3375    Status {
3376        /// The box's control plane.
3377        #[arg(long, value_name = "HOST:PORT")]
3378        at: String,
3379        /// The private key to authenticate with.
3380        #[arg(long, short = 'i', value_name = "PATH")]
3381        identity: Option<PathBuf>,
3382    },
3383    /// **Retention** (`Mirror.LogRetain`, server Admin): drop every whole
3384    /// segment of the PRIMARY's log below `--from` (default: everything up to
3385    /// the head). A twin that has not applied up to there takes a snapshot.
3386    Retain {
3387        /// The primary's control plane.
3388        #[arg(long, value_name = "HOST:PORT")]
3389        at: String,
3390        /// The oldest sequence to keep; 0 = keep nothing older than the next append.
3391        #[arg(long, default_value_t = 0, value_name = "SEQ")]
3392        from: u64,
3393        /// **Retain past what a twin has applied** (`LogRetainRequest.allow_past_twin`). By
3394        /// default the primary refuses to drop a frame the lowest-reporting twin has not applied,
3395        /// naming both numbers. This says the twin is gone or the hole is the point (a drill);
3396        /// the audit trail records who said it.
3397        #[arg(long)]
3398        allow_past_twin: bool,
3399        /// The private key to authenticate with.
3400        #[arg(long, short = 'i', value_name = "PATH")]
3401        identity: Option<PathBuf>,
3402    },
3403    /// **CATCH A TWIN UP THROUGH A RETENTION HOLE** — the one verb that drives
3404    /// `Mirror.SnapshotFetch` on purpose (`.nornir/twin-transaction-log.md`
3405    /// §Snapshot; the KVM row R8 in the same file).
3406    ///
3407    /// A twin that was away longer than the primary's retention window meets a
3408    /// HOLE: the next sequence it needs, `applied + 1`, is below the primary's
3409    /// `first_seq`, and no `LogFetch` can ever answer for it again
3410    /// (`mirror.proto`'s `LogFetch`: "`from` below `first_seq` is
3411    /// FAILED_PRECONDITION naming `first_seq`: the twin needs a snapshot, not a
3412    /// range"). The only way across is a full copy of the primary's state at one
3413    /// sequence, and this is the verb that takes one.
3414    ///
3415    /// **It freezes the primary, and that is why it is its own verb.** The
3416    /// producer holds `Log::freeze()` for the whole of `SnapshotFetch`
3417    /// (`crates/gunnar-grpc/src/service/mirror.rs:117`), so while the snapshot
3418    /// streams EVERY framed write on the primary is stopped — every push, every
3419    /// ref update, every control-plane mutation. A 44 GiB snapshot legitimately
3420    /// takes a long time and the freeze is the cost of a consistent `at`; it is
3421    /// never a cost to incur by accident. So:
3422    ///
3423    ///   * it REFUSES BY NAME when there is no hole (`no-hole`), because a twin
3424    ///     that is merely behind catches up with `twin follow`, which freezes
3425    ///     nothing;
3426    ///
3427    ///   * it reports how long the primary's writes were frozen, as a number;
3428    ///
3429    ///   * it bounds the wait: no piece for `--stall-secs` fails by name
3430    ///     (`stalled`) and drops the stream, which is what releases the freeze.
3431    ///
3432    /// Run it WHERE THE TWIN'S DISK IS, like `twin follow`: `--at` is the
3433    /// PRIMARY's control plane, `--data-root` is this box's own data root. The
3434    /// twin's own follower must not be running against that root at the same
3435    /// time.
3436    ///
3437    /// ```text
3438    /// gunnar control twin catch-up --at primary:50051 --data-root /data
3439    /// ```
3440    ///
3441    /// `--dry-run` establishes the hole and stops, so the decision to stop the
3442    /// primary's write path is made before it happens.
3443    CatchUp {
3444        /// **The PRIMARY's control plane** — the box the snapshot is taken
3445        /// from, and the box whose writes freeze while it streams.
3446        #[arg(long, value_name = "HOST:PORT")]
3447        at: String,
3448        /// **This twin's data root**, on this machine. The snapshot is staged
3449        /// in it (`txlog-snapshot-staging/`) and installed over it.
3450        #[arg(long, value_name = "DIR")]
3451        data_root: PathBuf,
3452        /// The private key to authenticate with: server Admin on the primary,
3453        /// or the twin's own machine key in group `twin` — a snapshot carries
3454        /// every sealed secret and every object.
3455        #[arg(long, short = 'i', value_name = "PATH")]
3456        identity: Option<PathBuf>,
3457        /// **Give up when NO snapshot piece has arrived for this long.** The
3458        /// deadline is on no progress, never on the transfer: a slow primary
3459        /// that keeps sending pieces is never cut off. The default is the
3460        /// primary's own mirror-image guard
3461        /// (`gunnar_grpc::service::mirror::SNAPSHOT_STALL_TIMEOUT`, 120 s),
3462        /// which is what cuts a twin off that stops READING.
3463        #[arg(long, default_value_t = 120, value_name = "SECS")]
3464        stall_secs: u64,
3465        /// **How many times the whole stream may be restarted.**
3466        ///
3467        /// `SnapshotFetchRequest` has no fields (`mirror.proto:165`), so there
3468        /// is no cursor on the wire and a broken stream can only be taken
3469        /// again FROM THE BEGINNING — which re-freezes the primary from
3470        /// scratch. Each restart is announced. A stall is never retried.
3471        #[arg(long, default_value_t = 3, value_name = "N")]
3472        attempts: u32,
3473        /// Establish whether there is a hole and say so, then stop. Nothing is
3474        /// streamed and the primary is never frozen.
3475        #[arg(long)]
3476        dry_run: bool,
3477    },
3478}
3479
3480/// `gunnar control replication …` — replication's standbys.
3481#[cfg(feature = "control")]
3482#[derive(Debug, Clone, Subcommand)]
3483pub enum ControlReplicationCommand {
3484    /// **Add or replace a standby, by name.** Takes effect immediately and
3485    /// survives a reboot.
3486    ///
3487    /// Idempotent: the same `<name>` twice replaces rather than duplicates, so
3488    /// a retry over a flaky link cannot leave two standbys behind.
3489    Add {
3490        /// Your name for this standby. Every event, log line and status row
3491        /// identifies it by this rather than by its URL.
3492        name: String,
3493        /// Where it is: an SSH base — `ssh://git@standby:2222` — onto which
3494        /// `/<namespace>/<repo>.git` is appended, or a complete URL ending in
3495        /// `.git` for a `--repo`-scoped target.
3496        ///
3497        /// SSH only, and anything else is refused by name: the push half is a
3498        /// send-pack over the server's russh transport, which has no HTTP
3499        /// transport to dial with.
3500        remote: String,
3501        /// Scope it to one namespace instead of the whole server.
3502        #[arg(long, value_name = "NAME", conflicts_with = "repo")]
3503        namespace: Option<String>,
3504        /// Scope it to one repository, `<namespace>/<repo>`.
3505        #[arg(long, value_name = "NS/REPO")]
3506        repo: Option<String>,
3507        /// **The deploy key the SERVER authenticates to the standby with**, as
3508        /// a path ON THE SERVER.
3509        ///
3510        /// A server has no ssh-agent and no interactive user. Without this the
3511        /// push falls back to the client key ladder, which on a server is
3512        /// almost never what was meant — and the server warns about it at
3513        /// every start-up.
3514        #[arg(long, value_name = "PATH")]
3515        identity_path: Option<String>,
3516        /// Override the `known_hosts` file for this target, as a path on the
3517        /// server.
3518        #[arg(long, value_name = "PATH")]
3519        known_hosts_path: Option<String>,
3520        /// Refuse an unknown host key instead of trusting it on first use.
3521        /// Off by default so a standby can be brought up without a
3522        /// provisioning step; on is right for anything that outlives an
3523        /// afternoon.
3524        #[arg(long)]
3525        strict_host_key: bool,
3526        /// **Which repositories, by visibility.** `all` (the default) is a
3527        /// standby: everything the scope covers. `public-only` is a browse
3528        /// replica's feed: a private repository is
3529        /// never pushed there, and one that goes private is DELETED there —
3530        /// which is why `public-only` also needs `--control`.
3531        #[arg(long, value_enum, default_value_t = ReplicationScopeArg::All)]
3532        scope: ReplicationScopeArg,
3533        /// The replica's control plane, `host:port`, REQUIRED with
3534        /// `--scope public-only`: the delete on a public → private flip is
3535        /// `Repos.Delete` over this address, signed with `--identity-path`.
3536        #[arg(long, value_name = "HOST:PORT", required_if_eq("scope", "public-only"))]
3537        control: Option<String>,
3538        #[arg(long, short = 'i', value_name = "PATH")]
3539        identity: Option<PathBuf>,
3540        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3541        at: String,
3542    },
3543    /// Remove a standby by name and stop replicating to it now.
3544    ///
3545    /// A name that is not configured is an error and not a quiet success:
3546    /// "removed" about a standby that was never there reads identically to
3547    /// "removed" about the one you meant, and the difference is whether your
3548    /// data is still going somewhere.
3549    Remove {
3550        name: String,
3551        #[arg(long, short = 'i', value_name = "PATH")]
3552        identity: Option<PathBuf>,
3553        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3554        at: String,
3555    },
3556    /// **DEMOTE this box: drop EVERY standby at once — "I am no longer a
3557    /// primary".**
3558    ///
3559    /// The failover primitive. When a primary dies and its standby is promoted
3560    /// (promotion is operational — a gunnar standby is already a full gunnar
3561    /// that serves reads and takes writes, so nothing on it flips), the
3562    /// recovered old box comes back up and serving but its
3563    /// `replication.json` still lists the box that is now the NEW primary. Its
3564    /// replicator then resumes pushing its now-stale state and
3565    /// **force-clobbers the new primary's post-failover writes** — MEASURED in
3566    /// `deploy/checks/failover-drill.sh`, where a client commit made against the
3567    /// promoted standby was overwritten back to the old primary's ref.
3568    ///
3569    /// Run this on the recovered box before re-attaching it: it clears the whole
3570    /// target roster in one write, so the box stops being a primary. Then point
3571    /// the NEW primary at it with `replication add` and `replication resync`, and
3572    /// the pair is whole again — replicating the other way.
3573    ///
3574    /// It does not touch the box's repositories or its own control plane: a
3575    /// demoted box still serves git and still takes writes. It has only stopped
3576    /// shipping a second copy anywhere. Idempotent — a box with no targets
3577    /// demotes to the same place, so it is safe to run again.
3578    Demote {
3579        #[arg(long, short = 'i', value_name = "PATH")]
3580        identity: Option<PathBuf>,
3581        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3582        at: String,
3583    },
3584    /// **Make this box a BROWSE replica**: a
3585    /// read-only standby that also serves anonymous reads of public
3586    /// repositories, refuses every control-plane mutator, and CANNOT be
3587    /// promoted — not by `promote`, not by `abdicate --promote`, not by the
3588    /// passive heartbeat. Feed it from the primary with
3589    /// `replication add <name> <remote> --scope public-only --control <this
3590    /// box>:50051`. The way back is `standby`, which only narrows what it
3591    /// serves.
3592    Browse {
3593        #[arg(long, short = 'i', value_name = "PATH")]
3594        identity: Option<PathBuf>,
3595        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3596        at: String,
3597    },
3598    /// **ABDICATE: this master steps down cleanly, then hands off.**
3599    ///
3600    /// The voluntary counterpart to a crash. `demote` is what you run on a box
3601    /// that already died; `abdicate` is how a healthy master leaves without
3602    /// losing a write, in order:
3603    ///
3604    ///   1. **freeze** — this box stops taking client writes (role → standby),
3605    ///      so nothing lands here after the hand-off begins;
3606    ///   2. **flush** — every standby is resynced and this waits for the queues
3607    ///      to drain, so the standby has everything that was pending. The wait
3608    ///      is a poll that returns the instant every queue is empty, bounded by
3609    ///      a ceiling (default 30 s) so a dead standby cannot wedge the hand-off.
3610    ///      On a heavily loaded box a *healthy* standby's backlog can take longer
3611    ///      than 30 s; raise the ceiling with `GUNNAR_ABDICATE_FLUSH_SECS=<secs>`
3612    ///      in the serving box's environment (a blank/zero/garbage value keeps
3613    ///      the 30 s default);
3614    ///   3. **demote** — the roster is cleared, so this box can never later
3615    ///      clobber the new primary.
3616    ///
3617    /// Then, as abdication's LAST act, it promotes the new primary — IF you name
3618    /// where its control plane is with `--promote`. That is the takeover: the
3619    /// standby is told `SetRole(PRIMARY)` and begins taking writes. Without
3620    /// `--promote` the three local steps still run and the command prints the
3621    /// one `replication promote` line to run against the standby yourself —
3622    /// naming a new primary is a decision, so it is never silently assumed.
3623    ///
3624    /// **The traffic switch is separate and is infrastructure.** The front
3625    /// (DNS / the UpCloud DNAT) still has to point at the new primary; abdication
3626    /// makes the hand-off safe, it does not move the address.
3627    Abdicate {
3628        /// The NEW primary's control plane, `host:port` — the standby to promote
3629        /// as abdication's last act. Omit to run only the local freeze/flush/
3630        /// demote and be told the `promote` command to run yourself.
3631        #[arg(long, value_name = "ADDR")]
3632        promote: Option<String>,
3633        #[arg(long, short = 'i', value_name = "PATH")]
3634        identity: Option<PathBuf>,
3635        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3636        at: String,
3637    },
3638    /// **PROMOTE this box to a writable primary — the takeover flip.**
3639    ///
3640    /// A standby serves reads and, once promoted, takes writes. Run it on the
3641    /// standby after the old master abdicated or died: the box records that it
3642    /// is a primary (persisted, so it survives a reboot) and thaws the write
3643    /// freeze. Idempotent — promoting a box that is already a primary is a
3644    /// no-op that prints the same "primary" status.
3645    ///
3646    /// The old master's `abdicate --promote <this box>` runs exactly this for
3647    /// you; this verb is the manual half, and the one a passive-heartbeat
3648    /// standby would call for itself when it decides the master is gone.
3649    Promote {
3650        #[arg(long, short = 'i', value_name = "PATH")]
3651        identity: Option<PathBuf>,
3652        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3653        at: String,
3654    },
3655    /// **STANDBY: make this box read-only to clients.**
3656    ///
3657    /// A standby serves reads and accepts only its primary's replication push (a
3658    /// `Machine` identity); a direct `git push` from a person or a deploy key is
3659    /// refused with "push to the primary". This is how a second box is
3660    /// deliberately made a standby up front, so a client that is mis-pointed at
3661    /// it cannot leave a write the primary never sees. `promote` is the inverse.
3662    Standby {
3663        #[arg(long, short = 'i', value_name = "PATH")]
3664        identity: Option<PathBuf>,
3665        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3666        at: String,
3667    },
3668    /// What is configured, and what each standby has actually done — including
3669    /// **"last successful replication: never"**.
3670    ///
3671    /// Cheap by default: it reports the roster, each standby's
3672    /// synced/failed/pending, the verdict word and each repository's copy state
3673    /// without opening a single repository store. The one expensive number —
3674    /// **how many refs live on this box alone** — is behind `--refs`, because
3675    /// counting it opens every repository's reference database.
3676    List {
3677        /// **Also count the refs a mirror does not carry**, per repository and
3678        /// server-wide. This opens every repository's reference database — one
3679        /// refold each on a cold box — so it is off unless you ask: reach for it
3680        /// when you want the durability shortfall, not on every status glance.
3681        #[arg(long)]
3682        refs: bool,
3683        #[arg(long, short = 'i', value_name = "PATH")]
3684        identity: Option<PathBuf>,
3685        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3686        at: String,
3687    },
3688    /// **BACKFILL a standby with what it never got.**
3689    ///
3690    /// Adding a standby copies NOTHING. Replication rides the push bus, so a
3691    /// standby holds what was pushed after it existed — a repository's whole
3692    /// history arrives on its next push, and a repository nobody pushes to
3693    /// again never arrives at all. MEASURED on the reference appliance 2026-08-31: a
3694    /// standby configured against 22 existing repositories held **0** of them,
3695    /// and `alice/linux-mirror` had 3818 refs on the primary and none on the
3696    /// standby.
3697    ///
3698    /// This queues them. It does not copy anything itself: every byte still
3699    /// moves on the standby's own background worker, through the same bounded
3700    /// queue and the same backoff a push uses, so a backfill never blocks a
3701    /// push and never waits for one. Watch it with `replication list` — the
3702    /// `pending` count falls, and the row names the repository in flight.
3703    ///
3704    /// **It is safe to run again.** A repository already outstanding coalesces;
3705    /// one the standby already has costs one ref advertisement and an empty
3706    /// command list. Interrupted half way — a reboot, a dead standby, a network
3707    /// that went away — the answer is to run it again.
3708    ///
3709    /// **The hash.** A standby PUSH-CREATES sha1 — the first-push prospect since
3710    /// 2026-09-20, because git advertises the object format before the client
3711    /// says anything and sha1 is what an un-negotiated client assumes. A
3712    /// repository of any OTHER hash replicates to nothing until it is
3713    /// pre-created on the standby with
3714    /// `control repo create <ns>/<repo> --hash sha256`. Those repositories are
3715    /// listed by name rather than left to fail quietly.
3716    Resync {
3717        /// The standby, by name, exactly as `list` prints it. A name that is
3718        /// not configured is an error: "resynced" about a standby that was
3719        /// never there reads identically to "resynced" about the one you meant.
3720        name: String,
3721        /// Only this repository, `<namespace>/<repo>`.
3722        ///
3723        /// Without it, every repository the standby's scope covers. With it,
3724        /// one — which is how a repository too large to ship in this maintenance
3725        /// window stays a separate decision from all the others.
3726        #[arg(long, value_name = "NS/REPO")]
3727        repo: Option<String>,
3728        /// Everything the standby covers **except** these. Repeatable.
3729        ///
3730        /// The other half of `--repo`, and the one you actually reach for. On
3731        /// the reference appliance 24 of the 25 repositories are a few hundred
3732        /// megabytes together and one is ~8.7 GB and 13.9 M objects that
3733        /// projects **182–235 GiB** against a standby whose `/data` is
3734        /// **224.9 GiB**. Without this the only way to ship the safe
3735        /// twenty-four is one call each, and a box with no shell is exactly
3736        /// what this control plane is for.
3737        ///
3738        /// Ignored when `--repo` names one. An exclusion that matched nothing
3739        /// is reported rather than passed over: a typo and a real exclusion are
3740        /// the same command with very different outcomes.
3741        #[arg(long, value_name = "NS/REPO")]
3742        except: Vec<String>,
3743        #[arg(long, short = 'i', value_name = "PATH")]
3744        identity: Option<PathBuf>,
3745        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3746        at: String,
3747    },
3748
3749    /// **Flush — the replication watermark.** Push everything pending to
3750    /// every standby, wait (bounded), and report whether the twin is caught
3751    /// up: the proof step 0 of a data-set growth needs before `/data` is
3752    /// unmounted. Changes no role and no roster.
3753    ///
3754    /// What it proves: every push the replicator saw is on the twin. What
3755    /// it does NOT prove: refs outside heads/tags, LFS objects, the control
3756    /// database, visibility, tombstones — and repositories nobody pushed
3757    /// since the twin existed, unless `--resync` enqueues them all.
3758    Flush {
3759        /// How long to wait for the queues to drain; 0 = the abdicate ceiling.
3760        #[arg(long, default_value_t = 0, value_name = "SECS")]
3761        timeout_secs: u32,
3762        /// Enqueue EVERY repository first (a full backfill).
3763        #[arg(long)]
3764        resync: bool,
3765        #[arg(long, short = 'i', value_name = "PATH")]
3766        identity: Option<PathBuf>,
3767        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3768        at: String,
3769    },
3770}
3771
3772/// `gunnar control grow …` — the runtime data-set growth
3773/// (`DATA-SET-GROWTH-FLOW.md` §3, ruling 1: the appliance never goes down).
3774#[cfg(feature = "control")]
3775#[derive(Subcommand, Debug, Clone)]
3776pub enum ControlGrowCommand {
3777    /// **Begin a growth.** gunnar-admin. Drains the box (new pushes are
3778    /// refused with "gunnar.rs is growing its storage…"), unmounts `/data`,
3779    /// then WAITS for the outside actor to detach, enlarge and re-attach
3780    /// every member volume; when all are back and larger it grows in place,
3781    /// mounts and serves again. `grow status` follows it; `grow resume` says
3782    /// "look again".
3783    ///
3784    /// Mints an OPERATOR go-ahead (the signed fact D3 asks for): the
3785    /// gunnar-admin credential on this call is the approval. `--go-ahead`
3786    /// sends one minted elsewhere (monetize's, on a metered box) instead.
3787    Start {
3788        /// The per-member size to grow to, GiB; without it, whatever every
3789        /// member comes back larger at.
3790        #[arg(long, value_name = "GIB")]
3791        target_gib: Option<u64>,
3792        /// How long in-flight transfers may take to finish before the growth
3793        /// aborts with nothing changed (never kills a transfer).
3794        #[arg(long, default_value_t = 600, value_name = "SECS")]
3795        drain_timeout_secs: u32,
3796        /// How long `/data` may stay unmounted waiting for the members. Past it,
3797        /// with every member present, the set is mounted back at the size the
3798        /// members carry and the growth fails as `grow.unmounted_too_long`
3799        /// (alarmed on `Admin.Health`); with a member missing it keeps waiting,
3800        /// alarmed.
3801        #[arg(long, default_value_t = 3600, value_name = "SECS")]
3802        wait_members_timeout_secs: u32,
3803        /// A go-ahead file to send verbatim instead of minting an operator one.
3804        #[arg(long, value_name = "PATH")]
3805        go_ahead: Option<PathBuf>,
3806        #[arg(long, short = 'i', value_name = "PATH")]
3807        identity: Option<PathBuf>,
3808        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3809        at: String,
3810    },
3811    /// The growth's phase, the members and the journal. **Needs no
3812    /// credential** — the actor outside the box that owns the volumes reads
3813    /// this while the identity table is off the stripe.
3814    Status {
3815        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3816        at: String,
3817    },
3818    /// **Look again**: the members are back and larger. gunnar-admin.
3819    /// `--give-up` stops waiting, assembles what is there at whatever size it
3820    /// is, and serves.
3821    Resume {
3822        #[arg(long)]
3823        give_up: bool,
3824        #[arg(long, short = 'i', value_name = "PATH")]
3825        identity: Option<PathBuf>,
3826        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3827        at: String,
3828    },
3829}
3830
3831/// `gunnar control webhook …` — the event stream's outbound delivery, over the control
3832/// plane.
3833///
3834/// Each verb returns the endpoint roster AND the server's webhook signing
3835/// public key — the OpenSSH line a receiver pins to verify a delivery with
3836/// `ssh-keygen -Y verify`. The private half never leaves the box.
3837#[cfg(feature = "control")]
3838#[derive(Debug, Clone, Subcommand)]
3839pub enum ControlWebhookCommand {
3840    /// **Add or replace an endpoint, by name.** Takes effect immediately and
3841    /// survives a reboot.
3842    ///
3843    /// Idempotent: the same `<name>` twice replaces rather than duplicates.
3844    Add {
3845        /// Your name for this endpoint. Every log line and status row names it
3846        /// by this rather than by its URL.
3847        name: String,
3848        /// Where deliveries are POSTed: `http://…` or `https://…`.
3849        url: String,
3850        /// Only deliver these event kinds (repeatable, or comma-separated),
3851        /// e.g. `ref.updated`. Omit for every kind. A name that matches no kind
3852        /// is refused — a filter that names nothing delivers nothing.
3853        #[arg(long, value_name = "KIND", value_delimiter = ',')]
3854        kinds: Vec<String>,
3855        /// Only deliver events about this namespace.
3856        #[arg(long, value_name = "NAME", conflicts_with = "repo")]
3857        namespace: Option<String>,
3858        /// Only deliver events about this repository, `<namespace>/<repo>`.
3859        #[arg(long, value_name = "NS/REPO")]
3860        repo: Option<String>,
3861        /// An HMAC shared secret, for a receiver written against GitHub's
3862        /// `X-Hub-Signature-256`. The `sshsig` is always sent and is the one to
3863        /// prefer; this is only for compatibility. Stored on the server, never
3864        /// shown again.
3865        #[arg(long, value_name = "SECRET")]
3866        secret: Option<String>,
3867        #[arg(long, short = 'i', value_name = "PATH")]
3868        identity: Option<PathBuf>,
3869        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3870        at: String,
3871    },
3872    /// What is configured, plus the signing public key a receiver verifies
3873    /// deliveries with, and per-endpoint delivered/failed/dropped counters.
3874    List {
3875        #[arg(long, short = 'i', value_name = "PATH")]
3876        identity: Option<PathBuf>,
3877        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3878        at: String,
3879    },
3880    /// Remove an endpoint by name and stop delivering to it now.
3881    ///
3882    /// A name that is not configured is an error, not a quiet success:
3883    /// "removed" about an endpoint that was never there reads identically to
3884    /// removing the one you meant.
3885    Remove {
3886        name: String,
3887        #[arg(long, short = 'i', value_name = "PATH")]
3888        identity: Option<PathBuf>,
3889        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3890        at: String,
3891    },
3892}
3893
3894/// `gunnar control policy …` — the pre-receive gate, over the control plane.
3895///
3896/// **The asymmetry these close.** Since a merge started requiring a verified
3897/// signed approval naming the exact commit, the review gate has been real —
3898/// and a direct push to `main` walked straight past it, because the surface
3899/// that would have stopped it was reachable from nothing. `Policy.*` has been
3900/// served since `gunnar_core::PolicyStore` gave the document a home at
3901/// `refs/gunnar/policy`; these verbs are the half that lets anyone say so.
3902///
3903/// A rule is stored **per repository, keyed by ref pattern**, and the pattern
3904/// is a git-style glob over full ref names — `refs/heads/main`,
3905/// `refs/heads/release/*`. It is not a branch name: `main` matches no ref and
3906/// protects nothing, which is why `protect` refuses it by name.
3907#[cfg(feature = "control")]
3908#[derive(Debug, Clone, Subcommand)]
3909pub enum ControlPolicyCommand {
3910    /// **Protect refs matching a pattern.** Upserted on the pattern, so the
3911    /// same pattern twice replaces rather than accumulates.
3912    ///
3913    /// The defaults are the strict ones: with no flags at all a matching ref
3914    /// cannot be force-pushed and cannot be deleted. Each `--allow-*` gives one
3915    /// of those back, which makes the permissive choice the one that had to be
3916    /// typed.
3917    Protect {
3918        /// `<account>/<name>`.
3919        path: String,
3920        /// A git-style glob over FULL ref names — `refs/heads/main`,
3921        /// `refs/heads/release/*`. Not a branch name.
3922        pattern: String,
3923        /// Permit a non-fast-forward update. Off by default.
3924        #[arg(long)]
3925        allow_force_push: bool,
3926        /// Permit deleting a matching ref. Off by default.
3927        #[arg(long)]
3928        allow_delete: bool,
3929        /// Every new commit must carry a valid ssh-sig from a key registered
3930        /// to its pusher.
3931        #[arg(long)]
3932        require_signature: bool,
3933        /// Only these principals may push here. Repeatable. Empty — the
3934        /// default — means "any principal with WRITE".
3935        #[arg(long, value_name = "ID")]
3936        principal: Vec<String>,
3937        /// Reject an object larger than this BEFORE it lands. 0 is no limit.
3938        #[arg(long, value_name = "BYTES", default_value_t = 0)]
3939        max_object_bytes: u64,
3940        /// Reject a push carrying more objects than this. 0 is no limit.
3941        #[arg(long, value_name = "N", default_value_t = 0)]
3942        max_objects_per_push: u64,
3943        /// A merge into a matching ref does not count the pull request
3944        /// AUTHOR's own approval. Off by default: a solo operator must stay
3945        /// able to merge.
3946        #[arg(long)]
3947        forbid_self_approval: bool,
3948        /// A merge into a matching ref needs this many DISTINCT verified
3949        /// approvers of the exact tip. Approvals by the same principal count
3950        /// once. 0 keeps the baseline: one verified approval.
3951        #[arg(long, value_name = "N", default_value_t = 0)]
3952        required_approvals: u32,
3953        #[arg(long, short = 'i', value_name = "PATH")]
3954        identity: Option<PathBuf>,
3955        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3956        at: String,
3957    },
3958    /// **Remove the rule on a pattern**, exactly as `list` prints it.
3959    ///
3960    /// A pattern that has no rule is an error and not a quiet success:
3961    /// "unprotected" about a rule that was never there reads identically to
3962    /// "unprotected" about the one you meant, and the difference is whether
3963    /// `main` is still guarded.
3964    Unprotect {
3965        /// `<account>/<name>`.
3966        path: String,
3967        /// The pattern, byte for byte as `list` prints it.
3968        pattern: String,
3969        #[arg(long, short = 'i', value_name = "PATH")]
3970        identity: Option<PathBuf>,
3971        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3972        at: String,
3973    },
3974    /// What is protected here — and **"nothing"** said as the word.
3975    ///
3976    /// An unprotected repository and a repository whose rules failed to load
3977    /// are the same empty list and very different facts, so the empty case
3978    /// says which one it is.
3979    List {
3980        /// `<account>/<name>`.
3981        path: String,
3982        /// Only the rule on this pattern. Without it, every rule.
3983        #[arg(long, value_name = "GLOB")]
3984        pattern: Option<String>,
3985        #[arg(long, short = 'i', value_name = "PATH")]
3986        identity: Option<PathBuf>,
3987        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3988        at: String,
3989    },
3990    /// The ref-name allowlist/denylist. **Deny wins.**
3991    RefNames {
3992        /// `<account>/<name>`.
3993        path: String,
3994        #[arg(long, short = 'i', value_name = "PATH")]
3995        identity: Option<PathBuf>,
3996        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3997        at: String,
3998    },
3999    /// **Replace** the ref-name allowlist/denylist. Deny wins.
4000    ///
4001    /// Replace and not merge: the document is written whole, so passing
4002    /// neither flag CLEARS the policy rather than leaving the old one in
4003    /// place. That is the only version of this verb whose result can be
4004    /// predicted from the command line alone, and it says so before it writes.
4005    SetRefNames {
4006        /// `<account>/<name>`.
4007        path: String,
4008        /// A ref name may match one of these. Repeatable. Empty means "any
4009        /// name that is not denied".
4010        #[arg(long, value_name = "GLOB")]
4011        allow: Vec<String>,
4012        /// A ref name matching one of these is refused, even if it is also
4013        /// allowed. Repeatable.
4014        #[arg(long, value_name = "GLOB")]
4015        deny: Vec<String>,
4016        #[arg(long, short = 'i', value_name = "PATH")]
4017        identity: Option<PathBuf>,
4018        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4019        at: String,
4020    },
4021    /// The gates that run on this repository's push path, and **which of them
4022    /// this repository has configuration for**.
4023    ///
4024    /// `enabled` is not "compiled in": a gate with nothing configured cannot
4025    /// reject anything, and a list that claimed otherwise would be a UI
4026    /// rendering a guard that does not exist.
4027    Gates {
4028        /// `<account>/<name>`.
4029        path: String,
4030        #[arg(long, short = 'i', value_name = "PATH")]
4031        identity: Option<PathBuf>,
4032        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4033        at: String,
4034    },
4035}
4036
4037/// `gunnar control repo …`
4038#[cfg(feature = "control")]
4039#[derive(Debug, Subcommand)]
4040pub enum ControlRepoCommand {
4041    /// Create a repository at `<account>/<name>`.
4042    ///
4043    /// New repositories are **sha256**, so a stock `git init` working
4044    /// tree cannot push to one — `git init --object-format=sha256`, or
4045    /// `--hash sha1` here.
4046    Create {
4047        /// `<account>/<name>`.
4048        path: String,
4049        /// The object hash. sha256 unless you have a sha1 client you cannot
4050        /// change.
4051        #[arg(long, value_enum, default_value_t = HashArg::Sha256)]
4052        hash: HashArg,
4053        /// Readable by anyone, with no key at all.
4054        #[arg(long)]
4055        public: bool,
4056        /// **Take over a store already on disk at this name** (a catalog
4057        /// restored over a surviving data disk). Refused unless the store's
4058        /// hash marker matches `--hash`. Without it a create refuses the name
4059        /// BY NAME: "a store already exists at …; delete reclaims it".
4060        #[arg(long)]
4061        adopt: bool,
4062        #[arg(long, short = 'i', value_name = "PATH")]
4063        identity: Option<PathBuf>,
4064        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4065        at: String,
4066    },
4067    /// List repositories.
4068    List {
4069        /// Only this account's.
4070        #[arg(long, value_name = "ACCOUNT")]
4071        namespace: Option<String>,
4072        /// Only repositories whose `<account>/<name>` contains this,
4073        /// case-insensitively — gunnar filters, over everything you may see.
4074        #[arg(long, value_name = "TEXT")]
4075        query: Option<String>,
4076        /// Only repositories that are mirrors of an upstream.
4077        #[arg(long)]
4078        mirrors: bool,
4079        #[arg(long, short = 'i', value_name = "PATH")]
4080        identity: Option<PathBuf>,
4081        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4082        at: String,
4083    },
4084    /// Show one repository: its hash, default branch and visibility.
4085    Get {
4086        /// `<account>/<name>`.
4087        path: String,
4088        #[arg(long, short = 'i', value_name = "PATH")]
4089        identity: Option<PathBuf>,
4090        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4091        at: String,
4092    },
4093    /// **Delete a repository. Without `--purge` the bytes are KEPT for a
4094    /// retention window and `repo undelete` puts it back.**
4095    ///
4096    /// The catalog row goes either way, so the name stops working immediately.
4097    /// What differs is the bytes: a tombstone holds them for
4098    /// `--retention-days` (30 by default) and a sweep reclaims them when it
4099    /// closes; `--purge` returns them now, in about two seconds, with no undo.
4100    ///
4101    /// A tombstone is still storage on the account until it expires —
4102    /// `repo list-deleted` shows what is held and what it costs.
4103    Delete {
4104        /// `<account>/<name>`.
4105        path: String,
4106        /// Required. Deleting a repository takes its name out of the catalog
4107        /// immediately, and a typo in a path is not distinguishable from an
4108        /// intention.
4109        #[arg(long)]
4110        yes: bool,
4111        /// **Reclaim the bytes NOW.** No window, no undelete.
4112        #[arg(long)]
4113        purge: bool,
4114        /// How long the bytes are kept. `0` means at the next sweep.
4115        #[arg(long, value_name = "DAYS")]
4116        retention_days: Option<u32>,
4117        #[arg(long, short = 'i', value_name = "PATH")]
4118        identity: Option<PathBuf>,
4119        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4120        at: String,
4121    },
4122    /// **Put a soft-deleted repository back**, inside its retention window.
4123    Undelete {
4124        /// `<account>/<name>`.
4125        path: String,
4126        #[arg(long, short = 'i', value_name = "PATH")]
4127        identity: Option<PathBuf>,
4128        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4129        at: String,
4130    },
4131    /// **End a retention window early.** There is no undo after it.
4132    Purge {
4133        /// `<account>/<name>`.
4134        path: String,
4135        /// Required, for the same reason `delete --yes` is.
4136        #[arg(long)]
4137        yes: bool,
4138        #[arg(long, short = 'i', value_name = "PATH")]
4139        identity: Option<PathBuf>,
4140        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4141        at: String,
4142    },
4143    /// **Maintain a live repository: seal, compact, drop the read cache.**
4144    ///
4145    /// The same maintenance `gunnar gc <path>` runs on the box, reachable from
4146    /// wherever the control plane is. It reclaims only what the server can
4147    /// re-derive — the lazily-exploded read cache goes, the commit-and-tree
4148    /// pool stays because that is the graph — so there is nothing to confirm
4149    /// and no `--yes`: the worst outcome is a slower next read.
4150    ///
4151    /// Needs `Write` on the repository, not `Admin`. A principal who may push
4152    /// to a repository may reclaim its cache.
4153    ///
4154    /// The server runs **one gc at a time for the whole box** and queues the
4155    /// rest, so this enqueues and then waits for its turn, printing what it
4156    /// freed. Asking twice for a repository already queued joins the job that
4157    /// is there rather than running a second one.
4158    Gc {
4159        /// `<account>/<name>`.
4160        path: String,
4161        /// Enqueue and return, printing the job instead of waiting for it.
4162        ///
4163        /// For a script that wants the gc started and is not going to sit
4164        /// through it. `repo gc-status <path>` reads the outcome afterwards.
4165        #[arg(long)]
4166        no_wait: bool,
4167        #[arg(long, short = 'i', value_name = "PATH")]
4168        identity: Option<PathBuf>,
4169        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4170        at: String,
4171    },
4172    /// **How a repository's gc is going, or how the last one went.**
4173    ///
4174    /// Needs only `Read`: seeing that maintenance ran is not asking for it.
4175    /// Says so plainly when the server is holding no job at all, which is a
4176    /// different answer from a gc that reclaimed nothing.
4177    GcStatus {
4178        /// `<account>/<name>`.
4179        path: String,
4180        #[arg(long, short = 'i', value_name = "PATH")]
4181        identity: Option<PathBuf>,
4182        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4183        at: String,
4184    },
4185    /// What is deleted and not yet purged, with the bytes it is still costing.
4186    ListDeleted {
4187        /// Only this account's.
4188        #[arg(long, value_name = "ACCOUNT")]
4189        namespace: Option<String>,
4190        #[arg(long, short = 'i', value_name = "PATH")]
4191        identity: Option<PathBuf>,
4192        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4193        at: String,
4194    },
4195    /// **Store directories with no catalog row and no tombstone — bytes
4196    /// nothing else can see, and names nothing else can free.**
4197    ///
4198    /// Every other reclaim path keys off a record: `repo delete` off the
4199    /// catalog row, `undelete`/`purge`/`list-deleted`/the sweep off a
4200    /// tombstone. A directory with neither is invisible to all of them, and
4201    /// `repo create` still refuses its name because the bytes are there.
4202    /// MEASURED on the appliance 2026-08-30: ~25-39 GB at `alice/linux`,
4203    /// stranded.
4204    ///
4205    /// The `HASH` column is the store's `OBJECT_FORMAT` marker, and `-` means
4206    /// the store predates it — that is the value to pass to `repo create
4207    /// --adopt --hash` if you want to KEEP the objects instead of reclaiming
4208    /// them.
4209    ListOrphans {
4210        /// Only this account's.
4211        #[arg(long, value_name = "ACCOUNT")]
4212        namespace: Option<String>,
4213        #[arg(long, short = 'i', value_name = "PATH")]
4214        identity: Option<PathBuf>,
4215        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4216        at: String,
4217    },
4218    /// **Reclaim an orphan store's bytes.** No window, no undo.
4219    ///
4220    /// To KEEP the objects instead, `repo create <path> --adopt --hash <the
4221    /// HASH column of list-orphans>` takes the store over as a repository.
4222    Reclaim {
4223        /// `<account>/<name>`.
4224        path: String,
4225        /// Required, for the same reason `delete --yes` is.
4226        #[arg(long)]
4227        yes: bool,
4228        #[arg(long, short = 'i', value_name = "PATH")]
4229        identity: Option<PathBuf>,
4230        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4231        at: String,
4232    },
4233    /// Set the branch a fresh clone checks out.
4234    SetDefaultBranch {
4235        /// `<account>/<name>`.
4236        path: String,
4237        /// A branch name (`main`) or a full ref (`refs/heads/main`). A short
4238        /// name is expanded to `refs/heads/<name>`; anything already under
4239        /// `refs/` is sent as given, so the server refuses a `refs/tags/…`
4240        /// rather than this quietly rewriting it.
4241        branch: String,
4242        #[arg(long, short = 'i', value_name = "PATH")]
4243        identity: Option<PathBuf>,
4244        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4245        at: String,
4246    },
4247    /// **Pull one repository's stored bytes OFF the box, over the control
4248    /// plane.**
4249    ///
4250    /// `gunnar repo archive-out` writes the same bytes to a path *on* the
4251    /// appliance, which is no use as a backup: the box has no shell to copy it
4252    /// out with. This streams them to a file on the machine running the
4253    /// command, so a cron somewhere else can hold the copy.
4254    ///
4255    /// The file it writes is exactly the file the local verb writes —
4256    /// `gunnar repo archive-in <FILE> --as <ns/name>` restores it, and the
4257    /// digest the server computed while sending is checked against the bytes
4258    /// that arrived.
4259    ///
4260    /// **It needs a Read grant on the repository**, the same grant cloning it
4261    /// privately needs, because that is what it hands over.
4262    ///
4263    /// `--from-generation` is a WATERMARK, not a diff: pass the previous run's
4264    /// `to_generation` and only the new bytes travel. Safe while the server is
4265    /// serving; there is nothing to freeze.
4266    ArchiveOut {
4267        /// `<account>/<name>`.
4268        path: String,
4269        /// Where to write the archive, **on this machine**.
4270        #[arg(long, value_name = "FILE")]
4271        to: PathBuf,
4272        /// Start above this `objects.pack` offset. 0 = a full archive.
4273        #[arg(long, default_value_t = 0, value_name = "N")]
4274        from_generation: u64,
4275        #[arg(long, short = 'i', value_name = "PATH")]
4276        identity: Option<PathBuf>,
4277        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4278        at: String,
4279    },
4280    /// Make a repository readable with no key at all, or stop it being.
4281    SetVisibility {
4282        /// `<account>/<name>`.
4283        path: String,
4284        /// `public` is anonymous READ. It never makes anything writable.
4285        #[arg(value_enum)]
4286        visibility: VisibilityArg,
4287        #[arg(long, short = 'i', value_name = "PATH")]
4288        identity: Option<PathBuf>,
4289        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4290        at: String,
4291    },
4292    /// Make a repository ENCRYPTED, or stop it being: on, the server refuses
4293    /// any push that would put plaintext on it. The account admin's decision.
4294    ///
4295    /// The server never holds a key either way; members encrypt with
4296    /// `gunnar crypt`. See `.nornir/encrypted-repos.md`.
4297    SetEncrypted {
4298        /// `<account>/<name>`.
4299        path: String,
4300        /// `on` or `off`.
4301        #[arg(value_parser = ["on", "off"])]
4302        state: String,
4303        #[arg(long, short = 'i', value_name = "PATH")]
4304        identity: Option<PathBuf>,
4305        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4306        at: String,
4307    },
4308}
4309
4310/// `--visibility`, one arm per [`gunnar_core::Visibility`].
4311#[cfg(feature = "control")]
4312#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
4313pub enum VisibilityArg {
4314    /// Reachable only with a key that holds a grant. The default for a new
4315    /// repository.
4316    Private,
4317    /// Anonymously READABLE. Writing still needs a key and a grant, and the
4318    /// listener's `--no-anonymous` closes the door regardless.
4319    Public,
4320}
4321
4322#[cfg(feature = "control")]
4323impl VisibilityArg {
4324    /// The proto's enum. One translation, in one place.
4325    pub fn to_proto(self) -> gunnar_proto::Visibility {
4326        match self {
4327            VisibilityArg::Private => gunnar_proto::Visibility::Private,
4328            VisibilityArg::Public => gunnar_proto::Visibility::Public,
4329        }
4330    }
4331}
4332
4333/// `gunnar control namespace …`
4334/// `replication add --scope`, see the verb.
4335#[cfg(feature = "control")]
4336#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
4337pub enum ReplicationScopeArg {
4338    /// Every repository the target's scope covers — a standby.
4339    All,
4340    /// Only public repositories — a browse replica's feed.
4341    PublicOnly,
4342}
4343
4344#[cfg(feature = "control")]
4345#[derive(Debug, Subcommand)]
4346pub enum ControlNamespaceCommand {
4347    /// Create a namespace. Nested names (`team/sub`) are legal; grants do NOT
4348    /// inherit between them.
4349    ///
4350    /// There is deliberately **no `--description`**. `CreateNamespaceRequest`
4351    /// carries the field, `CoreControlPlane::create_namespace` takes it as
4352    /// `_description` and drops it, `gunnar_core::Namespace` has nowhere to put
4353    /// it and `list_namespaces` returns `String::new()` for every namespace.
4354    /// A flag that reached none of that would be the same defect this file is
4355    /// closing, one layer up.
4356    Create {
4357        /// The namespace name.
4358        name: String,
4359        #[arg(long, short = 'i', value_name = "PATH")]
4360        identity: Option<PathBuf>,
4361        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4362        at: String,
4363    },
4364    /// List namespaces.
4365    List {
4366        #[arg(long, short = 'i', value_name = "PATH")]
4367        identity: Option<PathBuf>,
4368        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4369        at: String,
4370    },
4371    /// Delete a namespace. A namespace that still holds repositories is
4372    /// refused unless `--delete-repos` says otherwise; deleting them is never
4373    /// implicit.
4374    Delete {
4375        /// The namespace name.
4376        name: String,
4377        /// Delete the repositories in it too. **There is no undo.**
4378        #[arg(long)]
4379        delete_repos: bool,
4380        #[arg(long, short = 'i', value_name = "PATH")]
4381        identity: Option<PathBuf>,
4382        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4383        at: String,
4384    },
4385    /// **The account admin's settings: is the namespace public, are its
4386    /// events public.** Both are private for every account until its admin
4387    /// says otherwise, and only the account admin may (`Repos.
4388    /// SetNamespaceSettings`). With no flag, prints the current settings.
4389    Settings {
4390        /// The namespace (the account).
4391        name: String,
4392        /// Show the namespace to everyone (`on`), or only through its public
4393        /// repositories (`off`).
4394        #[arg(long, value_parser = ["on", "off"])]
4395        public: Option<String>,
4396        /// Show the account's emitted events to everyone (`on`) or only inside
4397        /// the account (`off`).
4398        #[arg(long, value_parser = ["on", "off"])]
4399        events_public: Option<String>,
4400        #[arg(long, short = 'i', value_name = "PATH")]
4401        identity: Option<PathBuf>,
4402        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4403        at: String,
4404    },
4405}
4406
4407/// `gunnar control user …`
4408#[cfg(feature = "control")]
4409#[derive(Debug, Subcommand)]
4410pub enum ControlUserCommand {
4411    /// Create a user with a key. **Needs gunnar-admin.**
4412    ///
4413    /// The new user is always admin of their own account — the namespace named
4414    /// after them — and that is intrinsic, not a grant. `--gunnar-admin` is the
4415    /// separate, grantable thing, and any gunnar-admin may hand it on.
4416    Add {
4417        /// Their display name. It is also their id — and therefore their
4418        /// account — unless `--id` says otherwise.
4419        name: String,
4420        /// Their OpenSSH public key line, or `@<path>`.
4421        #[arg(long, value_name = "KEY")]
4422        key: String,
4423        /// **Their id, if it is not the name.** The id is the account namespace
4424        /// and the key every grant, gate and membership is filed under; it is
4425        /// chosen here, once, and never moves. The name is a label — see
4426        /// `user rename`. Same character rules as a namespace.
4427        #[arg(long, value_name = "ID")]
4428        id: Option<String>,
4429        /// Also make them gunnar-admin: a server-wide Admin grant, exactly the
4430        /// one the first user got.
4431        #[arg(long)]
4432        gunnar_admin: bool,
4433        /// **What kind of identity this is**: a `user` (a human, the
4434        /// default), a `machine` (CI, a bot, or **a standby's replication
4435        /// source** — a `machine` key is the one a frozen standby lets push),
4436        /// or a `deploy` key. A standby refuses a direct `git push` from a
4437        /// `user` or `deploy` key while accepting the primary's replication
4438        /// push, and that is decided by this kind — so the deploy key a primary
4439        /// replicates with must be enrolled here as `machine`.
4440        #[arg(long, value_enum, default_value_t = PrincipalKindArg::User)]
4441        kind: PrincipalKindArg,
4442        #[arg(long, short = 'i', value_name = "PATH")]
4443        identity: Option<PathBuf>,
4444        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4445        at: String,
4446    },
4447    /// **Enrol another key on a user who already exists.** One human, several
4448    /// machines, ONE principal.
4449    ///
4450    /// Without this verb the only way to reach a second machine was to create a
4451    /// second user — MEASURED 2026-08-06, when a desktop's key and a laptop's key
4452    /// became `alice` and `alice-desktop`. That is not a cosmetic split: every grant,
4453    /// every ownership and every revoke then fragments per machine, and
4454    /// `gunnar control user remove alice` stops meaning what an operator
4455    /// reads it as.
4456    ///
4457    /// The identity table has always held **many keys per principal** — the key
4458    /// table is `fingerprint -> {principal_id, …}` and `keys_of()` returns a
4459    /// list — and `Access.AddKey` has always existed on the wire. What was
4460    /// missing was only this: a way to call it for a principal that is already
4461    /// there, since `user add` calls `AddPrincipal` first and so can only ever
4462    /// key a brand-new one.
4463    ///
4464    /// **Needs gunnar-admin**, which is `Access.AddKey`'s existing rule; adding
4465    /// a key to yourself without one is a policy question, deliberately not
4466    /// decided here.
4467    AddKey {
4468        /// The user who is getting another key, by id. They must already exist.
4469        name: String,
4470        /// The OpenSSH public key line to add, or `@<path>` to read one from a
4471        /// file. This is the key of the OTHER machine, so it is normally
4472        /// `@~/.ssh/id_ed25519.pub` copied from there — not derivable from the
4473        /// key `-i` signs this call with.
4474        #[arg(long, value_name = "KEY")]
4475        key: String,
4476        /// **The user's own attestation that this key may act as them**, as
4477        /// `user attest-key` printed it, or `@<path>` to read it from a file.
4478        ///
4479        /// Required when the user already holds a key and you are not them:
4480        /// your credential proves who asked, their attestation proves they
4481        /// agreed, and without it an admin could quietly mint access as
4482        /// another person. Not needed for a user's first key (nobody to ask
4483        /// yet), nor when `-i` is one of the user's own keys (the call is
4484        /// already their signed word).
4485        #[arg(long, value_name = "ATTESTATION")]
4486        attestation: Option<String>,
4487        #[arg(long, short = 'i', value_name = "PATH")]
4488        identity: Option<PathBuf>,
4489        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4490        at: String,
4491    },
4492    /// **Attest, as a user, that a key may act as you.** Run this on a machine
4493    /// that holds one of your keys; hand the output to the admin who runs
4494    /// `user add-key … --attestation`.
4495    ///
4496    /// It signs nothing but the fingerprint of the new key, your id and the
4497    /// time, under the sshsig namespace `gunnar-key-attestation`, so it cannot
4498    /// be spent on any other key, any other principal, a group change or a
4499    /// review. Nothing is sent anywhere: this verb makes no connection.
4500    AttestKey {
4501        /// Your id (what `user list` shows in the first column).
4502        name: String,
4503        /// The public key being added, or `@<path>`.
4504        #[arg(long, value_name = "KEY")]
4505        key: String,
4506        /// The private key you attest WITH — one already enrolled on you.
4507        #[arg(long, short = 'i', value_name = "PATH")]
4508        identity: Option<PathBuf>,
4509    },
4510    /// **Retire ONE key and leave the principal standing** — the online
4511    /// counterpart of `gunnar key revoke`, which needs the server DOWN.
4512    ///
4513    /// The verb that a re-imaged machine needs. MEASURED on the live appliance
4514    /// 2026-09-19, rotating `gunnar-twin`'s one machine key after its box was
4515    /// re-imaged and the private half died with it:
4516    ///
4517    /// ```text
4518    ///   gunnar control key revoke <fp>      unrecognized subcommand 'key'
4519    ///   user add-key gunnar-twin --key …    FAILED_PRECONDITION: gunnar-twin already
4520    ///                                       holds 1 key(s), so adding another needs
4521    ///                                       gunnar-twin's own attestation
4522    ///   user remove gunnar-twin             removed: 1 keys, 1 GRANTS
4523    ///   user add gunnar-twin …              ALREADY_EXISTS: namespace already exists
4524    ///   namespace delete gunnar-twin        0 repositories went with it
4525    ///   user add gunnar-twin …              created
4526    ///   group member add twin gunnar-twin   added
4527    /// ```
4528    ///
4529    /// Six steps and four refusals, every one of them individually correct, to
4530    /// change one fingerprint. The first is the root of it: `gunnar key revoke`
4531    /// writes the control repository directly and therefore wants the server
4532    /// stopped — and an appliance is PID 1 with no shell, so on the machine
4533    /// that most needs the verb it can never be run. `Access.RevokeKey` has
4534    /// been on the wire all along and gunnar-ui has called it since the console
4535    /// shipped; no `gunnar control` verb reached it. An RPC nothing can call is
4536    /// a feature nobody has.
4537    ///
4538    /// With this, the rotation is two steps and no refusals:
4539    /// `user revoke-key <old-fp>` then `user add-key <name> --key <new>` — the
4540    /// attestation precondition is gone because the principal then holds no
4541    /// key, and the GRANTS never went anywhere, so nothing has to be put back.
4542    ///
4543    /// **Needs gunnar-admin**, which is `Access.RevokeKey`'s own rule at server
4544    /// scope. Unlike the console's `DELETE /api/account/keys`, this does not
4545    /// check the key is the caller's: see the handler for why.
4546    RevokeKey {
4547        /// The fingerprint to retire, exactly as `user keys` prints it —
4548        /// `SHA256:…`. The key is the thing named, not the person: which
4549        /// principal held it is read back and reported.
4550        fingerprint: String,
4551        /// **Revoke it even if it is the last key that could run this verb.**
4552        ///
4553        /// Without this the last admin key is refused, and that refusal is the
4554        /// one guard here. See the handler: an appliance that loses its last
4555        /// gunnar-admin key cannot get one back, because the offline verb that
4556        /// would needs a shell the appliance does not have.
4557        #[arg(long)]
4558        force: bool,
4559        #[arg(long, short = 'i', value_name = "PATH")]
4560        identity: Option<PathBuf>,
4561        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4562        at: String,
4563    },
4564    /// The keys a user holds, or every key on the server.
4565    ///
4566    /// The verb that makes the question *"is this one human or three?"*
4567    /// answerable at all.
4568    Keys {
4569        /// Only this user's keys. Omit for every key on the server.
4570        name: Option<String>,
4571        #[arg(long, short = 'i', value_name = "PATH")]
4572        identity: Option<PathBuf>,
4573        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4574        at: String,
4575    },
4576    /// **A user's passkeys** (`Access.ListCredentials`, Stage L): the web
4577    /// credentials gunnar verifies a console login with. Your own need no
4578    /// grant; somebody else's, or every one (omit the name), need gunnar-admin.
4579    Credentials {
4580        /// Only this user's. Omit for every passkey on the server.
4581        name: Option<String>,
4582        #[arg(long, short = 'i', value_name = "PATH")]
4583        identity: Option<PathBuf>,
4584        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4585        at: String,
4586    },
4587    /// **Revoke a passkey** (`Access.RevokeCredential`), named by its id
4588    /// exactly as `user credentials` prints it (base64url). Your own need no
4589    /// grant; anybody's need gunnar-admin.
4590    RevokeCredential {
4591        /// The credential id, base64url.
4592        id: String,
4593        #[arg(long, short = 'i', value_name = "PATH")]
4594        identity: Option<PathBuf>,
4595        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4596        at: String,
4597    },
4598    /// List users.
4599    List {
4600        #[arg(long, short = 'i', value_name = "PATH")]
4601        identity: Option<PathBuf>,
4602        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4603        at: String,
4604    },
4605    /// **Change a user's display name, and nothing else.** Needs gunnar-admin.
4606    ///
4607    /// The id — the account namespace, and what every key, grant, membership
4608    /// and gate is filed under — stays exactly where it is; only the label
4609    /// moves. A console that pinned the id sees the same person afterwards.
4610    /// Refused if another principal already carries the name, as its name or
4611    /// as its id.
4612    Rename {
4613        /// Their id (what `user list` shows in the first column).
4614        id: String,
4615        /// The new display name.
4616        name: String,
4617        #[arg(long, short = 'i', value_name = "PATH")]
4618        identity: Option<PathBuf>,
4619        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4620        at: String,
4621    },
4622    /// Remove a user, with their keys and grants.
4623    ///
4624    /// This does NOT reopen the bootstrap window, not even if it removes the
4625    /// last user on the server.
4626    Remove {
4627        /// Their id.
4628        name: String,
4629        #[arg(long, short = 'i', value_name = "PATH")]
4630        identity: Option<PathBuf>,
4631        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4632        at: String,
4633    },
4634}
4635
4636/// `gunnar control group …` — groups in the ACL (punkt 9).
4637///
4638/// A group is a principal that grants attach to and members inherit. Everything
4639/// here needs gunnar-admin, the same rule as a grant, because adding a member
4640/// to a group that holds a grant is handing that member the grant.
4641#[cfg(feature = "control")]
4642#[derive(Debug, Subcommand)]
4643pub enum ControlGroupCommand {
4644    /// **Create a group.** It holds no key; grant it access with
4645    /// `gunnar control grant <group> …` and add members below.
4646    Add {
4647        /// The group's name, its id in every grant and membership.
4648        name: String,
4649        #[arg(long, short = 'i', value_name = "PATH")]
4650        identity: Option<PathBuf>,
4651        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4652        at: String,
4653    },
4654    /// **Membership**: add or remove a principal from a group.
4655    #[command(subcommand)]
4656    Member(ControlGroupMemberCommand),
4657    /// **List the groups** on the server.
4658    List {
4659        #[arg(long, short = 'i', value_name = "PATH")]
4660        identity: Option<PathBuf>,
4661        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4662        at: String,
4663    },
4664    /// **List the members of one group** (its direct members).
4665    Members {
4666        /// The group to list.
4667        group: String,
4668        #[arg(long, short = 'i', value_name = "PATH")]
4669        identity: Option<PathBuf>,
4670        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4671        at: String,
4672    },
4673    /// **The signed membership history** — every add and remove, in order, each
4674    /// a signed event a third party can verify. This is the evidence behind the
4675    /// live member list: who was in a group, when, and by whose hand.
4676    History {
4677        /// The group to show the history of. Omit for every group's history.
4678        group: Option<String>,
4679        #[arg(long, short = 'i', value_name = "PATH")]
4680        identity: Option<PathBuf>,
4681        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4682        at: String,
4683    },
4684}
4685
4686/// `gunnar control group member …`
4687#[cfg(feature = "control")]
4688#[derive(Debug, Subcommand)]
4689pub enum ControlGroupMemberCommand {
4690    /// **Add a principal to a group.** They inherit the group's grants at once.
4691    Add {
4692        /// The group.
4693        group: String,
4694        /// The principal to add — a user, a machine, or another group (nesting).
4695        principal: String,
4696        #[arg(long, short = 'i', value_name = "PATH")]
4697        identity: Option<PathBuf>,
4698        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4699        at: String,
4700    },
4701    /// **Remove a principal from a group.** They lose the inherited access at
4702    /// once.
4703    Remove {
4704        /// The group.
4705        group: String,
4706        /// The principal to remove.
4707        principal: String,
4708        #[arg(long, short = 'i', value_name = "PATH")]
4709        identity: Option<PathBuf>,
4710        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4711        at: String,
4712    },
4713}
4714
4715// ───────────────────────────────────────────────────────────────────────────
4716// client
4717// ───────────────────────────────────────────────────────────────────────────
4718
4719/// Where a client verb operates.
4720#[cfg(feature = "client")]
4721#[derive(Debug, clap::Args)]
4722pub struct RepoArgs {
4723    /// The repository to work in. Defaults to the current directory.
4724    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
4725    pub repo: PathBuf,
4726}
4727
4728/// `gunnar create`.
4729#[cfg(feature = "client")]
4730#[derive(Debug, clap::Args)]
4731pub struct CreateArgs {
4732    /// `<namespace>/<repository>`.
4733    pub repo: String,
4734    /// sha1 instead of sha256.
4735    #[arg(long)]
4736    pub sha1: bool,
4737    /// The forge's SSH address: `ssh://git@git.gunnar.rs:2222` or `git@host`.
4738    /// Default: the `origin` of the clone you stand in.
4739    #[arg(long, value_name = "SSH-URL")]
4740    pub host: Option<String>,
4741    /// The SSH key to sign in with (default: whatever ssh picks).
4742    #[arg(short = 'i', long, value_name = "KEY")]
4743    pub identity: Option<std::path::PathBuf>,
4744}
4745
4746/// `gunnar settings`.
4747#[cfg(feature = "client")]
4748#[derive(Debug, clap::Args)]
4749pub struct SettingsArgs {
4750    /// `<namespace>/<repository>`.
4751    pub repo: String,
4752    /// Make it `public` (anyone may read, no key) or `private`.
4753    #[arg(long, value_name = "public|private")]
4754    pub visibility: Option<String>,
4755    /// The branch a new clone checks out: a short name (`main`) or `refs/heads/…`.
4756    #[arg(long, value_name = "BRANCH")]
4757    pub default_branch: Option<String>,
4758    /// The forge's SSH address: `ssh://git@git.gunnar.rs:2222` or `git@host`.
4759    /// Default: the `origin` of the clone you stand in.
4760    #[arg(long, value_name = "SSH-URL")]
4761    pub host: Option<String>,
4762    /// The SSH key to sign in with (default: whatever ssh picks).
4763    #[arg(short = 'i', long, value_name = "KEY")]
4764    pub identity: Option<std::path::PathBuf>,
4765}
4766
4767/// `gunnar clone`.
4768#[cfg(feature = "client")]
4769#[derive(Debug, clap::Args)]
4770pub struct CloneArgs {
4771    /// `git@host:ns/repo.git` or `ssh://git@host/ns/repo.git`.
4772    pub url: String,
4773    /// Where to put it. Defaults to the repository name.
4774    pub dest: Option<PathBuf>,
4775    /// The private key to authenticate with, and the ONLY one that will be:
4776    /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4777    /// a key this server does not accept is refused rather than quietly
4778    /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4779    #[arg(long, short = 'i', value_name = "PATH")]
4780    pub identity: Option<PathBuf>,
4781    /// Truncate history to `<n>` commits, counted at the remote.
4782    ///
4783    /// The default of the common CI fetch action, and therefore the CI case.
4784    #[arg(long, value_name = "N")]
4785    pub depth: Option<u32>,
4786    /// Only history newer than this date, e.g. `@1700000000` or `2.weeks.ago`.
4787    #[arg(long, value_name = "DATE")]
4788    pub shallow_since: Option<String>,
4789    /// Exclude everything reachable from this revision. Repeatable, and a
4790    /// **short** name like `side` is the normal spelling.
4791    #[arg(long, value_name = "REV")]
4792    pub shallow_exclude: Vec<String>,
4793    /// Omit objects, and fetch them only when something reads them:
4794    /// `blob:none`, `tree:<depth>` or `blob:limit=<n>`.
4795    ///
4796    /// `blob:limit=<n>` omits a blob of exactly `n` bytes, as git does.
4797    #[arg(long, value_name = "SPEC")]
4798    pub filter: Option<String>,
4799}
4800
4801#[cfg(feature = "client")]
4802impl CloneArgs {
4803    /// The shallow flags, as the client's own type.
4804    ///
4805    /// `git clone` has no `--unshallow` and no `--deepen` — both need a boundary
4806    /// to move — so neither is offered here, which is why this is a conversion
4807    /// rather than a field.
4808    pub fn depth_args(&self) -> gunnar_client::DepthArgs {
4809        gunnar_client::DepthArgs {
4810            depth: self.depth,
4811            deepen: None,
4812            since: self.shallow_since.clone(),
4813            exclude: self.shallow_exclude.clone(),
4814            unshallow: false,
4815        }
4816    }
4817}
4818
4819/// `gunnar fetch`.
4820#[cfg(feature = "client")]
4821#[derive(Debug, clap::Args)]
4822pub struct FetchArgs {
4823    /// The remote URL.
4824    pub url: String,
4825    /// The repository to fetch into.
4826    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
4827    pub repo: PathBuf,
4828    /// The private key to authenticate with, and the ONLY one that will be:
4829    /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4830    /// a key this server does not accept is refused rather than quietly
4831    /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4832    #[arg(long, short = 'i', value_name = "PATH")]
4833    pub identity: Option<PathBuf>,
4834    /// Refspecs to fetch, repeatable. Defaults to
4835    /// `+refs/heads/*:refs/remotes/origin/*` and `+refs/tags/*:refs/tags/*`.
4836    ///
4837    /// A refspec whose source is a glob and which names no destination —
4838    /// `refs/heads/*` — is REFUSED, the way `git` refuses it, because it would
4839    /// take the pack and write no reference at all. `refs/heads/main`, with no
4840    /// glob and no destination, is legal and updates `FETCH_HEAD` only.
4841    #[arg(long, value_name = "SPEC")]
4842    pub refspec: Vec<String>,
4843    /// Truncate history to `<n>` commits, counted at the remote. On an existing
4844    /// shallow repository this can also **shrink** the boundary.
4845    #[arg(long, value_name = "N")]
4846    pub depth: Option<u32>,
4847    /// `<n>` more commits past the boundary this repository already has.
4848    ///
4849    /// Not the same as `--depth`, which counts at the remote: `--deepen 1` on a
4850    /// depth-1 clone gains a commit, `--depth 1` on it gains nothing.
4851    #[arg(long, value_name = "N")]
4852    pub deepen: Option<u32>,
4853    /// Only history newer than this date, e.g. `@1700000000` or `2.weeks.ago`.
4854    #[arg(long, value_name = "DATE")]
4855    pub shallow_since: Option<String>,
4856    /// Exclude everything reachable from this revision. Repeatable, and a
4857    /// **short** name like `side` is the normal spelling.
4858    #[arg(long, value_name = "REV")]
4859    pub shallow_exclude: Vec<String>,
4860    /// Fetch the whole history and remove the shallow boundary.
4861    #[arg(long)]
4862    pub unshallow: bool,
4863    /// Omit objects, and fetch them only when something reads them:
4864    /// `blob:none`, `tree:<depth>` or `blob:limit=<n>`.
4865    #[arg(long, value_name = "SPEC")]
4866    pub filter: Option<String>,
4867}
4868
4869#[cfg(feature = "client")]
4870impl FetchArgs {
4871    /// The shallow flags, as the client's own type. The refusals live in
4872    /// [`gunnar_client::DepthArgs::to_shallow`], where they can be tested
4873    /// against the real `git` binary.
4874    pub fn depth_args(&self) -> gunnar_client::DepthArgs {
4875        gunnar_client::DepthArgs {
4876            depth: self.depth,
4877            deepen: self.deepen,
4878            since: self.shallow_since.clone(),
4879            exclude: self.shallow_exclude.clone(),
4880            unshallow: self.unshallow,
4881        }
4882    }
4883}
4884
4885/// `gunnar ls-remote`.
4886#[cfg(feature = "client")]
4887#[derive(Debug, clap::Args)]
4888pub struct LsRemoteArgs {
4889    /// The remote URL: `git@host:ns/repo.git` or `ssh://git@host/ns/repo.git`.
4890    pub url: String,
4891    /// The private key to authenticate with, and the ONLY one that will be:
4892    /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4893    /// a key this server does not accept is refused rather than quietly
4894    /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4895    #[arg(long, short = 'i', value_name = "PATH")]
4896    pub identity: Option<PathBuf>,
4897}
4898
4899/// `gunnar push`.
4900#[cfg(feature = "client")]
4901#[derive(Debug, clap::Args)]
4902pub struct PushArgs {
4903    /// The remote URL.
4904    pub url: String,
4905    /// Refspecs, e.g. `HEAD:refs/heads/main` or `+refs/heads/*:refs/heads/*`.
4906    /// Defaults to the current branch.
4907    pub refspecs: Vec<String>,
4908    /// The repository to push from.
4909    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
4910    pub repo: PathBuf,
4911    /// The private key to authenticate with, and the ONLY one that will be:
4912    /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4913    /// a key this server does not accept is refused rather than quietly
4914    /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4915    #[arg(long, short = 'i', value_name = "PATH")]
4916    pub identity: Option<PathBuf>,
4917    /// Allow non-fast-forward updates.
4918    #[arg(long, short = 'f')]
4919    pub force: bool,
4920    /// Negotiate and report, but send nothing.
4921    #[arg(long)]
4922    pub dry_run: bool,
4923}
4924
4925/// `gunnar crypt` — encrypted repositories (Level 1: stock git plus a filter).
4926#[cfg(feature = "client")]
4927#[derive(Debug, Subcommand)]
4928pub enum CryptCommand {
4929    /// Make this repository encrypted: a new key ring, wrapped to your
4930    /// encryption key.
4931    ///
4932    /// Writes `.gitattributes`, `.gunnar-crypt/members` and
4933    /// `.gunnar-crypt/keys`, sets up the filter in this clone and commits.
4934    /// Files committed BEFORE this stay plaintext in history. Your encryption
4935    /// key is the forge's (origin's); when you have none yet, one is made and
4936    /// wrapped to your SSH key.
4937    Init {
4938        /// Your member name (your Gunnar user name). Defaults to the name the forge origin
4939        /// points at says you are (its `gunnar-whoami` door); without one it is required.
4940        #[arg(long, value_name = "NAME")]
4941        member: Option<String>,
4942        /// Your encryption public key (`age1…`) when the forge is not asked. Without it
4943        /// the forge origin points at gives yours.
4944        #[arg(long = "key", value_name = "PUBKEY")]
4945        keys: Vec<String>,
4946        /// Start QUANTUM-SAFE: the ring is wrapped only to age1pq1… keys
4947        /// (yours defaults to the one `gunnar crypt keygen` made).
4948        #[arg(long)]
4949        quantum_safe: bool,
4950        /// Stage the files but do not commit.
4951        #[arg(long)]
4952        no_commit: bool,
4953    },
4954    /// Make your QUANTUM-SAFE key for encrypted repositories: an age1pq1…
4955    /// key (age's hybrid ML-KEM-768 + X25519), made on this machine.
4956    ///
4957    /// The private half is written mode 0600 and never leaves this machine;
4958    /// the public half (`<file>.pub`) is what you register with gunnar, beside
4959    /// your SSH keys, so members can add you from the server. SSH keys cannot
4960    /// be quantum-safe recipients: their key exchange (X25519, RSA) is what a
4961    /// quantum computer breaks.
4962    Keygen {
4963        /// Where to write the private key. Defaults to
4964        /// `$XDG_CONFIG_HOME/gunnar/crypt-pq.key` (`~/.config/gunnar/…`).
4965        #[arg(long, short = 'o', value_name = "PATH")]
4966        out: Option<PathBuf>,
4967        /// Replace an existing key file. Rings wrapped to the old key no
4968        /// longer open with the new one.
4969        #[arg(long)]
4970        force: bool,
4971    },
4972    /// Wrap the key ring to one more member. They need not be present: their
4973    /// encryption PUBLIC key is enough — given here, or the one gunnar holds
4974    /// for them (`--from-server`). In a quantum-safe repository it is their
4975    /// quantum-safe (age1pq1…) encryption key.
4976    #[command(name = "add-member")]
4977    AddMember {
4978        /// The member's name (their gunnar user name, for `--from-server`).
4979        name: String,
4980        /// Their encryption public key (`age1…`), as a file or a key line.
4981        #[arg(long = "key", value_name = "PUBKEY", required_unless_present = "from_server")]
4982        keys: Vec<String>,
4983        /// Use the encryption key gunnar holds for them (they must be able to
4984        /// read the repository, and so must you). Asked of the forge `origin`
4985        /// pushes to, over its SSH door (`gunnar-recipient-keys`, signed in by
4986        /// the ssh git runs) — or, with `--at`, of a control plane
4987        /// (`Repos.RecipientKeys`, `gunnar-cli` only).
4988        #[arg(long)]
4989        from_server: bool,
4990        /// The fingerprint of the member's key (`SHA256:…`, from them: their
4991        /// `gunnar crypt key status` or Account page). Required with
4992        /// `--from-server` the first time this machine sees their key: the
4993        /// server's word alone is not taken (RA4-1).
4994        #[arg(long, value_name = "SHA256:…", requires = "from_server")]
4995        fingerprint: Option<String>,
4996        /// The repository on the server, `<ns>/<repo>`. Defaults to the one
4997        /// `origin` points at.
4998        #[arg(long, value_name = "NS/REPO", requires = "from_server")]
4999        repo: Option<String>,
5000        /// A control plane to ask instead of the forge's SSH door (with
5001        /// `--from-server`; an installed estate binds it to its WireGuard
5002        /// overlay, so its members leave this off).
5003        #[arg(long, value_name = "ADDR", requires = "from_server")]
5004        at: Option<String>,
5005        /// The key to sign in with (with `--from-server`): the control call's
5006        /// signer, or `ssh -i` at the forge's door.
5007        #[arg(long, short = 'i', value_name = "PATH", requires = "from_server")]
5008        identity: Option<PathBuf>,
5009        /// Stage the files but do not commit.
5010        #[arg(long)]
5011        no_commit: bool,
5012    },
5013    /// Remove a member and rotate: content committed from now on is sealed
5014    /// under a new key they never get. What they already had, they keep.
5015    #[command(name = "remove-member")]
5016    RemoveMember {
5017        /// The member's name.
5018        name: String,
5019        /// Stage the files but do not commit.
5020        #[arg(long)]
5021        no_commit: bool,
5022    },
5023    /// Rotate: add a new key epoch; new content is sealed under it.
5024    Rotate {
5025        /// Stage the files but do not commit.
5026        #[arg(long)]
5027        no_commit: bool,
5028    },
5029    /// Open your encryption key with one of its unlock methods — your SSH
5030    /// key FILE, your security key, or your recovery code — and with it this
5031    /// repository's key ring; decrypt this clone.
5032    ///
5033    /// ssh-agent cannot help: it only signs. An encrypted key file asks for
5034    /// its passphrase on the terminal.
5035    Unlock {
5036        #[command(flatten)]
5037        open: KeyOpenArgs,
5038    },
5039    /// Your encryption key: one per person, for every encrypted repository you
5040    /// are a member of, opened by any of its unlock methods (an SSH key, a
5041    /// security key, a recovery code). Adding or removing a method changes no
5042    /// repository.
5043    #[command(subcommand)]
5044    Key(CryptKeyCommand),
5045    /// Forget the local key ring and put ciphertext back in the work tree.
5046    Lock,
5047    /// Members, key epochs, and whether this clone is unlocked.
5048    Status,
5049    /// Diff two revisions in plaintext — the same diff the browser console
5050    /// computes.
5051    Diff {
5052        /// The old revision.
5053        from: String,
5054        /// The new revision.
5055        to: String,
5056        /// Limit to these paths.
5057        #[arg(last = true)]
5058        paths: Vec<String>,
5059    },
5060    /// git filter: seal stdin to stdout (`%f` is the path).
5061    #[command(hide = true)]
5062    Clean {
5063        /// The path git names.
5064        path: Option<String>,
5065    },
5066    /// git filter: open stdin to stdout.
5067    #[command(hide = true)]
5068    Smudge {
5069        /// The path git names.
5070        path: Option<String>,
5071    },
5072    /// git filter, long-running protocol (`filter.<driver>.process`).
5073    #[command(name = "filter-process", hide = true)]
5074    FilterProcess,
5075    /// git diff driver: print a sealed file's plaintext.
5076    #[command(hide = true)]
5077    Textconv {
5078        /// The file git hands over.
5079        file: PathBuf,
5080    },
5081    /// git merge driver: open the three versions, merge the plaintext, seal
5082    /// the result.
5083    #[command(hide = true)]
5084    Merge {
5085        /// `%O`, the common ancestor.
5086        base: PathBuf,
5087        /// `%A`, ours; the result is written here.
5088        ours: PathBuf,
5089        /// `%B`, theirs.
5090        theirs: PathBuf,
5091        /// `%L`, the conflict marker size.
5092        #[arg(default_value_t = 7)]
5093        marker_size: usize,
5094        /// `%P`, the path.
5095        path: Option<String>,
5096    },
5097}
5098
5099/// How `gunnar crypt` opens your encryption key.
5100#[cfg(feature = "client")]
5101#[derive(Debug, Clone, Default, clap::Args)]
5102pub struct KeyOpenArgs {
5103    /// The key file to open it with: an SSH key file or a `gunnar crypt keygen`
5104    /// file. Defaults to `~/.ssh/id_ed25519`, `~/.ssh/id_rsa` and
5105    /// `~/.config/gunnar/crypt-pq.key`, whichever exist.
5106    #[arg(long, short = 'i', value_name = "PATH")]
5107    pub identity: Vec<PathBuf>,
5108    /// Open it with your FIDO2 security key (a YubiKey 5: its hmac-secret), over USB.
5109    #[arg(long, conflicts_with = "recovery_code")]
5110    pub security_key: bool,
5111    /// Open it with your printed recovery code (asked on the terminal).
5112    #[arg(long)]
5113    pub recovery_code: bool,
5114}
5115
5116/// `gunnar crypt key …`: your encryption key and its unlock methods.
5117#[cfg(feature = "client")]
5118#[derive(Debug, Subcommand)]
5119pub enum CryptKeyCommand {
5120    /// Your encryption key's public half and its unlock methods.
5121    Status,
5122    /// Make your encryption key (the first time), wrapped to your SSH key.
5123    New {
5124        /// A quantum-safe key (age1pq1…): for quantum-safe repositories only.
5125        #[arg(long)]
5126        quantum_safe: bool,
5127        /// The SSH public key that opens it. Defaults to `~/.ssh/id_ed25519.pub`.
5128        #[arg(long, value_name = "PUBKEY")]
5129        ssh_key: Option<String>,
5130    },
5131    /// One more SSH key opens your encryption key.
5132    AddSsh {
5133        /// The SSH public key, as a file or a key line. Defaults to `~/.ssh/id_ed25519.pub`.
5134        key: Option<String>,
5135        #[command(flatten)]
5136        open: KeyOpenArgs,
5137    },
5138    /// Your `gunnar crypt keygen` key (age1pq1…) opens your encryption key.
5139    AddPq {
5140        /// The age1pq1… public key, as a file or a key line. Defaults to the keygen file's.
5141        key: Option<String>,
5142        #[command(flatten)]
5143        open: KeyOpenArgs,
5144    },
5145    /// A FIDO2 security key opens your encryption key, here and in the console.
5146    AddSecurityKey {
5147        /// The console's host name: the WebAuthn relying party the credential is
5148        /// bound to, so the same key opens it in the browser too.
5149        #[arg(long, value_name = "HOST")]
5150        rp_id: String,
5151        /// Your name for this key ("YubiKey 5C, keyring").
5152        #[arg(long)]
5153        label: Option<String>,
5154        #[command(flatten)]
5155        open: KeyOpenArgs,
5156    },
5157    /// A printed recovery code opens your encryption key (shown once).
5158    AddRecoveryCode {
5159        #[command(flatten)]
5160        open: KeyOpenArgs,
5161    },
5162    /// Delete one unlock method (its id from `status`). The last one stays.
5163    Remove {
5164        /// The method's id.
5165        id: String,
5166    },
5167}
5168
5169/// Mirror verbs — the pull mirror.
5170#[cfg(feature = "client")]
5171#[derive(Debug, Subcommand)]
5172pub enum MirrorCommand {
5173    /// Register a remote to mirror.
5174    Add {
5175        /// A local name for the mirror.
5176        name: String,
5177        /// The remote URL.
5178        url: String,
5179        /// Where the mirror registry and its bare repositories live.
5180        #[arg(long, env = "GUNNAR_MIRROR_ROOT", value_name = "DIR")]
5181        root: Option<PathBuf>,
5182    },
5183    /// ls-remote, fetch the deltas, apply the refs.
5184    Sync {
5185        /// The mirror name.
5186        name: String,
5187        #[arg(long, env = "GUNNAR_MIRROR_ROOT", value_name = "DIR")]
5188        root: Option<PathBuf>,
5189        /// The private key to authenticate with, and the ONLY one that will be:
5190        /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
5191        /// a key this server does not accept is refused rather than quietly
5192        /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
5193        #[arg(long, short = 'i', value_name = "PATH")]
5194        identity: Option<PathBuf>,
5195    },
5196    /// What is registered, and what is on disk.
5197    Status {
5198        #[arg(long, env = "GUNNAR_MIRROR_ROOT", value_name = "DIR")]
5199        root: Option<PathBuf>,
5200    },
5201}
5202
5203/// `gunnar log`.
5204#[cfg(feature = "client")]
5205#[derive(Debug, clap::Args)]
5206pub struct LogArgs {
5207    /// The repository.
5208    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5209    pub repo: PathBuf,
5210    /// Where to start. Defaults to `HEAD`.
5211    #[arg(long, value_name = "REV")]
5212    pub start: Option<String>,
5213    /// How many commits to show.
5214    #[arg(long, short = 'n', default_value_t = 20)]
5215    pub max: usize,
5216}
5217
5218/// `gunnar commit`.
5219#[cfg(feature = "client")]
5220#[derive(Debug, clap::Args)]
5221pub struct CommitArgs {
5222    /// The commit message.
5223    #[arg(long, short = 'm')]
5224    pub message: String,
5225    /// The repository.
5226    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5227    pub repo: PathBuf,
5228}
5229
5230/// `gunnar branch`.
5231#[cfg(feature = "client")]
5232#[derive(Debug, clap::Args)]
5233pub struct BranchArgs {
5234    /// Create this branch. Omit to list.
5235    pub name: Option<String>,
5236    /// Delete `name` instead of creating it.
5237    #[arg(long, short = 'd')]
5238    pub delete: bool,
5239    /// The repository.
5240    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5241    pub repo: PathBuf,
5242}
5243
5244/// `gunnar tag`.
5245#[cfg(feature = "client")]
5246#[derive(Debug, clap::Args)]
5247pub struct TagArgs {
5248    /// Create this tag. Omit to list.
5249    pub name: Option<String>,
5250    /// Make it an annotated tag with this message.
5251    #[arg(long, short = 'm')]
5252    pub message: Option<String>,
5253    /// The repository.
5254    #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5255    pub repo: PathBuf,
5256}
5257
5258// ───────────────────────────────────────────────────────────────────────────
5259// systemd
5260// ───────────────────────────────────────────────────────────────────────────
5261
5262/// `gunnar service` — the `systemd` integration.
5263#[cfg(feature = "systemd")]
5264#[derive(Debug, Subcommand)]
5265pub enum ServiceCommand {
5266    /// Render and install the unit.
5267    Install(ServiceArgs),
5268    /// Start the service.
5269    Start(ServiceArgs),
5270    /// Stop the service.
5271    Stop(ServiceArgs),
5272    /// Is it active?
5273    Status(ServiceArgs),
5274    /// Remove the unit.
5275    ///
5276    /// Skidbladnir 0.1.12 has no `uninstall.rs`, so this is the thinner
5277    /// `Result<()>` — no `UninstallReport`, no guarded-delete purge law. Those
5278    /// arrived in the unpublished 0.1.13.
5279    Uninstall(ServiceArgs),
5280    /// Print the rendered unit and exit. Touches nothing.
5281    Unit(ServiceArgs),
5282}
5283
5284/// Shared arguments for every `gunnar service` verb.
5285#[cfg(feature = "systemd")]
5286#[derive(Debug, clap::Args, Clone)]
5287pub struct ServiceArgs {
5288    /// The installed binary the unit runs.
5289    #[arg(long, value_name = "PATH")]
5290    pub exec: Option<PathBuf>,
5291    /// Where the unit's `gunnar serve` binds SSH.
5292    #[arg(long, default_value = "0.0.0.0:2222", value_name = "ADDR")]
5293    pub ssh_bind: String,
5294    /// Where the unit's `gunnar serve` binds gRPC. Loopback: two listeners, one identity table.
5295    #[arg(long, default_value = "127.0.0.1:50051", value_name = "ADDR")]
5296    pub grpc_bind: String,
5297    /// Install a `--user` unit rather than a system one.
5298    #[arg(long)]
5299    pub user: bool,
5300    /// The system user the unit runs as. System scope only.
5301    #[arg(long, default_value = "gunnar", value_name = "USER")]
5302    pub run_as: String,
5303    /// The data root the unit passes to `gunnar serve`.
5304    #[arg(long, value_name = "DIR")]
5305    pub data_root: Option<PathBuf>,
5306    /// The `RUST_LOG` the unit runs with.
5307    ///
5308    /// Defaults to `gunnar_core::observe::service_log_filter()`, which names
5309    /// every one of gunnar's targets explicitly. **A unit that names no filter
5310    /// is not a unit with no opinion** — the process then falls through to a
5311    /// bare `info`, and which of gunnar's lines that selects is decided
5312    /// somewhere the operator cannot see. See `service::spec`.
5313    #[arg(long, value_name = "FILTER", env = "RUST_LOG")]
5314    pub log_filter: Option<String>,
5315}
5316
5317#[cfg(test)]
5318mod tests {
5319    use super::*;
5320    use clap::CommandFactory;
5321
5322    /// clap's own consistency check. It catches a duplicated short flag, a
5323    /// group naming an argument that does not exist, and every other way a
5324    /// derive can be wrong that would otherwise only show up as a panic the
5325    /// first time a user runs the binary.
5326    #[test]
5327    fn the_command_tree_is_well_formed() {
5328        Cli::command().debug_assert();
5329    }
5330
5331    /// **The metered appliance's blocker, at the argv it actually boots with.**
5332    ///
5333    /// `--monetize-pubkey <64 hex>` must arrive as monetize's 32 verifying-key
5334    /// bytes. Asserting `is_ok()` here would pass on a byte-swapped key and on
5335    /// a key read as a filename, so the assertion is the bytes — written out
5336    /// by hand, not produced by the parser being tested.
5337    /// **A flag that only means something with `--from-server` is refused
5338    /// without it** (wiring tracer B 2026-10-08): `crypt add-member bob --key
5339    /// k --at addr` parsed and ignored `--at`, `--repo` and `-i`, and a
5340    /// `GUNNAR_CONTROL_ADDR` in the environment quietly turned the forge's
5341    /// door into a control-plane call.
5342    #[cfg(feature = "client")]
5343    #[test]
5344    fn crypt_flags_that_need_from_server_are_refused_without_it() {
5345        for args in [
5346            &["gunnar", "crypt", "add-member", "bob", "--key", "k.pub", "--at", "10.0.0.1:50051"][..],
5347            &["gunnar", "crypt", "add-member", "bob", "--key", "k.pub", "--repo", "acme/vault"][..],
5348            &["gunnar", "crypt", "add-member", "bob", "--key", "k.pub", "-i", "id"][..],
5349            &["gunnar", "crypt", "upgrade-pq", "--key", "bob=age1pq1x"][..],
5350        ] {
5351            assert!(Cli::try_parse_from(args).is_err(), "{args:?} parsed with a flag that does nothing");
5352        }
5353        assert!(Cli::try_parse_from(["gunnar", "crypt", "add-member", "bob", "--from-server", "--at", "10.0.0.1:50051", "-i", "id"]).is_ok());
5354        std::env::set_var("GUNNAR_CONTROL_ADDR", "10.0.0.9:50051");
5355        let parsed = Cli::try_parse_from(["gunnar", "crypt", "add-member", "bob", "--from-server"]).expect("parses");
5356        std::env::remove_var("GUNNAR_CONTROL_ADDR");
5357        let text = format!("{parsed:?}");
5358        assert!(!text.contains("10.0.0.9"), "the environment chose the control plane for the forge's door: {text}");
5359    }
5360
5361    #[cfg(feature = "server")]
5362    #[test]
5363    fn serve_takes_the_monetize_key_inline_on_the_command_line() {
5364        const LIVE: &str = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e6";
5365        const LIVE_BYTES: [u8; 32] = [
5366            0x1c, 0x36, 0x63, 0x24, 0x8d, 0x60, 0x90, 0x0e, 0x29, 0x96, 0x38, 0x9f, 0xba, 0x4e,
5367            0xfa, 0x1d, 0x71, 0x79, 0xd6, 0xdb, 0xd0, 0x86, 0x27, 0xd0, 0x42, 0x6f, 0x16, 0xf9,
5368            0x4e, 0x44, 0xc4, 0xe6,
5369        ];
5370        let cli = Cli::try_parse_from(["gunnar", "serve", "--monetize-pubkey", LIVE])
5371            .expect("the live key is accepted on the command line");
5372        let Command::Serve(args) = cli.command else {
5373            panic!("expected serve")
5374        };
5375        let key = args.monetize_pubkey.expect("the flag was given");
5376        assert_eq!(
5377            key.literal(),
5378            Some(LIVE_BYTES),
5379            "an appliance whose only writable channel is the cmdline must get the \
5380             key itself, not a filename: {key:?}"
5381        );
5382    }
5383
5384    /// **The open build is unaffected by `monetize_vital`** (owner ruling 2026-09-22): it has no
5385    /// monetize, so it has no such flag — neither on its argv nor in its `--help` — and nothing
5386    /// in it can raise `entitlement.key_mismatch` or report maintenance for a key.
5387    #[cfg(all(feature = "server", not(feature = "metered")))]
5388    #[test]
5389    fn the_open_build_has_no_monetize_vital() {
5390        assert!(Cli::try_parse_from(["gunnar", "serve", "--monetize-vital", "true"]).is_err());
5391        let help = <Cli as clap::CommandFactory>::command()
5392            .find_subcommand_mut("serve")
5393            .expect("serve")
5394            .render_long_help()
5395            .to_string();
5396        assert!(!help.contains("monetize-vital"), "{help}");
5397    }
5398
5399    /// **The usage share's flags read the medium's tokens**: `admit_limit`
5400    /// lands `korp.env.GUNNAR_ADMIT_LIMIT` (gunnar-recipe `ADMIT_LIMIT_ENV`)
5401    /// and `admit_window_days` `korp.env.GUNNAR_ADMIT_WINDOW_DAYS`; the flag
5402    /// wins over the env, and neither exists without the feature.
5403    #[cfg(all(feature = "server", feature = "admit-share"))]
5404    #[test]
5405    fn serve_reads_the_admit_flags_and_their_env() {
5406        let cli = Cli::try_parse_from(["gunnar", "serve", "--admit-limit", "100GB", "--admit-window-days", "7"])
5407            .expect("parses");
5408        let Command::Serve(args) = cli.command else { panic!("expected serve") };
5409        assert_eq!((args.admit_limit.as_deref(), args.admit_window_days), (Some("100GB"), Some(7)));
5410        let serve = <Cli as clap::CommandFactory>::command();
5411        let serve = serve.find_subcommand("serve").expect("serve");
5412        let env_of = |id: &str| {
5413            serve.get_arguments().find(|a| a.get_id() == id).and_then(|a| a.get_env()).map(|e| e.to_string_lossy().into_owned())
5414        };
5415        assert_eq!(env_of("admit_limit").as_deref(), Some("GUNNAR_ADMIT_LIMIT"));
5416        assert_eq!(env_of("admit_window_days").as_deref(), Some("GUNNAR_ADMIT_WINDOW_DAYS"));
5417    }
5418
5419    #[cfg(all(feature = "server", not(feature = "admit-share")))]
5420    #[test]
5421    fn without_the_share_there_is_no_admit_flag() {
5422        assert!(Cli::try_parse_from(["gunnar", "serve", "--admit-limit", "none"]).is_err());
5423    }
5424
5425    /// The path spelling is unchanged, and the literal arm has not swallowed
5426    /// it: a path that does not exist is still carried as a PATH, to fail when
5427    /// it is read rather than at parse time.
5428    #[cfg(feature = "server")]
5429    #[test]
5430    fn serve_still_takes_the_monetize_key_as_a_path() {
5431        let cli =
5432            Cli::try_parse_from(["gunnar", "serve", "--monetize-pubkey", "/no/such/monetize.pub"])
5433                .expect("a path parses whether or not it exists");
5434        let Command::Serve(args) = cli.command else {
5435            panic!("expected serve")
5436        };
5437        let key = args.monetize_pubkey.expect("the flag was given");
5438        assert_eq!(
5439            key.path(),
5440            Some(std::path::Path::new("/no/such/monetize.pub")),
5441            "the literal arm swallowed the path arm: {key:?}"
5442        );
5443    }
5444
5445    /// A near-miss key is refused BY CLAP, naming what was wrong — not read as
5446    /// a filename, and not truncated to 32 bytes.
5447    #[cfg(feature = "server")]
5448    #[test]
5449    fn a_near_miss_monetize_key_is_refused_on_the_command_line() {
5450        let short = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e";
5451        let err = Cli::try_parse_from(["gunnar", "serve", "--monetize-pubkey", short])
5452            .expect_err("a 63-character key must be refused")
5453            .to_string();
5454        assert!(
5455            err.contains("63") && err.contains("64"),
5456            "the refusal must name the length that was wrong: {err}"
5457        );
5458    }
5459
5460    #[cfg(feature = "server")]
5461    #[test]
5462    fn serve_defaults_grpc_to_loopback() {
5463        let cli = Cli::try_parse_from(["gunnar", "serve"]).expect("serve parses with no arguments");
5464        let Command::Serve(args) = cli.command else {
5465            panic!("expected serve")
5466        };
5467        assert_eq!(
5468            args.grpc_bind.to_string(),
5469            "127.0.0.1:50051",
5470            "the gRPC control plane's default bind must be \
5471             loopback. Anything else makes an open admin port the default."
5472        );
5473        assert!(
5474            args.grpc_bind.is_loopback_addr(),
5475            "the default gRPC bind must be a loopback address"
5476        );
5477        assert!(
5478            !args.ssh_bind.ip().is_loopback(),
5479            "SSH authenticates every connection by public key, so its default is public; a \
5480             loopback default here would mean this test is asserting the wrong field"
5481        );
5482    }
5483
5484    /// Every spelling `--prune-grace` accepts, and the ones it must refuse.
5485    ///
5486    /// The refusals are the half that matters: a parser that fell back to zero
5487    /// on anything it did not understand would turn `--prune-grace 2.weeks.ago`
5488    /// — the spelling a git operator will type first — into `--prune=now`
5489    /// against a live server. So `2.weeks.ago` is asserted to be an **error**,
5490    /// not silently anything.
5491    #[cfg(feature = "server")]
5492    #[test]
5493    fn prune_grace_parses_what_it_claims_and_refuses_the_rest() {
5494        use std::time::Duration;
5495        for (raw, want) in [
5496            ("now", Duration::ZERO),
5497            ("NOW", Duration::ZERO),
5498            ("0", Duration::ZERO),
5499            ("90", Duration::from_secs(90)),
5500            ("45s", Duration::from_secs(45)),
5501            ("5m", Duration::from_secs(300)),
5502            ("2h", Duration::from_secs(7200)),
5503            ("3d", Duration::from_secs(259_200)),
5504            ("2w", Duration::from_secs(1_209_600)),
5505        ] {
5506            assert_eq!(
5507                parse_prune_grace(raw),
5508                Ok(want),
5509                "--prune-grace {raw} did not parse to {want:?}"
5510            );
5511        }
5512        for raw in ["2.weeks.ago", "yesterday", "", "w", "-1", "2 weeks", "2y"] {
5513            assert!(
5514                parse_prune_grace(raw).is_err(),
5515                "--prune-grace {raw:?} was accepted; a grace this parser does not understand \
5516                 must be refused, never defaulted"
5517            );
5518        }
5519        // And the flag reaches the struct, so a parser that worked in isolation
5520        // and was not wired would still fail here.
5521        let cli = Cli::try_parse_from(["gunnar", "gc", "--prune", "--prune-grace", "now"])
5522            .expect("gc --prune-grace now parses");
5523        let Command::Gc(args) = cli.command else {
5524            panic!("expected gc")
5525        };
5526        assert_eq!(args.prune_grace, Duration::ZERO);
5527        assert_ne!(args.prune_grace, GcArgs::default().prune_grace);
5528    }
5529
5530    #[cfg(feature = "server")]
5531    #[test]
5532    fn grant_requires_exactly_one_scope() {
5533        Cli::try_parse_from(["gunnar", "grant", "alice", "write"])
5534            .expect_err("a grant with no scope must be refused");
5535        Cli::try_parse_from([
5536            "gunnar",
5537            "grant",
5538            "alice",
5539            "write",
5540            "--server",
5541            "--namespace",
5542            "team",
5543        ])
5544        .expect_err("a grant with two scopes must be refused");
5545        let cli = Cli::try_parse_from(["gunnar", "grant", "alice", "write", "--repo", "team/x"])
5546            .expect("one scope parses");
5547        let Command::Grant(args) = cli.command else {
5548            panic!("expected grant")
5549        };
5550        assert_eq!(args.repo.as_deref(), Some("team/x"));
5551        assert_eq!(args.level, LevelArg::Write);
5552    }
5553
5554    #[cfg(feature = "server")]
5555    #[test]
5556    fn repo_create_defaults_to_sha256() {
5557        let cli = Cli::try_parse_from(["gunnar", "repo", "create", "team/x"]).expect("parses");
5558        let Command::Repo(RepoCommand::Create { hash, .. }) = cli.command else {
5559            panic!("expected repo create")
5560        };
5561        assert_eq!(
5562            hash,
5563            HashArg::Sha256,
5564            "sha256 is the default hash kind"
5565        );
5566    }
5567
5568    /// The appliance build must not offer a verb it cannot perform, and the
5569    /// default build must offer both. Asserted in both directions so the test
5570    /// cannot pass vacuously on a binary that has no subcommands at all.
5571    #[test]
5572    fn the_feature_split_reaches_the_command_line() {
5573        let has = |name: &str| {
5574            Cli::command()
5575                .get_subcommands()
5576                .any(|sub| sub.get_name() == name)
5577        };
5578        assert_eq!(
5579            has("serve"),
5580            cfg!(feature = "server"),
5581            "`serve` exists exactly when the server feature is on"
5582        );
5583        assert_eq!(
5584            has("clone"),
5585            cfg!(feature = "client"),
5586            "`clone` exists exactly when the client feature is on"
5587        );
5588        assert_eq!(
5589            has("service"),
5590            cfg!(feature = "systemd"),
5591            "`service` exists exactly when the systemd feature is on"
5592        );
5593    }
5594
5595    /// ★ **The verb that reads the key a tenant's own renewal depends on.**
5596    ///
5597    /// `Billing.ApplianceIdentity` was served from the day the tenant path
5598    /// shipped, with a proto comment saying it exists "so an operator can
5599    /// configure their monetize with it" — and nothing but a test ever called
5600    /// it, because no verb existed. MEASURED on gunnar.rs 2026-09-19: every
5601    /// self-service renewal answered 501, monetize's `[products.gunnar]`
5602    /// carried no `appliance_key`, and there was no way to obtain one. An RPC
5603    /// nothing can call is a feature nobody has.
5604    ///
5605    /// Metered only, like the service it calls: `billing.proto` is not in
5606    /// codegen without the feature, so on a community build this verb must not
5607    /// exist at all rather than fail at the far end.
5608    #[cfg(all(feature = "metered", feature = "control"))]
5609    #[test]
5610    fn appliance_identity_parses_and_takes_an_address_and_an_identity() {
5611        let cli = Cli::try_parse_from([
5612            "gunnar",
5613            "control",
5614            "appliance-identity",
5615            "--at",
5616            "10.13.8.101:50051",
5617            "-i",
5618            "/var/lib/monetize/gunnar-identity",
5619        ])
5620        .expect("the verb parses");
5621        match cli.command {
5622            Command::Control(ControlCommand::ApplianceIdentity { at, identity }) => {
5623                assert_eq!(at, "10.13.8.101:50051");
5624                assert_eq!(identity.as_deref(), Some(std::path::Path::new("/var/lib/monetize/gunnar-identity")));
5625            }
5626            other => panic!("parsed as something else: {other:?}"),
5627        }
5628    }
5629
5630    /// And it defaults its address like every other control verb, so the
5631    /// common case is two words.
5632    #[cfg(all(feature = "metered", feature = "control"))]
5633    #[test]
5634    fn appliance_identity_defaults_its_address() {
5635        let cli = Cli::try_parse_from(["gunnar", "control", "appliance-identity"])
5636            .expect("the verb parses bare");
5637        match cli.command {
5638            Command::Control(ControlCommand::ApplianceIdentity { at, .. }) => {
5639                assert_eq!(at, crate::DEFAULT_CONTROL_ADDR);
5640            }
5641            other => panic!("parsed as something else: {other:?}"),
5642        }
5643    }
5644
5645    /// `gunnar control entitlement set|get|list` parse, and `set` takes a PATH
5646    /// to a fact monetize signed — never a namespace and a state, because this
5647    /// verb signs nothing and a verb that took a state would be one that could.
5648    #[cfg(all(feature = "metered", feature = "control"))]
5649    #[test]
5650    fn entitlement_verbs_parse_and_set_takes_only_a_signed_fact() {
5651        let cli = Cli::try_parse_from(["gunnar", "control", "entitlement", "set", "/tmp/fact.json", "--at", "10.0.0.1:50051"])
5652            .expect("set parses");
5653        match cli.command {
5654            Command::Control(ControlCommand::Entitlement(ControlEntitlementCommand::Set { fact, at, .. })) => {
5655                assert_eq!(fact, std::path::PathBuf::from("/tmp/fact.json"));
5656                assert_eq!(at, "10.0.0.1:50051");
5657            }
5658            other => panic!("parsed as something else: {other:?}"),
5659        }
5660        assert!(
5661            Cli::try_parse_from(["gunnar", "control", "entitlement", "set", "team", "paid"]).is_err(),
5662            "`set` must not take a namespace and a state"
5663        );
5664        let cli = Cli::try_parse_from(["gunnar", "control", "entitlement", "get", "team"]).expect("get parses");
5665        match cli.command {
5666            Command::Control(ControlCommand::Entitlement(ControlEntitlementCommand::Get { namespace, at, .. })) => {
5667                assert_eq!(namespace, "team");
5668                assert_eq!(at, crate::DEFAULT_CONTROL_ADDR);
5669            }
5670            other => panic!("parsed as something else: {other:?}"),
5671        }
5672        Cli::try_parse_from(["gunnar", "control", "entitlement", "list"]).expect("list parses");
5673    }
5674
5675    /// ★ **The ONLINE revoke exists, and it takes a bare fingerprint.**
5676    ///
5677    /// MEASURED on the live appliance 2026-09-19: `gunnar control key revoke
5678    /// <fp>` answered *"unrecognized subcommand 'key'"*, because the only
5679    /// `Revoke` in the CLI was `gunnar key revoke` — a `server`-feature verb
5680    /// that writes the control repository directly and therefore needs the
5681    /// server DOWN. An appliance is PID 1 with no shell, so the machine that
5682    /// most needs the verb is the one that can never run it, and rotating one
5683    /// dead machine key took six steps and four refusals instead.
5684    ///
5685    /// The shape is the transcript's: the fingerprint is positional, as the
5686    /// operator pastes it out of `user keys`, and nothing else is required.
5687    #[cfg(feature = "control")]
5688    #[test]
5689    fn user_revoke_key_parses_a_bare_fingerprint_and_defaults_its_address() {
5690        let cli = Cli::try_parse_from([
5691            "gunnar",
5692            "control",
5693            "user",
5694            "revoke-key",
5695            "SHA256:Bw3OsBmcabcdefghijklmnopqrstuvwxyz0123456789",
5696        ])
5697        .expect("the online revoke parses");
5698        match cli.command {
5699            Command::Control(ControlCommand::User(ControlUserCommand::RevokeKey {
5700                fingerprint,
5701                force,
5702                at,
5703                identity,
5704            })) => {
5705                assert_eq!(
5706                    fingerprint,
5707                    "SHA256:Bw3OsBmcabcdefghijklmnopqrstuvwxyz0123456789"
5708                );
5709                assert!(!force, "the lockout guard is on unless --force says otherwise");
5710                assert_eq!(at, crate::DEFAULT_CONTROL_ADDR);
5711                assert!(identity.is_none());
5712            }
5713            other => panic!("parsed as something else: {other:?}"),
5714        }
5715    }
5716
5717    /// And `--force` is spelled, because the guard it turns off is the only
5718    /// thing standing between a slip and a box that must be re-imaged.
5719    #[cfg(feature = "control")]
5720    #[test]
5721    fn user_revoke_key_takes_force_to_get_past_the_last_admin_guard() {
5722        let cli = Cli::try_parse_from([
5723            "gunnar",
5724            "control",
5725            "user",
5726            "revoke-key",
5727            "SHA256:zzz",
5728            "--force",
5729            "-i",
5730            "/root/.ssh/id_ed25519",
5731            "--at",
5732            "10.13.8.101:50051",
5733        ])
5734        .expect("the verb parses with --force");
5735        match cli.command {
5736            Command::Control(ControlCommand::User(ControlUserCommand::RevokeKey {
5737                force,
5738                at,
5739                identity,
5740                ..
5741            })) => {
5742                assert!(force);
5743                assert_eq!(at, "10.13.8.101:50051");
5744                assert_eq!(
5745                    identity.as_deref(),
5746                    Some(std::path::Path::new("/root/.ssh/id_ed25519"))
5747                );
5748            }
5749            other => panic!("parsed as something else: {other:?}"),
5750        }
5751    }
5752}
5753
5754/// `gunnar control account …`: the gate and the hard purge.
5755///
5756/// Gated on `control` like every other `gunnar control` enum, and it has to be:
5757/// each variant's `--at` carries [`CONTROL_ADDR_HELP`] as its `long_help`, and
5758/// that const is behind the same feature. A build without `control` has no
5759/// control plane to send these to, so an ungated enum would be four subcommands
5760/// that cannot exist referring to a string that does not.
5761#[cfg(feature = "control")]
5762#[derive(Debug, Subcommand)]
5763pub enum ControlAccountCommand {
5764    /// **Lock a user out. Completely.**
5765    ///
5766    /// Not read-only. Every level, both doors: the SSH listener and the control
5767    /// plane ask the same table, so a gated user can neither push nor fetch nor
5768    /// read a repository they own — and nobody else can reach into the account
5769    /// either, including a stranger cloning a public repository out of it.
5770    ///
5771    /// **Reads are not safe to allow**, which is why there is no `--read-only`.
5772    /// A fetch opens the store and lazily explodes objects into the read cache,
5773    /// the first read of a pack with no index builds one and writes it, and a
5774    /// log walk caches: a "harmless" reader puts bytes back into an account
5775    /// something else is emptying.
5776    ///
5777    /// Standalone and idempotent. It is not part of `purge` — a lapsed account
5778    /// is locked out without deleting anything, and an operator freezes an
5779    /// account under investigation the same way.
5780    Gate {
5781        /// The account: a user's name, which is also their namespace.
5782        account: String,
5783        /// **Why.** Required: it is what the locked-out user is shown in every
5784        /// refusal from that moment on, and a lockout with no reason is a
5785        /// support ticket.
5786        #[arg(long, value_name = "TEXT")]
5787        note: String,
5788        #[arg(long, short = 'i', value_name = "PATH")]
5789        identity: Option<PathBuf>,
5790        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5791        at: String,
5792    },
5793
5794    /// **Let them back in.**
5795    ///
5796    /// Unconditional: it does not care who took the lockout, so a lease left
5797    /// behind by a purge that died can always be cleared by hand. That is the
5798    /// escape hatch, and it is why an account can never be permanently bricked.
5799    ///
5800    /// Ungating an account that is not locked is a success and not an error.
5801    Ungate {
5802        /// The account.
5803        account: String,
5804        #[arg(long, short = 'i', value_name = "PATH")]
5805        identity: Option<PathBuf>,
5806        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5807        at: String,
5808    },
5809
5810    /// **Who is locked out**, and by what.
5811    ///
5812    /// Purge leases that have expired are not listed: a lease that ran out is
5813    /// not a lockout in force, and showing one would say an account is shut
5814    /// when it is open.
5815    Gates {
5816        #[arg(long, short = 'i', value_name = "PATH")]
5817        identity: Option<PathBuf>,
5818        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5819        at: String,
5820    },
5821
5822    /// **Empty an account: everything the user owns, gone. NO undo.**
5823    ///
5824    /// Every repository, every soft-delete tombstone still holding bytes, every
5825    /// orphan store with no catalog row, the namespace rows, and — with
5826    /// `--remove-principal` — the user's keys, grants, tokens and group
5827    /// memberships. Nested namespaces under the account's name go with it: they
5828    /// live in the same directory subtree, and leaving them would leave bytes
5829    /// on the disk and in the meters after you were told the account was
5830    /// emptied.
5831    ///
5832    /// **The user is locked out before this command returns**, and let back in
5833    /// when it is over — including when it FAILS. The walk itself is queued
5834    /// (one account at a time for the whole box) and this waits for it, unless
5835    /// `--no-wait`.
5836    ///
5837    /// `repo delete` without `--purge` leaves a dated tombstone precisely so a
5838    /// fat finger is recoverable. This is the verb that says the account is
5839    /// finished: it reclaims tombstones rather than making them, so `--yes` is
5840    /// required.
5841    Purge {
5842        /// The account.
5843        account: String,
5844        /// **Why.** Required, like `gate --note`.
5845        #[arg(long, value_name = "TEXT")]
5846        reason: String,
5847        /// Also remove the user: keys, grants, tokens, memberships, and the
5848        /// account's own lockout row.
5849        ///
5850        /// Without it the account shell survives, still locked, and `ungate`
5851        /// hands the user an empty account back. With it the name is free for
5852        /// somebody else.
5853        #[arg(long)]
5854        remove_principal: bool,
5855        /// **Refuse rather than destroy** if the account still holds a live
5856        /// repository. Names what is in the way and touches nothing.
5857        ///
5858        /// For a caller that believes it has already emptied the account by
5859        /// other means and wants the server to check rather than to act.
5860        #[arg(long)]
5861        expect_empty: bool,
5862        /// Say it. There is no retention window and no undelete after this.
5863        #[arg(long)]
5864        yes: bool,
5865        /// Enqueue and return, printing the job instead of waiting for it.
5866        #[arg(long)]
5867        no_wait: bool,
5868        #[arg(long, short = 'i', value_name = "PATH")]
5869        identity: Option<PathBuf>,
5870        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5871        at: String,
5872    },
5873
5874    /// **Where an account's purge got to, and whether the lockout is off.**
5875    ///
5876    /// Both, because neither answers the question on its own: a finished purge
5877    /// beside a lockout still in force means the account is empty and still
5878    /// shut, which is either a suspension the purge correctly refused to lift
5879    /// or a lease nothing released — and those need different actions.
5880    PurgeStatus {
5881        /// The account.
5882        account: String,
5883        #[arg(long, short = 'i', value_name = "PATH")]
5884        identity: Option<PathBuf>,
5885        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5886        at: String,
5887    },
5888    /// **Claim the estate's operator, and record the break-glass token as his
5889    /// first invite** — the installer's word, for a box no installer fed (a
5890    /// standalone gunnar, a test rig). Server admin only.
5891    ///
5892    /// gunnar names the operator (an installer-fed `operator_name` wins over
5893    /// NAME), makes him a person and gunnar-admin with his account namespace
5894    /// (an ownerless namespace of that name is adopted, never a refusal), and
5895    /// keeps the SHA-256 of `--bootstrap-file`'s contents as the invite for his
5896    /// FIRST passkey: the console registers it with `Access.WebRegister`, the
5897    /// token as the invite secret. The token is read here and hashed here; only
5898    /// its hash leaves this machine. Once he holds a passkey the bootstrap is
5899    /// spent and nothing is recorded.
5900    ClaimOperator {
5901        /// The operator's name — his account, and the namespace named after it.
5902        name: String,
5903        /// A file holding the break-glass token (one line). Absent: the
5904        /// operator is claimed and no invite is recorded.
5905        #[arg(long, value_name = "PATH")]
5906        bootstrap_file: Option<PathBuf>,
5907        #[arg(long, short = 'i', value_name = "PATH")]
5908        identity: Option<PathBuf>,
5909        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5910        at: String,
5911    },
5912    /// **Enroll an employee: a NEW user, created by and bound to the
5913    /// account.** An account with employees is an enterprise account; that is
5914    /// not a tier and not a setting. An existing user can never become an employee. The
5915    /// employee reads the account; only the account admin makes anything
5916    /// public. Account admin only.
5917    ///
5918    /// **"Add users"** is this verb, as the console's dialog asks it: each user
5919    /// holds `--room-gib` of the account's room (default 10, raised never
5920    /// lowered), out of what the account holds and has neither stored nor
5921    /// promised; `--count` above 1 adds that many SEATS with no names, and
5922    /// `--from-seat` fills one with the named user (what an invite does).
5923    #[command(visible_alias = "add-users")]
5924    Enroll {
5925        /// The account (its namespace).
5926        account: String,
5927        /// The new user's id. Absent for seats (`--count` above 1).
5928        id: Option<String>,
5929        /// Display name; the id when absent.
5930        #[arg(long)]
5931        name: Option<String>,
5932        /// How many users: 1 names one (the id), more adds that many seats
5933        /// with no names. At most 10000.
5934        #[arg(long, default_value_t = 1)]
5935        count: u32,
5936        /// Room per user, GiB, out of the account's room. Default 10; less is
5937        /// refused.
5938        #[arg(long, value_name = "GIB")]
5939        room_gib: Option<u64>,
5940        /// Fill one of the account's open seats with this new user; the seat
5941        /// decides the room.
5942        #[arg(long)]
5943        from_seat: bool,
5944        /// Ask whether it fits and how much room is missing; change nothing.
5945        #[arg(long)]
5946        dry_run: bool,
5947        /// Also make the new named user's invite (as the console does) and
5948        /// print it once: its id and the secret a `/login?invite=` link carries.
5949        #[arg(long)]
5950        invite: bool,
5951        /// An OpenSSH public key file to register on the new user at once.
5952        #[arg(long, value_name = "PATH")]
5953        key: Option<PathBuf>,
5954        #[arg(long, short = 'i', value_name = "PATH")]
5955        identity: Option<PathBuf>,
5956        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5957        at: String,
5958    },
5959    /// The account's employees, and its seats (all, open). Account admin only.
5960    Employees {
5961        /// The account (its namespace).
5962        account: String,
5963        #[arg(long, short = 'i', value_name = "PATH")]
5964        identity: Option<PathBuf>,
5965        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5966        at: String,
5967    },
5968}
5969
5970/// `gunnar control clan …` — clans (2026-10-05): every verb is the caller's
5971/// own, signed with its key; gunnar decides from the clan. Sizes are typed as
5972/// `10GiB`, `512MiB` or plain bytes.
5973#[cfg(feature = "control")]
5974#[derive(Debug, Subcommand)]
5975pub enum ControlClanCommand {
5976    /// Make a clan: NAME becomes its namespace (private); you are its admin.
5977    Create {
5978        name: String,
5979        /// What you give it out of your own room (e.g. `5GiB`).
5980        #[arg(long, default_value = "0")]
5981        give: String,
5982        #[arg(long, short = 'i', value_name = "PATH")]
5983        identity: Option<PathBuf>,
5984        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5985        at: String,
5986    },
5987    /// Your clans and invitations; with CLAN, that one in full.
5988    Show {
5989        clan: Option<String>,
5990        #[arg(long, short = 'i', value_name = "PATH")]
5991        identity: Option<PathBuf>,
5992        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5993        at: String,
5994    },
5995    /// Invite an ordinary account into CLAN (its admin's).
5996    Invite {
5997        clan: String,
5998        account: String,
5999        #[arg(long, short = 'i', value_name = "PATH")]
6000        identity: Option<PathBuf>,
6001        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6002        at: String,
6003    },
6004    /// Join CLAN, which invited you, giving `--give` of your own room.
6005    Accept {
6006        clan: String,
6007        #[arg(long, default_value = "0")]
6008        give: String,
6009        #[arg(long, short = 'i', value_name = "PATH")]
6010        identity: Option<PathBuf>,
6011        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6012        at: String,
6013    },
6014    /// Decline your invitation to CLAN — or, as its admin, withdraw
6015    /// `--account`'s.
6016    Decline {
6017        clan: String,
6018        #[arg(long)]
6019        account: Option<String>,
6020        #[arg(long, short = 'i', value_name = "PATH")]
6021        identity: Option<PathBuf>,
6022        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6023        at: String,
6024    },
6025    /// Set what you give CLAN (up out of your own room; down no lower than
6026    /// what the clan stores).
6027    Give {
6028        clan: String,
6029        size: String,
6030        #[arg(long, short = 'i', value_name = "PATH")]
6031        identity: Option<PathBuf>,
6032        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6033        at: String,
6034    },
6035    /// Leave CLAN; what you gave comes back (refused while it is in use, and
6036    /// to its last admin).
6037    Leave {
6038        clan: String,
6039        #[arg(long, short = 'i', value_name = "PATH")]
6040        identity: Option<PathBuf>,
6041        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6042        at: String,
6043    },
6044    /// Remove a member from CLAN (its admin's).
6045    Remove {
6046        clan: String,
6047        account: String,
6048        #[arg(long, short = 'i', value_name = "PATH")]
6049        identity: Option<PathBuf>,
6050        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6051        at: String,
6052    },
6053    /// Make a member of CLAN admin (`--no` to make it a plain member).
6054    Admin {
6055        clan: String,
6056        account: String,
6057        #[arg(long)]
6058        no: bool,
6059        #[arg(long, short = 'i', value_name = "PATH")]
6060        identity: Option<PathBuf>,
6061        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6062        at: String,
6063    },
6064    /// Dissolve an EMPTY clan (its admin's): every contribution goes back.
6065    Dissolve {
6066        clan: String,
6067        #[arg(long, short = 'i', value_name = "PATH")]
6068        identity: Option<PathBuf>,
6069        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6070        at: String,
6071    },
6072    /// Offer CLAN/REPO to ACCOUNT, to move it out of the clan (its admin's);
6073    /// the account's admin takes or declines it.
6074    Offer {
6075        clan: String,
6076        repo: String,
6077        account: String,
6078        #[arg(long, short = 'i', value_name = "PATH")]
6079        identity: Option<PathBuf>,
6080        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6081        at: String,
6082    },
6083    /// Take CLAN/REPO, offered to your account: it moves to <account>/<repo>.
6084    /// `--no` declines it — or, as the clan's admin, withdraws the offer.
6085    Take {
6086        clan: String,
6087        repo: String,
6088        #[arg(long)]
6089        no: bool,
6090        #[arg(long, short = 'i', value_name = "PATH")]
6091        identity: Option<PathBuf>,
6092        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6093        at: String,
6094    },
6095}
6096
6097/// `gunnar control mirror …` — **the pull mirror, told to the APPLIANCE.**
6098///
6099/// # Why there are two `mirror` verbs and this is the one with `control` in it
6100///
6101/// `gunnar mirror add/sync/status` (no `control`) already exists and stays. It
6102/// mirrors into a LOCAL data root on the machine you typed it on, over gix
6103/// porcelain, and it is a git client's verb: my laptop keeps a copy. These are
6104/// the opposite job — *tell the server over there to keep the copy* — and the
6105/// `control` prefix is that line, the same line every other `gunnar control`
6106/// verb draws. Confusing the two is not a cosmetic mistake: one of them writes
6107/// to a disk you can `ls`, and the other writes to a box that has no shell.
6108///
6109/// # The one rule that shapes every verb below: `Sync` ENQUEUES
6110///
6111/// A fetch is minutes of somebody else's network, so `Mirror.Sync` puts a job on
6112/// the server's single runner and answers immediately — with `refs_changed = 0`
6113/// and no updates, **deliberately**, because at enqueue nothing has happened
6114/// yet. Reading that as "0 refs changed" is reading "I have not started" as "I
6115/// found nothing", and those are the same sentence about two different worlds.
6116/// So the answer to *what did the mirror do* is always `status`, never the echo
6117/// `sync` returns, and `sync --wait` is `status` in a loop with a human at the
6118/// other end.
6119///
6120/// # `has_progress` is read BEFORE the pair, everywhere
6121///
6122/// `(progress_done, progress_total) = (0, 0)` with `has_progress = false` means
6123/// the server has no denominator yet — the upstream has not said how many
6124/// objects there are. It is not zero percent, and a percentage bar drawn from it
6125/// sits at 0% for a minute and then jumps, which reads as a hang. Every renderer
6126/// here prints `counting…` for that case and `n/N` only when the server says the
6127/// pair is real.
6128/// `gunnar control entitlement …`. See [`ControlCommand::Entitlement`].
6129#[cfg(all(feature = "control", feature = "metered"))]
6130#[derive(Debug, Subcommand)]
6131pub enum ControlEntitlementCommand {
6132    /// **Hand the box ONE fact monetize signed.** Admin at server scope.
6133    ///
6134    /// The file is monetize's own `EntitlementFact` as JSON — the value
6135    /// `monetize_embed::signing::fact_message` canonicalises and signs, with its
6136    /// signatures — so what is pushed is byte for byte what monetize minted. This
6137    /// verb SIGNS NOTHING and holds no monetize key: the box verifies the fact
6138    /// against its own `--monetize-pubkey`, and a fact that does not verify is
6139    /// refused by name and nothing moves.
6140    ///
6141    /// **Operator-only** (monetize v2.0 P2 T13, A28 L13): monetize no longer
6142    /// calls this door. The ONE box pulls every Gunnar estate, so a paid plan
6143    /// reaches the box as a signed order its main pulls. This verb is the
6144    /// operator's hand tool for one fact, under the server's admin key.
6145    Set {
6146        /// Path to the signed fact (JSON), or `-` for stdin.
6147        #[arg(value_name = "FACT.json")]
6148        fact: PathBuf,
6149        #[arg(long, short = 'i', value_name = "PATH")]
6150        identity: Option<PathBuf>,
6151        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6152        at: String,
6153    },
6154    /// The fact this box ENFORCES for one namespace, or `free` when it enforces
6155    /// none. Read on the namespace (a tenant may ask about their own).
6156    Get {
6157        #[arg(value_name = "NAMESPACE")]
6158        namespace: String,
6159        #[arg(long, short = 'i', value_name = "PATH")]
6160        identity: Option<PathBuf>,
6161        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6162        at: String,
6163    },
6164    /// Every fact this box ENFORCES, one line per namespace. A namespace absent
6165    /// here is Free — including one whose stored fact was refused at boot.
6166    List {
6167        #[arg(long, short = 'i', value_name = "PATH")]
6168        identity: Option<PathBuf>,
6169        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6170        at: String,
6171    },
6172}
6173
6174#[cfg(feature = "control")]
6175#[derive(Debug, Subcommand)]
6176pub enum ControlMirrorCommand {
6177    /// **Point a repository at a remote.** A document write; it fetches nothing.
6178    ///
6179    /// `add` then `sync` is deliberate and not a missing convenience: registering
6180    /// where a repository's history comes from and pulling it are different
6181    /// decisions with different failure modes, and an `add` that fetched would
6182    /// make a typo in a URL into a minutes-long timeout instead of a stored
6183    /// string you can look at.
6184    ///
6185    /// Needs **Admin** on the repository — or on the namespace with `--create`,
6186    /// because there is nothing narrower than a namespace to hold the right to
6187    /// make a repository that does not exist yet.
6188    Add {
6189        /// `<namespace>/<repo>` on THIS server: where the mirror lands.
6190        #[arg(value_name = "NS/REPO")]
6191        path: String,
6192        /// The upstream. `ssh://git@host/ns/repo.git`, the scp-like
6193        /// `git@host:ns/repo.git`, or `https://host/ns/repo.git`. The URL — and
6194        /// nothing else — decides which transport is dialled and therefore which
6195        /// kind of credential is legal; see `credential`.
6196        #[arg(value_name = "URL")]
6197        url: String,
6198        /// Delete local refs the upstream has deleted. Off by default: a mirror
6199        /// that prunes is a mirror that can lose a branch because somebody else
6200        /// tidied theirs.
6201        #[arg(long)]
6202        prune: bool,
6203        /// Create the repository if it is not there.
6204        ///
6205        /// The hash kind is **not** a flag here and must not become one: the
6206        /// server takes it from the remote — the wire negotiates it — because a mirror of a sha256
6207        /// repository into a sha1 one is not a mirror, and a caller who guessed
6208        /// would find that out after the first fetch.
6209        #[arg(long)]
6210        create: bool,
6211        /// With `--create`: take over a repository of that name that exists
6212        /// and is NOT a mirror — it becomes a mirror, and its next sync moves
6213        /// its refs to the upstream's. Without it gunnar refuses the take-over.
6214        #[arg(long, requires = "create")]
6215        adopt: bool,
6216        #[arg(long, short = 'i', value_name = "PATH")]
6217        identity: Option<PathBuf>,
6218        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6219        at: String,
6220    },
6221
6222    /// **Fetch from the upstream now** — enqueued on the server's one runner.
6223    ///
6224    /// Returns as soon as the job is on the queue. `--wait` is the shape a human
6225    /// wants and `--wait`'s absence is the shape a script wants, which is why
6226    /// the default here is the opposite of `repo gc`'s: a gc is seconds and a
6227    /// mirror of a real repository is minutes to hours, so waiting is the thing
6228    /// you ask for rather than the thing you opt out of.
6229    ///
6230    /// Asking twice while a job is live is ONE job, not two. Needs **Write**.
6231    Sync {
6232        /// `<namespace>/<repo>`.
6233        #[arg(value_name = "NS/REPO")]
6234        path: String,
6235        /// Poll until the job is DONE or FAILED, printing each state as it
6236        /// moves, and exit non-zero if it failed.
6237        #[arg(long)]
6238        wait: bool,
6239        /// Give up waiting after this many seconds and say where to look. The
6240        /// job is NOT cancelled — it is the server's, and it keeps running.
6241        ///
6242        /// A bound rather than "forever" because `--wait` is what a person leaves
6243        /// running in a terminal, and a wait with no end is indistinguishable
6244        /// from a hang at exactly the moment you most want to know which it is.
6245        #[arg(long, default_value_t = 3600, value_name = "SECS")]
6246        timeout: u64,
6247        #[arg(long, short = 'i', value_name = "PATH")]
6248        identity: Option<PathBuf>,
6249        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6250        at: String,
6251    },
6252
6253    /// **What is registered, what it dials with, and where its job got to.**
6254    ///
6255    /// The applied output, as against the request echo `add` and `sync` return.
6256    /// The ref updates are printed only on a DONE job, because a mirror's whole
6257    /// ref update set lands in one compare-and-swap or none of it does — a list of
6258    /// updates from a running job is a list of refs that have not moved.
6259    ///
6260    /// The credential's KIND and username are shown. The value is not, and there
6261    /// is no flag that shows it: no RPC in this service returns it.
6262    Status {
6263        /// One repository, `<namespace>/<repo>`. Omit for every mirror you can
6264        /// read.
6265        #[arg(value_name = "NS/REPO")]
6266        path: Option<String>,
6267        /// Only this namespace's mirrors. Ignored when a path is given.
6268        #[arg(long, value_name = "NAME")]
6269        namespace: Option<String>,
6270        #[arg(long, short = 'i', value_name = "PATH")]
6271        identity: Option<PathBuf>,
6272        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6273        at: String,
6274    },
6275
6276    /// **Stop mirroring.** The repository and everything already fetched stay;
6277    /// only the upstream link and its credential go.
6278    Remove {
6279        /// `<namespace>/<repo>`.
6280        #[arg(value_name = "NS/REPO")]
6281        path: String,
6282        #[arg(long, short = 'i', value_name = "PATH")]
6283        identity: Option<PathBuf>,
6284        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6285        at: String,
6286    },
6287
6288    /// **Store what this mirror authenticates to its upstream with.**
6289    ///
6290    /// # The secret is read from a FILE or from stdin, never from an argument
6291    ///
6292    /// There is no `--secret <VALUE>` and there will not be one. An argument is
6293    /// in `~/.bash_history`, in `ps aux` for every user on the box while the
6294    /// command runs, and in the shell's job table — three copies of a long-lived
6295    /// bearer credential made by a convenience nobody needed. `--ssh-key` and
6296    /// `--token-file` name a path (a leading `@` is accepted, and `-` means
6297    /// stdin), which is where a key already lives.
6298    ///
6299    /// # The kind must match the transport, and the server refuses by name
6300    ///
6301    /// An `ssh://` remote takes `--ssh-key`; an `https://` remote takes
6302    /// `--token-file`. The mismatch is refused BEFORE anything is dialled,
6303    /// because it is otherwise the quietest failure in the whole feature: an ssh
6304    /// key has nowhere to go in an HTTP request, so the fetch goes out
6305    /// anonymous, the upstream answers 401, and an operator who can see that a
6306    /// credential IS stored rotates the key. For ever.
6307    ///
6308    /// A public `https://` upstream needs no credential at all, and `--forget`
6309    /// is how a stored one is taken back.
6310    Credential {
6311        /// `<namespace>/<repo>`.
6312        #[arg(value_name = "NS/REPO")]
6313        path: String,
6314        /// An OpenSSH **private** key file, for an `ssh://` upstream. `@<path>`
6315        /// and `-` (stdin) are accepted.
6316        #[arg(long, value_name = "PATH", conflicts_with_all = ["token_file", "forget"])]
6317        ssh_key: Option<String>,
6318        /// A file holding a personal access token, for an `https://` upstream.
6319        /// `@<path>` and `-` (stdin) are accepted. Trailing newlines are
6320        /// stripped — a token file written by a human has one, and a token sent
6321        /// with it is a token the forge rejects.
6322        #[arg(long, value_name = "PATH", conflicts_with = "forget")]
6323        token_file: Option<String>,
6324        /// The username the token is offered with (HTTP Basic). GitHub, Codeberg
6325        /// and GitLab personal access tokens all want one; with none the token
6326        /// is sent as `Authorization: Bearer`.
6327        #[arg(long, value_name = "NAME")]
6328        username: Option<String>,
6329        /// Forget the stored credential. The mirror stays; it dials anonymously
6330        /// from now on, which is right for a public upstream and a 401 for any
6331        /// other.
6332        #[arg(long)]
6333        forget: bool,
6334        #[arg(long, short = 'i', value_name = "PATH")]
6335        identity: Option<PathBuf>,
6336        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6337        at: String,
6338    },
6339
6340    /// **Take a whole forge account off GitHub, Codeberg, GitLab or a self-hosted
6341    /// Forgejo — list it, choose, mirror it.** The verb that makes gunnar
6342    /// adoptable without a migration project.
6343    ///
6344    /// # Why this needs a REST call at all, in a git server
6345    ///
6346    /// git has no verb for "enumerate an account". The protocol starts from a
6347    /// URL you already know; `git ls-remote github.com` is not a shorter version
6348    /// of this, it is a question with no answer. Every forge exposes the list
6349    /// over its own HTTPS API and nowhere else, which is why `gunnar-forge`
6350    /// exists and why it is linked by **this** binary and never by the
6351    /// appliance: it holds a personal access token for the length of one call,
6352    /// and a box whose whole design is that it holds only repositories has no
6353    /// business with a bearer credential for somebody's entire GitHub.
6354    ///
6355    /// # The order, per chosen repository
6356    ///
6357    /// `Mirror.Add` with `create_repo` (the hash kind comes from the remote
6358    /// — it is not a flag), then `Mirror.SetCredential` if `--https`, then
6359    /// `Mirror.Sync`. A repository that fails does not stop the ones after it;
6360    /// the summary at the end says how many were queued and names every failure
6361    /// with its reason. A forge **401 or 403**, though, stops before anything is
6362    /// queued at all: half an import is worse than none, because it leaves an
6363    /// operator guessing which half.
6364    ///
6365    /// Re-running it is safe. `Add` on a mirror that is already there is a
6366    /// no-op and a second `Sync` while one is live is the same job, so `import
6367    /// --all` after adding one repository upstream mirrors the new one and
6368    /// bores the other twenty.
6369    Import {
6370        /// Which forge, and where. `github`, `codeberg`, `gitlab`, or any of
6371        /// them with `=<base-url>` for a self-hosted instance:
6372        /// `forgejo=https://git.example.com`, `github=https://ghe.example.com/api/v3`,
6373        /// `gitlab=https://gitlab.example.com`.
6374        #[arg(long, value_name = "FORGE[=URL]")]
6375        from: String,
6376        /// A file holding the forge token. `-` means stdin, which
6377        /// `--pick` also wants — so use a file when you use `--pick`.
6378        ///
6379        /// Without it the environment is read, in this order:
6380        /// `GUNNAR_FORGE_TOKEN`, then the forge's own conventional variable
6381        /// (`GITHUB_TOKEN`, `CODEBERG_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`).
6382        /// There is deliberately no `--token <VALUE>`; see `credential`.
6383        #[arg(long, value_name = "PATH")]
6384        token_file: Option<String>,
6385        /// Mirror every listed repository.
6386        #[arg(long, conflicts_with = "pick")]
6387        all: bool,
6388        /// Read the choice from **stdin**: the `#` numbers as printed, or names
6389        /// (`octocat/hello-world` or just `hello-world`), separated by
6390        /// whitespace or commas. A `#` starts a comment to end of line.
6391        ///
6392        /// stdin and not a terminal picker, so the choice can be piped, kept in
6393        /// a file, and re-run. A chooser that only works at a tty is one a
6394        /// script cannot repeat.
6395        #[arg(long)]
6396        pick: bool,
6397        /// The gunnar namespace the mirrors land in — `<into>/<repo-name>`.
6398        ///
6399        /// Defaults to your login name (`$USER`), because that is your account.
6400        /// It is NOT asked of the server: the control plane authenticates a KEY
6401        /// and has no verb that names an account back, so guessing from the
6402        /// fingerprint would be a guess. Say `--into` when the two differ.
6403        #[arg(long, value_name = "NAMESPACE")]
6404        into: Option<String>,
6405        /// Mirror over `ssh://` (the forge's ssh clone URL) and store NO
6406        /// credential: a deploy key has to be enrolled on the upstream side,
6407        /// which is the upstream forge's business and not gunnar's. The command
6408        /// says where to put the private half afterwards.
6409        #[arg(long, conflicts_with = "https")]
6410        ssh: bool,
6411        /// Mirror over `https://` and store the SAME token the listing used —
6412        /// the forge that enumerated these repositories is the forge that will
6413        /// serve the clone. The default.
6414        #[arg(long)]
6415        https: bool,
6416        /// Include archived repositories, which are excluded by default.
6417        ///
6418        /// Excluded rather than shown-and-skipped because an account with ten
6419        /// years of archived experiments is mostly archived experiments, and
6420        /// `--all` should not mean "and 200 things you last touched in 2016".
6421        /// Mirroring one on purpose is exactly why the flag exists.
6422        #[arg(long)]
6423        include_archived: bool,
6424        /// Take over repositories of the same name that exist and are NOT
6425        /// mirrors. Without it gunnar refuses each such import by name and the
6426        /// rest go ahead.
6427        #[arg(long)]
6428        adopt: bool,
6429        #[arg(long, short = 'i', value_name = "PATH")]
6430        identity: Option<PathBuf>,
6431        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6432        at: String,
6433    },
6434}