ref:main
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<output::Response, 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<output::Response, 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("/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);
output::line("\nThis is the only time the token is shown — store it now.");
Ok(output::Response::ok("token", resp))
}
async fn list() -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get("/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(output::Response::items(tokens));
}
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();
output::line(&format!("{id} {name} [{scopes}]"));
}
Ok(output::Response::items(tokens))
}
async fn delete(args: DeleteArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!("/registry/tokens/{}", args.id))
.await?;
output::success(&format!("Revoked registry token {}", args.id));
Ok(output::Response::deleted(&args.id))
}
#[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());
}
}