use crate::commands::{encode_package_name, ensure_registry_auth, split_name_spec};
use aube_manifest::PackageJson;
use aube_registry::client::RegistryClient;
use aube_registry::config::{NpmConfig, normalize_registry_url_pub};
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use serde_json::Value;
#[derive(Debug, Args)]
pub struct UnpublishArgs {
#[arg(long)]
pub dry_run: bool,
#[arg(short, long)]
pub force: bool,
#[arg(long, value_name = "CODE")]
pub otp: Option<String>,
#[arg(long, value_name = "URL")]
pub registry: Option<String>,
pub spec: Option<String>,
}
struct Target {
name: String,
version: Option<String>,
}
pub async fn run(args: UnpublishArgs) -> miette::Result<()> {
let cwd = if args.spec.is_some() {
crate::dirs::project_root_or_cwd()?
} else {
crate::dirs::project_root()?
};
let target = resolve_target(args.spec.as_deref(), &cwd)?;
if target.version.is_none() && !args.force {
return Err(miette!(
"unpublishing an entire package requires --force\nhelp: pass `--force` to drop every version of `{}`, or specify a version (`{}@<version>`)",
target.name,
target.name,
));
}
let config = NpmConfig::load(&cwd);
let registry_url = args
.registry
.as_deref()
.map(normalize_registry_url_pub)
.unwrap_or_else(|| config.registry_for(&target.name).to_string());
if args.dry_run {
match &target.version {
Some(v) => println!(
"- {}@{} (dry run, would unpublish from {registry_url})",
target.name, v
),
None => println!(
"- {} (dry run, would unpublish ALL versions from {registry_url})",
target.name
),
}
return Ok(());
}
let policy = crate::commands::resolve_fetch_policy(&cwd);
let client = RegistryClient::from_config_with_policy(config, policy);
ensure_registry_auth(&client, ®istry_url)?;
match target.version {
Some(version) => {
unpublish_version(
&client,
®istry_url,
&target.name,
&version,
args.otp.as_deref(),
)
.await?;
println!("- {}@{}", target.name, version);
}
None => {
unpublish_package(&client, ®istry_url, &target.name, args.otp.as_deref()).await?;
println!("- {}", target.name);
}
}
Ok(())
}
fn resolve_target(spec: Option<&str>, cwd: &std::path::Path) -> miette::Result<Target> {
if let Some(spec) = spec {
let (name, version) = split_name_spec(spec);
if name.is_empty() {
return Err(miette!("package name is empty in `{spec}`"));
}
return Ok(Target {
name: name.to_string(),
version: version.filter(|v| !v.is_empty()).map(|v| v.to_string()),
});
}
let manifest = PackageJson::from_path(&cwd.join("package.json"))
.into_diagnostic()
.wrap_err("failed to read ./package.json")?;
let name = manifest
.name
.ok_or_else(|| miette!("package.json has no `name` field"))?;
let version = manifest
.version
.ok_or_else(|| miette!("package.json has no `version` field"))?;
Ok(Target {
name,
version: Some(version),
})
}
async fn unpublish_package(
client: &RegistryClient,
registry_url: &str,
name: &str,
otp: Option<&str>,
) -> miette::Result<()> {
let packument = fetch_packument(client, registry_url, name).await?;
let rev = extract_rev(&packument, name)?;
let url = format!(
"{}/{}/-rev/{}",
registry_url.trim_end_matches('/'),
encode_package_name(name),
rev
);
let mut req = client.authed_request(reqwest::Method::DELETE, &url, registry_url);
if let Some(otp) = otp {
req = req.header("npm-otp", otp);
}
let resp = req
.send()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to DELETE {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(miette!("unpublish failed: {status}: {}", body.trim()));
}
Ok(())
}
async fn unpublish_version(
client: &RegistryClient,
registry_url: &str,
name: &str,
version: &str,
otp: Option<&str>,
) -> miette::Result<()> {
let mut packument = fetch_packument(client, registry_url, name).await?;
let rev = extract_rev(&packument, name)?;
let tarball = strip_version(&mut packument, name, version)?;
let put_url = format!(
"{}/{}/-rev/{}",
registry_url.trim_end_matches('/'),
encode_package_name(name),
rev
);
let mut req = client
.authed_request(reqwest::Method::PUT, &put_url, registry_url)
.header("content-type", "application/json")
.body(serde_json::to_vec(&packument).into_diagnostic()?);
if let Some(otp) = otp {
req = req.header("npm-otp", otp);
}
let resp = req
.send()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to PUT {put_url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(miette!(
"unpublish (trim versions) failed: {status}: {}",
body.trim()
));
}
let Some(tarball_path) = tarball else {
return Ok(());
};
let put_body_bytes = resp
.bytes()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read PUT response for {name}"))?;
let new_rev = match serde_json::from_slice::<Value>(&put_body_bytes)
.ok()
.as_ref()
.and_then(|b| b.get("rev"))
.and_then(Value::as_str)
{
Some(rev) => rev.to_string(),
None => fetch_rev(client, registry_url, name).await?,
};
let del_url = format!(
"{}/{}/-/{}/-rev/{}",
registry_url.trim_end_matches('/'),
encode_package_name(name),
tarball_path,
new_rev
);
let mut req = client.authed_request(reqwest::Method::DELETE, &del_url, registry_url);
if let Some(otp) = otp {
req = req.header("npm-otp", otp);
}
let resp = req
.send()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to DELETE {del_url}"))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(miette!(
"unpublish (tarball delete) failed: {status}: {}",
body.trim()
));
}
Ok(())
}
async fn fetch_packument(
client: &RegistryClient,
registry_url: &str,
name: &str,
) -> miette::Result<Value> {
let url = format!(
"{}/{}",
registry_url.trim_end_matches('/'),
encode_package_name(name)
);
let resp = client
.authed_request(reqwest::Method::GET, &url, registry_url)
.send()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to GET {url}"))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(miette!("package not found: {name}"));
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(miette!("failed to fetch {name}: {status}: {}", body.trim()));
}
resp.json::<Value>()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to parse packument for {name}"))
}
async fn fetch_rev(
client: &RegistryClient,
registry_url: &str,
name: &str,
) -> miette::Result<String> {
let packument = fetch_packument(client, registry_url, name).await?;
extract_rev(&packument, name)
}
fn extract_rev(packument: &Value, name: &str) -> miette::Result<String> {
packument
.get("_rev")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| miette!("packument for {name} has no `_rev` field"))
}
fn strip_version(
packument: &mut Value,
name: &str,
version: &str,
) -> miette::Result<Option<String>> {
let obj = packument
.as_object_mut()
.ok_or_else(|| miette!("packument for {name} is not a JSON object"))?;
let versions = obj
.get_mut("versions")
.and_then(Value::as_object_mut)
.ok_or_else(|| miette!("packument for {name} has no `versions` map"))?;
let doomed = versions.remove(version).ok_or_else(|| {
miette!("version {version} is not published for {name} — nothing to unpublish")
})?;
let tarball = doomed
.get("dist")
.and_then(|d| d.get("tarball"))
.and_then(Value::as_str)
.and_then(|url| url.rsplit('/').next())
.map(|seg| seg.split(['?', '#']).next().unwrap_or(seg).to_string());
if let Some(time) = obj.get_mut("time").and_then(Value::as_object_mut) {
time.remove(version);
}
let survivors: Vec<String> = obj
.get("versions")
.and_then(Value::as_object)
.map(|v| v.keys().cloned().collect())
.unwrap_or_default();
if let Some(tags) = obj.get_mut("dist-tags").and_then(Value::as_object_mut) {
tags.retain(|_, v| v.as_str() != Some(version));
if !tags.contains_key("latest")
&& let Some(highest) = highest_semver(&survivors)
{
tags.insert("latest".to_string(), Value::String(highest));
}
}
Ok(tarball)
}
fn highest_semver(versions: &[String]) -> Option<String> {
versions
.iter()
.filter_map(|v| {
node_semver::Version::parse(v)
.ok()
.map(|parsed| (parsed, v))
})
.max_by(|a, b| a.0.cmp(&b.0))
.map(|(_, raw)| raw.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highest_semver_picks_max() {
let v = vec![
"1.2.3".to_string(),
"2.0.0-rc.1".to_string(),
"1.9.0".to_string(),
];
assert_eq!(highest_semver(&v).as_deref(), Some("2.0.0-rc.1"));
}
#[test]
fn highest_semver_ignores_unparseable() {
let v = vec!["not-a-version".to_string(), "1.0.0".to_string()];
assert_eq!(highest_semver(&v).as_deref(), Some("1.0.0"));
}
#[test]
fn highest_semver_empty_is_none() {
assert!(highest_semver(&[]).is_none());
}
#[test]
fn strip_version_reassigns_latest_to_highest_remaining() {
let mut pkg = serde_json::json!({
"_rev": "3-abc",
"name": "demo",
"dist-tags": { "latest": "2.0.0", "next": "3.0.0-rc.1" },
"time": {},
"versions": {
"1.0.0": { "dist": { "tarball": "https://r.test/demo/-/demo-1.0.0.tgz" } },
"1.9.0": { "dist": { "tarball": "https://r.test/demo/-/demo-1.9.0.tgz" } },
"2.0.0": { "dist": { "tarball": "https://r.test/demo/-/demo-2.0.0.tgz" } },
"3.0.0-rc.1": { "dist": { "tarball": "https://r.test/demo/-/demo-3.0.0-rc.1.tgz" } }
}
});
strip_version(&mut pkg, "demo", "2.0.0").unwrap();
assert_eq!(pkg["dist-tags"]["latest"], "3.0.0-rc.1");
assert_eq!(pkg["dist-tags"]["next"], "3.0.0-rc.1");
}
#[test]
fn strip_version_drops_latest_when_no_survivors() {
let mut pkg = serde_json::json!({
"_rev": "1-abc",
"dist-tags": { "latest": "1.0.0" },
"versions": {
"1.0.0": { "dist": { "tarball": "https://r.test/demo/-/demo-1.0.0.tgz" } }
}
});
strip_version(&mut pkg, "demo", "1.0.0").unwrap();
assert!(pkg["dist-tags"].get("latest").is_none());
}
#[test]
fn strip_version_tarball_query_string_is_stripped() {
let mut pkg = serde_json::json!({
"_rev": "1-abc",
"dist-tags": {},
"versions": {
"1.0.0": { "dist": { "tarball": "https://r.test/demo/-/demo-1.0.0.tgz?sig=abc&exp=123" } }
}
});
let tarball = strip_version(&mut pkg, "demo", "1.0.0").unwrap();
assert_eq!(tarball.as_deref(), Some("demo-1.0.0.tgz"));
}
#[test]
fn resolve_target_from_manifest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"demo","version":"1.2.3"}"#,
)
.unwrap();
let t = resolve_target(None, dir.path()).unwrap();
assert_eq!(t.name, "demo");
assert_eq!(t.version.as_deref(), Some("1.2.3"));
}
#[test]
fn resolve_target_name_only() {
let dir = tempfile::tempdir().unwrap();
let t = resolve_target(Some("lodash"), dir.path()).unwrap();
assert_eq!(t.name, "lodash");
assert!(t.version.is_none());
}
#[test]
fn resolve_target_name_version() {
let dir = tempfile::tempdir().unwrap();
let t = resolve_target(Some("lodash@4.17.21"), dir.path()).unwrap();
assert_eq!(t.name, "lodash");
assert_eq!(t.version.as_deref(), Some("4.17.21"));
}
#[test]
fn resolve_target_scoped_name_version() {
let dir = tempfile::tempdir().unwrap();
let t = resolve_target(Some("@scope/pkg@0.1.0"), dir.path()).unwrap();
assert_eq!(t.name, "@scope/pkg");
assert_eq!(t.version.as_deref(), Some("0.1.0"));
}
#[test]
fn strip_version_drops_versions_time_and_tags() {
let mut pkg = serde_json::json!({
"_rev": "3-abc",
"name": "demo",
"dist-tags": { "latest": "1.2.3", "next": "2.0.0-rc.1" },
"time": { "1.2.3": "2024-01-01", "2.0.0-rc.1": "2024-06-01" },
"versions": {
"1.2.3": { "dist": { "tarball": "https://r.test/demo/-/demo-1.2.3.tgz" } },
"2.0.0-rc.1": { "dist": { "tarball": "https://r.test/demo/-/demo-2.0.0-rc.1.tgz" } }
}
});
let tarball = strip_version(&mut pkg, "demo", "1.2.3").unwrap();
assert_eq!(tarball.as_deref(), Some("demo-1.2.3.tgz"));
assert!(pkg["versions"].get("1.2.3").is_none());
assert!(pkg["time"].get("1.2.3").is_none());
assert_eq!(pkg["dist-tags"]["latest"], "2.0.0-rc.1");
assert_eq!(pkg["dist-tags"]["next"], "2.0.0-rc.1");
}
#[test]
fn strip_version_errors_when_version_missing() {
let mut pkg = serde_json::json!({
"_rev": "1-x",
"versions": { "1.0.0": {} }
});
let err = strip_version(&mut pkg, "demo", "9.9.9").unwrap_err();
assert!(err.to_string().contains("not published"));
}
#[test]
fn extract_rev_returns_value() {
let pkg = serde_json::json!({"_rev": "7-deadbeef"});
assert_eq!(extract_rev(&pkg, "demo").unwrap(), "7-deadbeef");
}
#[test]
fn extract_rev_errors_when_missing() {
let pkg = serde_json::json!({});
assert!(extract_rev(&pkg, "demo").is_err());
}
}