crates/gunnar-cli/src/lib.rs
//! **`gunnar`, the binary that ties the crates together.**
//!
//! Everything below this crate is already built and tested. `gunnar-cli` is
//! wiring, and the wiring is the point: until this crate existed, the SSH
//! server ([`gunnar_ssh`]) and the protocol state machines ([`gunnar_wire`])
//! had each been proven on their own and never joined. `gunnar-ssh`'s tests
//! drove a `BannerTransport`; `gunnar-wire`'s tests drove a pipe. Here they
//! meet, and `tests/ssh_end_to_end.rs` runs stock `git` against the join.
//!
//! ```text
//! gunnar serve
//! ├── SSH :2222 russh ── auth by key ── sandbox ── grant
//! │ └── GitTransport ──▶ gunnar-wire upload/receive-pack
//! │ receive-pack ── PushGate ──▶ gunnar-policy
//! └── gRPC 127.0.0.1:50051 tonic ── ControlPlane ──▶ gunnar-core
//! ```
//!
//! # Two listeners, never one
//!
//! Git traffic never passes through gRPC and there is no bridge. **Both**
//! listeners authenticate against the same identity key table: SSH by publickey,
//! gRPC by an `sshsig` credential signed with that same key (the gRPC
//! allow-all hole was closed 2026-08-03), and both authorise through the same
//! `AuthTable`. gRPC still defaults to **loopback** (closing the hole is not a
//! licence to widen the bind), and `tests/serve_wiring.rs` goes red if that
//! default moves.
//!
//! # Two halves, two features
//!
//! `server` and `client` are both on by default. An appliance image builds
//! `--no-default-features --features server`, and links **no** client code:
//! `gunnar-client`, and with it gix's worktree/status porcelain, is an
//! optional dependency of the `client` feature alone. `tests/feature_split.rs`
//! asserts that from a resolved `cargo tree`, not from the declaration. A
//! declaration cannot see feature unification.
#![warn(clippy::all)]
#![doc(
html_logo_url = "https://gunnar.rs/download/gunnar.webp",
html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]
// `cli` and `logging` moved to `gunnar-common` so `gunnar-server` can share
// them without depending on this crate. Re-exported here so every existing path
// — `gunnar_cli::cli::…`, `gunnar_cli::logging::…`, and the `crate::cli` /
// `crate::logging` forms the modules below use — keeps resolving unchanged.
pub use gunnar_common::{cli, logging};
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "control")]
pub mod control;
#[cfg(feature = "client")]
pub mod crypt;
#[cfg(feature = "client")]
pub mod crypt_key;
#[cfg(feature = "client")]
pub mod fido2;
#[cfg(feature = "client")]
pub mod repo_settings;
// `gunnar-cli login` asks the console over HTTPS: the named extra `online` (GUN-PRODUCTS).
#[cfg(feature = "online")]
pub mod login;
// The server half lives in its own crate (`gunnar-server`) now. Re-exported so
// every existing path — `gunnar_cli::server::…` (used by ~15 tests and by
// gunnar-znippy) and the `crate::server::…` dispatch below — resolves unchanged.
#[cfg(feature = "server")]
pub use gunnar_server as server;
#[cfg(feature = "systemd")]
pub mod service;
use anyhow::Result;
use clap::Parser;
use crate::cli::{Cli, Command};
/// **Every verb, dispatched.** `pub` so a second binary — `crates/gunnar-znippy`,
/// which installs a cold tier and then runs exactly this — is a `main` of four
/// lines rather than a copy of this `match` (one implementation).
pub fn run() -> Result<()> {
let cli = Cli::parse();
crate::init_tracing(cli.verbose);
match cli.command {
// **`gunnar issuer show --at <addr>`: the same verb, asked over the
// wire.** Intercepted before the server arm below because the wire
// half needs the CONTROL client and not a data root — a thin client
// with no storage engine can ask it, which is the point: the appliance
// it asks about has no shell to run the offline half on.
//
// One verb, not two. `gunnar control issuer show` would have been a
// second spelling of one question and a second place for the rendering
// to drift; the offline and the remote halves print through the same
// `IssuerInfoResponse::render`.
#[cfg(feature = "control")]
Command::Issuer(cli::IssuerCommand::Show {
pem,
at: Some(at),
identity,
}) => crate::control::issuer_show(&at, identity.as_deref(), pem),
// A CONTROL-only binary carries no data root reader, so `issuer show`
// with no `--at` has nothing to read. Said out loud rather than left
// to clap: the offline form is the one every runbook prints, and an
// operator who typed it on a thin client deserves the one word that
// fixes it.
#[cfg(all(feature = "control", not(feature = "server")))]
Command::Issuer(cli::IssuerCommand::Show { at: None, .. }) => anyhow::bail!(
"`gunnar issuer show` with no `--at` reads the data root under its feet, and this binary carries no storage half (built without the `server` feature). Name the server to ask: `gunnar issuer show --at <addr> -i <key>`"
),
// Every server verb routes into `gunnar-server`, where the dispatch
// and all its wiring live exactly once (one implementation). The
// `gunnar-server` binary calls the same `gunnar_server::run`, so there
// is one server match.
#[cfg(feature = "server")]
server_command @ (Command::Serve(_)
| Command::Namespace(_)
| Command::Repo(_)
| Command::Principal(_)
| Command::Issuer(_)
| Command::Key(_)
| Command::Token(_)
| Command::Attest(_)
| Command::Verify(_)
| Command::Blame(_)
| Command::Search(_)
| Command::Forge(_)
| Command::Grant(_)
| Command::Revoke(_)
| Command::Gc(_)
| Command::Reflog(_)
| Command::Wg(_)) => gunnar_server::run(cli.data_root, server_command),
#[cfg(feature = "client")]
Command::Clone(args) => crate::client::clone(args),
#[cfg(feature = "client")]
Command::Fetch(args) => crate::client::fetch(args),
#[cfg(feature = "client")]
Command::LsRemote(args) => crate::client::ls_remote(args),
#[cfg(feature = "client")]
Command::Push(args) => crate::client::push(args),
#[cfg(feature = "client")]
Command::Mirror(command) => crate::client::mirror(command),
#[cfg(feature = "client")]
Command::Crypt(command) => crate::crypt::run(command),
#[cfg(feature = "client")]
Command::Settings(args) => crate::repo_settings::run(args),
#[cfg(feature = "client")]
Command::Create(args) => crate::repo_settings::create(args),
#[cfg(feature = "client")]
Command::Log(args) => crate::client::log(args),
#[cfg(feature = "client")]
Command::Status(args) => crate::client::status(args),
#[cfg(feature = "client")]
Command::Commit(args) => crate::client::commit(args),
#[cfg(feature = "client")]
Command::Branch(args) => crate::client::branch(args),
#[cfg(feature = "client")]
Command::Tag(args) => crate::client::tag(args),
#[cfg(feature = "control")]
Command::Control(command) => crate::control::run(command),
#[cfg(feature = "online")]
Command::Login(args) => crate::login::run(args),
#[cfg(all(feature = "control", not(feature = "online")))]
Command::Login(_) => anyhow::bail!(
"this gunnar-cli has no `login`: it asks the console over HTTPS, the named extra `online` \
(`cargo install gunnar-cli --features online`); `-i <key>` signs in with an SSH key"
),
#[cfg(feature = "systemd")]
Command::Service(command) => crate::service::run(command),
// A verb `gunnar-common` parses but this build does not carry: only when
// `gunnar-common` is built with a half this crate is not (feature
// unification — the front's test links the control verbs beside a
// server-featured `gunnar-common`). Said, never a compile failure.
#[allow(unreachable_patterns)]
_ => anyhow::bail!("this build of gunnar does not carry that verb"),
}
}
// The build identity, the SSH banner and `init_tracing` moved to `gunnar-common`
// when the server became its own crate. Re-exported so every existing path —
// `gunnar_cli::AGENT`, `gunnar_cli::ssh_server_id`, `gunnar_cli::init_tracing`,
// `crate::AGENT`, `crate::ssh_server_id()`, `crate::init_tracing(..)` — resolves
// unchanged.
pub use gunnar_common::{init_tracing, ssh_server_id, AGENT, BUILD_REF};