use clap::{Parser, Subcommand};
use serde_json::json;
use crate::CliError;
use crate::auth::domain_key;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::CliContext;
#[derive(Subcommand, Debug)]
pub enum DomainAction {
Keygen(KeygenArgs),
Manifest(ManifestArgs),
}
#[derive(Parser, Debug)]
pub struct KeygenArgs {
#[arg(long, value_name = "DOMAIN")]
pub domain: String,
#[arg(long = "contact", value_name = "EMAIL_OR_URI", required = true)]
pub contacts: Vec<String>,
#[arg(long)]
pub force: bool,
}
#[derive(Parser, Debug)]
pub struct ManifestArgs {
#[arg(long, value_name = "DOMAIN")]
pub domain: String,
#[arg(long = "contact", value_name = "EMAIL_OR_URI", required = true)]
pub contacts: Vec<String>,
}
pub fn run(ctx: &CliContext, action: DomainAction) -> anyhow::Result<()> {
match action {
DomainAction::Keygen(args) => keygen(ctx, args),
DomainAction::Manifest(args) => manifest(ctx, args),
}
}
fn keygen(ctx: &CliContext, args: KeygenArgs) -> anyhow::Result<()> {
let domain = normalize_domain(&args.domain)?;
let public_key = domain_key::generate(&domain, args.force)
.map_err(|e| CliError::new(ExitKind::Generic, "DOMAIN_KEYGEN_FAILED", e.to_string()))?;
emit_manifest(ctx, &domain, &public_key, &args.contacts, true)
}
fn manifest(ctx: &CliContext, args: ManifestArgs) -> anyhow::Result<()> {
let domain = normalize_domain(&args.domain)?;
let signing = domain_key::load(&domain)
.map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
let public_key = domain_key::public_key_string(&signing);
emit_manifest(ctx, &domain, &public_key, &args.contacts, false)
}
fn emit_manifest(
ctx: &CliContext,
domain: &str,
public_key: &str,
contacts: &[String],
generated: bool,
) -> anyhow::Result<()> {
let manifest =
domain_key::manifest_json(std::slice::from_ref(&public_key.to_string()), contacts);
let url = format!("https://{domain}/.well-known/memstead-publishing.json");
if ctx.json {
print_json(&json!({
"domain": domain,
"public_key": public_key,
"manifest_url": url,
"manifest": manifest,
"generated": generated,
}))?;
} else {
let pretty = serde_json::to_string_pretty(&manifest).unwrap_or_default();
let lead = if generated {
format!(
"# Domain signing key for `{domain}`\n\nA new keypair was generated and the private key stored locally."
)
} else {
format!("# Manifest for `{domain}`")
};
print_markdown(&format!(
"{lead}\n\n\
Host this exact file at:\n\n {url}\n\n\
```json\n{pretty}\n```\n\n\
Then publish with `memstead publish --scope {domain}:<handle>` — \
the CLI signs each publish with the stored key. Remove the key from \
the manifest (or take the manifest down) to revoke."
));
}
Ok(())
}
fn normalize_domain(raw: &str) -> anyhow::Result<String> {
let d = raw.trim().to_ascii_lowercase();
let looks_like_domain = !d.is_empty()
&& d.len() <= 253
&& d.contains('.')
&& !d.contains('/')
&& !d.contains(':')
&& d.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if !looks_like_domain {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_DOMAIN",
format!("{raw:?} is not a valid domain (expected e.g. `acme.com`, no scheme or path)"),
)
.into());
}
Ok(d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_and_validates_domains() {
assert_eq!(normalize_domain("Acme.COM").unwrap(), "acme.com");
assert_eq!(
normalize_domain(" sub.acme.co.uk ").unwrap(),
"sub.acme.co.uk"
);
for bad in [
"nodot",
"has space.com",
"https://acme.com",
"acme.com:demo",
"acme.com/x",
] {
assert!(normalize_domain(bad).is_err(), "{bad} should be rejected");
}
}
}