ref:5a2ca4243d63ed4f7031ef7065b84bc0334928c3

feat: anvil registry token — manage container-registry credentials (#40)

## Why Registry tokens (docker-login credentials) could only be created in the web UI. Add a CLI, consuming the new registry-token API (**fangorn/anvil #347**), so automation can provision them — concretely, the fangorn/fleet tofu deploy needs a read-only `pull:fangorn/*` token for the droplet. ## What ``` anvil registry token create --name <n> [--read] [--write] [--repo org/repo | --org org] [--scope ...] anvil registry token list anvil registry token delete <id> ``` - `--read`/`--write` + `--repo`/`--org` expand to `<action>:<org>/<repo|*>` scopes; `--write` implies pull+push; explicit `--scope` overrides. - `create` prints the plaintext `anvreg_...` **once**; `list` never shows it; `delete` is id-scoped. Example for the fleet token: `anvil registry token create --name fleet-deploy --read --org fangorn` → `pull:fangorn/*`. ## Tests / checks 6 unit tests on the pure `build_scopes` logic (read/org, write/repo, explicit override, missing target, missing access, repo+org conflict). `cargo fmt`/`clippy` clean; full test suite green. **Depends on anvil #347** (the API) being deployed. Closes #31 🤖 Generated with [Claude Code](https://claude.com/claude-code)
SHA: 5a2ca4243d63ed4f7031ef7065b84bc0334928c3
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-18 06:47
Parents: b5e9f01
2 files changed +239 -0
Type
src/commands/mod.rs +4 −0
@@ -10,6 +10,7 @@
pub mod label;
pub mod milestone;
pub mod pr;
pub mod registry;
pub mod release;
pub mod repo;
pub mod requirement;
@@ -59,6 +60,8 @@
Branch(branch::BranchArgs),
/// Release operations
Release(release::ReleaseArgs),
/// Container-registry token management
Registry(registry::RegistryArgs),
/// Manage requirements and traceability
Requirement(Box<requirement::RequirementArgs>),
/// Deployment operations
@@ -93,6 +96,7 @@
Command::Commit(args) => commit::run(args).await,
Command::Branch(args) => branch::run(args).await,
Command::Release(args) => release::run(args).await,
Command::Registry(args) => registry::run(args).await,
Command::Requirement(args) => requirement::run(*args).await,
Command::Deploy(args) => deploy::run(args).await,
Command::Agent(args) => agent::run(args).await,
src/commands/registry.rs +235 −0
@@ -1,0 +1,235 @@
use crate::client::Client;
use crate::output;
use clap::{Args, Subcommand};
#[derive(Args)]
pub struct RegistryArgs {
#[command(subcommand)]
pub command: RegistryCommand,
}
#[derive(Subcommand)]
pub enum RegistryCommand {
/// Manage container-registry tokens (docker login credentials)
Token(TokenArgs),
}
#[derive(Args)]
pub struct TokenArgs {
#[command(subcommand)]
pub command: TokenCommand,
}
#[derive(Subcommand)]
pub enum TokenCommand {
/// Create a registry token. The plaintext value is shown ONCE.
Create(CreateArgs),
/// List your registry tokens (never shows plaintext).
List,
/// Revoke a registry token by id.
Delete(DeleteArgs),
}
#[derive(Args)]
pub struct CreateArgs {
/// Human-readable name for the token.
#[arg(long)]
name: String,
/// Grant pull (read / `docker pull`) access.
#[arg(long)]
read: bool,
/// Grant push (write / `docker push`) access — implies read.
#[arg(long)]
write: bool,
/// Target a single repository (org/repo). Mutually exclusive with --org.
#[arg(long, conflicts_with = "org")]
repo: Option<String>,
/// Target every repository in an org (expands to `<org>/*`).
#[arg(long)]
org: Option<String>,
/// Explicit scope string(s), e.g. `pull:fangorn/*`. Repeatable. When given,
/// overrides --read/--write/--repo/--org.
#[arg(long = "scope")]
scopes: Vec<String>,
}
#[derive(Args)]
pub struct DeleteArgs {
/// Token id (uuid) — from `anvil registry token list`.
id: String,
}
pub async fn run(args: RegistryArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
RegistryCommand::Token(t) => match t.command {
TokenCommand::Create(a) => create(a).await,
TokenCommand::List => list().await,
TokenCommand::Delete(a) => delete(a).await,
},
}
}
/// Build the `<action>:<org>/<repo|*>` scope strings from the flags. Explicit
/// `--scope` wins; otherwise derive from --read/--write + --repo/--org.
fn build_scopes(args: &CreateArgs) -> Result<Vec<String>, String> {
if !args.scopes.is_empty() {
return Ok(args.scopes.clone());
}
let target = match (&args.repo, &args.org) {
(Some(repo), None) => repo.clone(),
(None, Some(org)) => format!("{org}/*"),
(Some(_), Some(_)) => return Err("--repo and --org are mutually exclusive".into()),
(None, None) => {
return Err("specify a target: --repo <org/repo>, --org <org>, or --scope".into())
}
};
let mut actions = Vec::new();
if args.read || args.write {
actions.push("pull");
}
if args.write {
actions.push("push");
}
if actions.is_empty() {
return Err("specify --read and/or --write (or an explicit --scope)".into());
}
Ok(actions.iter().map(|a| format!("{a}:{target}")).collect())
}
async fn create(args: CreateArgs) -> Result<(), Box<dyn std::error::Error>> {
let scopes = build_scopes(&args)?;
let client = Client::from_config()?;
let body = serde_json::json!({ "name": args.name, "scopes": scopes });
let resp: serde_json::Value = client.post("/api/v1/registry/tokens", &body).await?;
let token = resp
.get("token")
.and_then(|v| v.as_str())
.unwrap_or_default();
output::success(&format!("Created registry token '{}'", args.name));
output::detail("Scopes", &scopes.join(", "));
output::detail("Token", token);
println!("\nThis is the only time the token is shown — store it now.");
Ok(())
}
async fn list() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get("/api/v1/registry/tokens").await?;
let tokens = resp
.get("tokens")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if tokens.is_empty() {
output::info("No registry tokens.");
return Ok(());
}
for t in &tokens {
let id = t.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
let scopes = t
.get("scopes")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
println!("{id} {name} [{scopes}]");
}
Ok(())
}
async fn delete(args: DeleteArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!("/api/v1/registry/tokens/{}", args.id))
.await?;
output::success(&format!("Revoked registry token {}", args.id));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn args(
read: bool,
write: bool,
repo: Option<&str>,
org: Option<&str>,
scopes: &[&str],
) -> CreateArgs {
CreateArgs {
name: "t".into(),
read,
write,
repo: repo.map(String::from),
org: org.map(String::from),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn read_org_expands_to_pull_wildcard() {
let s = build_scopes(&args(true, false, None, Some("fangorn"), &[])).unwrap();
assert_eq!(s, vec!["pull:fangorn/*"]);
}
#[test]
fn write_repo_grants_pull_and_push() {
let s = build_scopes(&args(false, true, Some("fangorn/mail"), None, &[])).unwrap();
assert_eq!(s, vec!["pull:fangorn/mail", "push:fangorn/mail"]);
}
#[test]
fn explicit_scope_overrides_flags() {
let s = build_scopes(&args(
true,
false,
None,
Some("ignored"),
&["delete:fangorn/x"],
))
.unwrap();
assert_eq!(s, vec!["delete:fangorn/x"]);
}
#[test]
fn requires_a_target() {
assert!(build_scopes(&args(true, false, None, None, &[])).is_err());
}
#[test]
fn requires_an_access_level() {
assert!(build_scopes(&args(false, false, None, Some("fangorn"), &[])).is_err());
}
#[test]
fn repo_and_org_conflict() {
assert!(build_scopes(&args(
true,
false,
Some("fangorn/mail"),
Some("fangorn"),
&[]
))
.is_err());
}
}