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