use clap::{Parser, Subcommand};
use serde_json::json;
use crate::CliError;
use crate::auth::resolve_token;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::registry::{self, ApiErrorBody, PublishError};
use crate::setup::CliContext;
#[derive(Subcommand, Debug)]
pub enum AdminAction {
Takedown(TakedownArgs),
Denylist(DenylistArgs),
}
#[derive(Parser, Debug)]
pub struct TakedownArgs {
#[arg(value_name = "SCOPE/NAME")]
pub target: String,
#[arg(long, value_name = "REF")]
pub notice: String,
#[arg(long, value_name = "TOKEN")]
pub token: Option<String>,
#[arg(long, value_name = "URL")]
pub registry: Option<String>,
}
#[derive(Parser, Debug)]
pub struct DenylistArgs {
#[arg(value_name = "SHA256")]
pub sha256: String,
#[arg(long, value_name = "TEXT")]
pub reason: Option<String>,
#[arg(long, value_name = "TOKEN")]
pub token: Option<String>,
#[arg(long, value_name = "URL")]
pub registry: Option<String>,
}
pub fn run_takedown(ctx: &CliContext, args: TakedownArgs) -> anyhow::Result<()> {
let (scope, name) = registry::parse_ref(&args.target).ok_or_else(|| {
CliError::new(
ExitKind::Generic,
"INVALID_INPUT",
format!("expected `<scope>/<name>`, got `{}`", args.target),
)
})?;
if args.notice.trim().is_empty() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
"--notice must be a non-empty statement-of-reasons reference",
)
.into());
}
let base = registry::registry_base(args.registry.as_deref());
let host = registry::registry_host(&base);
let client = registry::build_http()?;
let token = resolve_admin_token(&host, args.token.as_deref())?;
match registry::admin_takedown(&client, &base, &scope, &name, &args.notice, &token) {
Ok(resp) => {
if ctx.json {
print_json(&json!({
"ok": true,
"action": "takedown",
"scope": resp.scope,
"name": resp.name,
"notice": args.notice,
}))?;
} else {
print_markdown(&format!(
"# Took down {}/{}\n\n- Bytes deny-listed, every version tombstoned, name burned.\n- The `{}/{}` name can no longer be re-published.\n- Notice: {}",
resp.scope, resp.name, resp.scope, resp.name, args.notice,
));
}
Ok(())
}
Err(e) => Err(map_admin_error(e).into()),
}
}
pub fn run_denylist(ctx: &CliContext, args: DenylistArgs) -> anyhow::Result<()> {
let sha = args.sha256.trim().to_ascii_lowercase();
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("expected a 64-hex-char SHA-256, got `{}`", args.sha256),
)
.into());
}
let base = registry::registry_base(args.registry.as_deref());
let host = registry::registry_host(&base);
let client = registry::build_http()?;
let token = resolve_admin_token(&host, args.token.as_deref())?;
match registry::admin_denylist(&client, &base, &sha, args.reason.as_deref(), &token) {
Ok(resp) => {
if ctx.json {
print_json(&json!({
"ok": true,
"action": "denylist",
"content_sha256": resp.content_sha256,
}))?;
} else {
print_markdown(&format!(
"# Deny-listed {}\n\n- A publish of these exact bytes is now refused.",
resp.content_sha256,
));
}
Ok(())
}
Err(e) => Err(map_admin_error(e).into()),
}
}
fn resolve_admin_token(host: &str, flag: Option<&str>) -> anyhow::Result<String> {
match resolve_token(host, flag)? {
Some(r) => Ok(r.token),
None => Err(CliError::new(
ExitKind::Generic,
"NOT_AUTHENTICATED",
"not logged in — run `memstead login` or set MEMSTEAD_TOKEN \
(admin actions do not auto-trigger Device Flow)",
)
.into()),
}
}
fn map_admin_error(err: PublishError) -> CliError {
match err {
PublishError::Io(e) => {
CliError::new(ExitKind::Generic, crate::INTERNAL_CODE, format!("io: {e}"))
}
PublishError::Network(e) => CliError::new(
ExitKind::Generic,
"NETWORK_ERROR",
format!("network error: {e}"),
),
PublishError::Malformed(e) => CliError::new(
ExitKind::Generic,
"REGISTRY_MALFORMED_RESPONSE",
format!("registry sent an unparseable success response: {e}"),
),
PublishError::Raw { status, text } => CliError::new(
ExitKind::Generic,
"REGISTRY_ERROR",
format!("registry returned {status}: {text}"),
),
PublishError::Api { status, envelope } => map_admin_api_error(status, envelope),
}
}
fn map_admin_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
let (kind, code): (ExitKind, &'static str) = match status.as_u16() {
401 => (ExitKind::Generic, "NOT_AUTHENTICATED"),
403 => (ExitKind::Generic, "FORBIDDEN"),
404 => (ExitKind::NotFound, "REGISTRY_NOT_FOUND"),
_ => (ExitKind::Generic, "REGISTRY_ERROR"),
};
let mut msg = match status.as_u16() {
401 => {
"unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
}
403 => {
"forbidden — this is an admin-only action; your GitHub login is not in MEMSTEAD_ADMINS"
.to_string()
}
404 => "no such mem on the registry".to_string(),
_ => envelope
.detail
.clone()
.unwrap_or_else(|| format!("registry returned {status}")),
};
if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
msg = format!("{msg} [{}]", envelope.error);
}
CliError::new(kind, code, msg)
}