use crate::commands::pack::{BuiltArchive, build_archive_with_package_json, tarball_filename};
use crate::commands::{encode_package_name, ensure_registry_auth_for_package};
use aube_manifest::PackageJson;
use aube_registry::client::RegistryClient;
use aube_registry::config::{NpmConfig, normalize_registry_url_pub};
use base64::Engine;
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use reqwest::Url;
use serde::Deserialize;
use sha2::Digest as _;
use sha2::Sha512;
use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct PublishArgs {
#[arg(value_name = "TARBALL|FOLDER")]
pub package: Option<PathBuf>,
#[arg(long, value_name = "LEVEL")]
pub access: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub ignore_scripts: bool,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub no_git_checks: bool,
#[arg(long, value_name = "CODE")]
pub otp: Option<String>,
#[arg(long)]
pub provenance: bool,
#[arg(long, value_name = "TAG")]
pub tag: Option<String>,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
}
pub async fn run(
args: PublishArgs,
filter: aube_workspace::selector::EffectiveFilter,
) -> miette::Result<()> {
args.network.install_overrides();
if !filter.is_empty() {
if args.package.is_some() {
return Err(miette!(
code = aube_codes::errors::ERR_AUBE_RECURSIVE_NOT_SUPPORTED,
"{}: an explicit tarball or folder cannot be combined with --recursive or --filter",
aube_util::cmd("publish")
));
}
let project_root = crate::dirs::project_root()?;
if !args.no_git_checks {
enforce_git_checks(&project_root)?;
}
return run_recursive(
&project_root,
&args,
&filter,
args.network.registry.as_deref(),
)
.await;
}
let invocation_cwd = crate::dirs::cwd()?;
let package = args.package.as_deref();
let source = match package {
Some(path) => {
if path.is_absolute() {
path.to_path_buf()
} else {
invocation_cwd.join(path)
}
}
None => crate::dirs::project_root()?,
};
if source.is_dir() {
if !args.no_git_checks {
enforce_git_checks(&source)?;
}
let config = super::load_npm_config(&source);
let policy = super::resolve_fetch_policy(&source);
let client = RegistryClient::from_config_with_policy(config.clone(), policy);
let outcome = publish_one(
&source,
&config,
&client,
&args,
false,
args.network.registry.as_deref(),
)
.await?;
emit_outcome(&outcome, args.json)?;
return Ok(());
}
if !source.is_file() {
return Err(miette!(
code = aube_codes::errors::ERR_AUBE_PUBLISH_SOURCE_NOT_FOUND,
"publish source does not exist: {}",
source.display()
));
}
let config_root = crate::dirs::project_root_or_cwd()?;
let config = super::load_npm_config(&config_root);
let policy = super::resolve_fetch_policy(&config_root);
let client = RegistryClient::from_config_with_policy(config.clone(), policy);
let tarball_path = source.clone();
let (archive, manifest) =
tokio::task::spawn_blocking(move || read_publish_tarball(&tarball_path))
.await
.into_diagnostic()
.wrap_err_with(|| format!("publish tarball task failed for {}", source.display()))??;
let outcome = publish_tarball(
archive,
manifest,
&source,
&config,
&client,
&args,
args.network.registry.as_deref(),
)
.await?;
emit_outcome(&outcome, args.json)?;
Ok(())
}
fn enforce_git_checks(cwd: &Path) -> miette::Result<()> {
let inside = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output();
let Ok(out) = inside else {
return Ok(());
};
if !out.status.success() || String::from_utf8_lossy(&out.stdout).trim() != "true" {
return Ok(());
}
let status = std::process::Command::new("git")
.args(["status", "--porcelain", "--untracked-files=no"])
.current_dir(cwd)
.output()
.map_err(|e| miette!("failed to run `git status`: {e}"))?;
if !status.status.success() {
return Err(miette!(
"git status failed: {}",
String::from_utf8_lossy(&status.stderr).trim()
));
}
let dirty = String::from_utf8_lossy(&status.stdout);
if !dirty.trim().is_empty() {
return Err(miette!(
"{}: working tree has uncommitted changes:\n{}\n\
help: commit or stash them, or pass --no-git-checks to override",
aube_util::cmd("publish"),
dirty.trim_end()
));
}
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.map_err(|e| miette!("failed to run `git rev-parse`: {e}"))?;
if !branch.status.success() {
return Ok(());
}
let branch = String::from_utf8_lossy(&branch.stdout).trim().to_string();
let is_version_branch = |b: &str, prefix: &str| -> bool {
let Some(rest) = b.strip_prefix(prefix) else {
return false;
};
rest.chars()
.next()
.is_some_and(|c| c.is_ascii_digit() || c == '/' || c == '-' || c == '.')
};
let ok = matches!(branch.as_str(), "master" | "main" | "HEAD")
|| is_version_branch(&branch, "v")
|| is_version_branch(&branch, "release");
if !ok {
return Err(miette!(
"{}: current branch `{branch}` is not a release branch\n\
help: switch to main/master or pass --no-git-checks to override",
aube_util::cmd("publish")
));
}
Ok(())
}
async fn run_recursive(
source_root: &Path,
args: &PublishArgs,
filter: &aube_workspace::selector::EffectiveFilter,
registry_override: Option<&str>,
) -> miette::Result<()> {
let workspace_pkgs = aube_workspace::find_workspace_packages(source_root)
.map_err(|e| miette!("failed to discover workspace packages: {e}"))?;
if workspace_pkgs.is_empty() {
return Err(miette!(
"{}: no workspace packages found. \
`--recursive` / `--filter` requires a workspace root (aube-workspace.yaml, pnpm-workspace.yaml, or package.json with a `workspaces` field) at {}",
aube_util::cmd("publish"),
source_root.display()
));
}
let selected = select_workspace_packages(source_root, &workspace_pkgs, filter)?;
if selected.is_empty() {
if !filter.is_empty() {
return Err(miette!(
"{}: --filter {:?} did not match any workspace package",
aube_util::cmd("publish"),
filter
));
}
return Err(miette!(
"{}: no publishable workspace packages (all private or empty)",
aube_util::cmd("publish")
));
}
let config = super::load_npm_config(source_root);
let policy = super::resolve_fetch_policy(source_root);
let client = RegistryClient::from_config_with_policy(config.clone(), policy);
let mut outcomes: Vec<PublishOutcome> = Vec::new();
let mut failures: Vec<(String, miette::Report)> = Vec::new();
for pkg_dir in &selected {
match publish_one(pkg_dir, &config, &client, args, true, registry_override).await {
Ok(outcome) => outcomes.push(outcome),
Err(e) => failures.push((pkg_dir.display().to_string(), e)),
}
}
if args.json {
emit_json_outcomes(&outcomes)?;
} else {
for o in &outcomes {
emit_outcome_line(o);
}
}
if !failures.is_empty() {
let joined = failures
.iter()
.map(|(p, e)| format!(" {p}: {e}"))
.collect::<Vec<_>>()
.join("\n");
return Err(miette!(
"{}: {} failed:\n{joined}",
aube_util::cmd("publish"),
pluralizer::pluralize("package", failures.len() as isize, true)
));
}
Ok(())
}
fn select_workspace_packages(
workspace_root: &Path,
workspace_pkgs: &[PathBuf],
filters: &aube_workspace::selector::EffectiveFilter,
) -> miette::Result<Vec<PathBuf>> {
let selected = aube_workspace::selector::select_workspace_packages(
workspace_root,
workspace_pkgs,
filters,
)
.map_err(|e| miette!("invalid --filter selector: {e}"))?;
let seen_names: Vec<String> = selected.iter().filter_map(|p| p.name.clone()).collect();
let out: Vec<PathBuf> = selected
.into_iter()
.filter(|p| p.name.is_some() && p.version.is_some() && !p.private)
.map(|p| p.dir)
.collect();
if !filters.is_empty() && out.is_empty() && !seen_names.is_empty() {
tracing::debug!("aube publish: known workspace packages: {seen_names:?}");
}
Ok(out)
}
struct PublishOutcome {
name: String,
version: String,
registry_url: String,
archive: Option<BuiltArchive>,
status: PublishStatus,
}
#[derive(Debug, PartialEq, Eq)]
struct PublishTarget {
name: String,
version: String,
registry_url: String,
tag: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublishStatus {
Published,
DryRun,
AlreadyPublished,
}
async fn publish_one(
pkg_dir: &Path,
config: &NpmConfig,
client: &RegistryClient,
args: &PublishArgs,
fanout: bool,
registry_override: Option<&str>,
) -> miette::Result<PublishOutcome> {
let manifest = PackageJson::from_path(&pkg_dir.join("package.json"))
.map_err(miette::Report::new)
.wrap_err_with(|| format!("failed to read {}/package.json", pkg_dir.display()))?;
let initial_target =
resolve_publish_target(&manifest, pkg_dir, config, args, registry_override)?;
let initial_already_published = !args.dry_run
&& !args.force
&& version_on_registry(
client,
&initial_target.registry_url,
&initial_target.name,
&initial_target.version,
)
.await;
if initial_already_published && fanout {
return Ok(PublishOutcome {
name: initial_target.name,
version: initial_target.version,
registry_url: initial_target.registry_url,
archive: None,
status: PublishStatus::AlreadyPublished,
});
}
run_publish_lifecycle_pre(pkg_dir, &manifest, args.ignore_scripts).await?;
let manifest = super::pack::read_root_manifest(pkg_dir)?;
let target = resolve_publish_target(&manifest, pkg_dir, config, args, registry_override)?;
let already_published = if args.dry_run || args.force {
false
} else if target == initial_target {
initial_already_published
} else {
version_on_registry(client, &target.registry_url, &target.name, &target.version).await
};
if already_published {
if fanout {
return Ok(PublishOutcome {
name: target.name,
version: target.version,
registry_url: target.registry_url,
archive: None,
status: PublishStatus::AlreadyPublished,
});
}
return Err(already_published_error(&target));
}
let archive = build_archive_for_publish(pkg_dir)?;
super::pack::run_pack_lifecycle_post(pkg_dir, args.ignore_scripts).await?;
let manifest = super::pack::read_root_manifest(pkg_dir)?;
publish_archive(archive, manifest, target, client, args, Some(pkg_dir)).await
}
async fn publish_tarball(
archive: BuiltArchive,
manifest: PackageJson,
tarball_path: &Path,
config: &NpmConfig,
client: &RegistryClient,
args: &PublishArgs,
registry_override: Option<&str>,
) -> miette::Result<PublishOutcome> {
let target = resolve_publish_target(&manifest, tarball_path, config, args, registry_override)?;
if !args.dry_run
&& !args.force
&& version_on_registry(client, &target.registry_url, &target.name, &target.version).await
{
return Err(already_published_error(&target));
}
publish_archive(archive, manifest, target, client, args, None).await
}
async fn publish_archive(
archive: BuiltArchive,
manifest: PackageJson,
target: PublishTarget,
client: &RegistryClient,
args: &PublishArgs,
lifecycle_dir: Option<&Path>,
) -> miette::Result<PublishOutcome> {
if args.dry_run {
if args.provenance {
crate::commands::publish_provenance::probe_oidc_available()
.await
.wrap_err("--dry-run --provenance: OIDC probe failed")?;
}
return Ok(PublishOutcome {
name: archive.name.clone(),
version: archive.version.clone(),
registry_url: target.registry_url,
archive: Some(archive),
status: PublishStatus::DryRun,
});
}
let provenance_bundle = if args.provenance {
Some(
crate::commands::publish_provenance::generate(
&archive.tarball,
&archive.name,
&archive.version,
)
.await
.wrap_err("failed to generate SLSA provenance attestation")?,
)
} else {
None
};
let pc_access = manifest
.extra
.get("publishConfig")
.and_then(|v| v.as_object())
.and_then(|p| p.get("access"))
.and_then(|v| v.as_str());
let effective_access = args.access.as_deref().or(pc_access);
let body = build_publish_body(
&archive,
&manifest,
&target.registry_url,
&target.tag,
effective_access,
provenance_bundle.as_deref(),
)?;
let url = put_url(&target.registry_url, &target.name);
let trusted_publish_token =
trusted_publish_token(client, &target.registry_url, &target.name).await?;
if trusted_publish_token.is_none() {
ensure_registry_auth_for_package(client, &target.registry_url, &target.name)?;
}
let body_bytes = serde_json::to_vec(&body).into_diagnostic()?;
match send_publish_put(
client,
&url,
&target.registry_url,
&archive.name,
body_bytes.clone(),
trusted_publish_token.as_deref(),
args.otp.as_deref(),
)
.await?
{
Ok(()) => {}
Err(first) if args.otp.is_none() && publish_failure_needs_otp(&first) => {
let otp = read_publish_otp(&archive.name, &archive.version)?;
if let Err(second) = send_publish_put(
client,
&url,
&target.registry_url,
&archive.name,
body_bytes,
trusted_publish_token.as_deref(),
Some(&otp),
)
.await?
{
return Err(publish_failure_report(second));
}
}
Err(failure) => return Err(publish_failure_report(failure)),
}
if let Some(pkg_dir) = lifecycle_dir {
run_publish_lifecycle_post(pkg_dir, &manifest, args.ignore_scripts).await?;
}
Ok(PublishOutcome {
name: archive.name.clone(),
version: archive.version.clone(),
registry_url: target.registry_url,
archive: Some(archive),
status: PublishStatus::Published,
})
}
fn normalize_archive_for_publish(archive: &mut BuiltArchive) {
let version = normalize_publish_version(&archive.version);
archive.version = version;
archive.filename = tarball_filename(&archive.name, &archive.version);
}
fn published_name(manifest: &PackageJson) -> miette::Result<String> {
let manifest_name = manifest
.name
.as_deref()
.ok_or_else(|| miette!("package.json has no `name`"))?;
Ok(manifest
.extra
.get("publishConfig")
.and_then(|value| value.as_object())
.and_then(|config| config.get("name"))
.and_then(|value| value.as_str())
.unwrap_or(manifest_name)
.to_string())
}
fn resolve_publish_target(
manifest: &PackageJson,
pkg_dir: &Path,
config: &NpmConfig,
args: &PublishArgs,
registry_override: Option<&str>,
) -> miette::Result<PublishTarget> {
let name = published_name(manifest)
.wrap_err_with(|| format!("publish: invalid {}/package.json", pkg_dir.display()))?;
let version = normalize_publish_version(manifest.version.as_deref().ok_or_else(|| {
miette!(
"publish: {}/package.json has no `version`",
pkg_dir.display()
)
})?);
let publish_config = manifest
.extra
.get("publishConfig")
.and_then(|value| value.as_object());
let registry_url = registry_override
.map(normalize_registry_url_pub)
.or_else(|| {
publish_config
.and_then(|config| config.get("registry"))
.and_then(|value| value.as_str())
.map(normalize_registry_url_pub)
})
.unwrap_or_else(|| config.registry_for(&name).to_string());
let tag = args
.tag
.as_deref()
.or_else(|| {
publish_config
.and_then(|config| config.get("tag"))
.and_then(|value| value.as_str())
})
.unwrap_or("latest")
.to_string();
Ok(PublishTarget {
name,
version,
registry_url,
tag,
})
}
fn already_published_error(target: &PublishTarget) -> miette::Report {
miette!(
"{}: {}@{} is already on {}\n\
help: pass --force to republish (the registry must allow it; npm's public registry does not)",
aube_util::cmd("publish"),
target.name,
target.version,
aube_util::url::redact_url(&target.registry_url),
)
}
const MAX_PUBLISH_TARBALL_DECOMPRESSED_BYTES: u64 = 1 << 30;
const MAX_PUBLISH_TARBALL_ENTRIES: usize = 200_000;
const MAX_PUBLISH_PACKAGE_JSON_BYTES: u64 = 8 << 20;
fn invalid_publish_tarball(path: &Path, reason: impl std::fmt::Display) -> miette::Report {
miette!(
code = aube_codes::errors::ERR_AUBE_TARBALL_EXTRACT,
"invalid publish tarball {}: {reason}",
path.display()
)
}
fn read_publish_tarball(path: &Path) -> miette::Result<(BuiltArchive, PackageJson)> {
let tarball = std::fs::read(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read publish tarball {}", path.display()))?;
if !tarball.starts_with(&[0x1f, 0x8b]) {
return Err(invalid_publish_tarball(
path,
"input is not gzip-compressed",
));
}
let gz = flate2::read::GzDecoder::new(tarball.as_slice());
let capped = gz.take(MAX_PUBLISH_TARBALL_DECOMPRESSED_BYTES);
let mut archive = tar::Archive::new(capped);
let entries = archive
.entries()
.map_err(|e| invalid_publish_tarball(path, e))?;
let mut files = Vec::new();
let mut unpacked_size = 0_u64;
let mut manifest_bytes = None;
for (index, entry) in entries.enumerate() {
if index >= MAX_PUBLISH_TARBALL_ENTRIES {
return Err(invalid_publish_tarball(
path,
format_args!("archive exceeds {MAX_PUBLISH_TARBALL_ENTRIES} entries"),
));
}
let mut entry = entry.map_err(|e| invalid_publish_tarball(path, e))?;
let entry_path = entry
.path()
.map_err(|e| invalid_publish_tarball(path, e))?
.into_owned();
let is_file = entry.header().entry_type().is_file();
if is_file {
unpacked_size = unpacked_size
.checked_add(entry.size())
.ok_or_else(|| invalid_publish_tarball(path, "unpacked size overflow"))?;
let relative = entry_path.components().skip(1).collect::<PathBuf>();
if !relative.as_os_str().is_empty() {
files.push(relative.to_string_lossy().replace('\\', "/"));
}
}
let is_manifest = is_file
&& entry_path.components().count() == 2
&& entry_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "package.json");
if is_manifest && manifest_bytes.is_none() {
let mut bytes = Vec::new();
entry
.by_ref()
.take(MAX_PUBLISH_PACKAGE_JSON_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| invalid_publish_tarball(path, e))?;
if bytes.len() as u64 > MAX_PUBLISH_PACKAGE_JSON_BYTES {
return Err(invalid_publish_tarball(path, "package.json exceeds 8 MiB"));
}
manifest_bytes = Some(bytes);
}
}
let manifest_bytes = manifest_bytes
.ok_or_else(|| invalid_publish_tarball(path, "archive has no top-level package.json"))?;
let manifest: PackageJson = serde_json::from_slice(&manifest_bytes)
.map_err(|e| invalid_publish_tarball(path, format_args!("invalid package.json: {e}")))?;
let name = published_name(&manifest).map_err(|e| invalid_publish_tarball(path, e))?;
let version = normalize_publish_version(
manifest
.version
.as_deref()
.ok_or_else(|| invalid_publish_tarball(path, "package.json has no `version`"))?,
);
Ok((
BuiltArchive {
filename: tarball_filename(&name, &version),
name,
version,
files,
unpacked_size,
tarball,
},
manifest,
))
}
fn build_archive_for_publish(pkg_dir: &Path) -> miette::Result<BuiltArchive> {
let manifest_path = pkg_dir.join("package.json");
let manifest_bytes = std::fs::read(&manifest_path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", manifest_path.display()))?;
let manifest: PackageJson = serde_json::from_slice(&manifest_bytes)
.into_diagnostic()
.wrap_err_with(|| format!("failed to parse {}", manifest_path.display()))?;
let name = published_name(&manifest)
.wrap_err_with(|| format!("publish: invalid {}", manifest_path.display()))?;
let mut manifest_json: serde_json::Value =
serde_json::from_slice(&manifest_bytes).into_diagnostic()?;
if let Some(obj) = manifest_json.as_object_mut() {
obj.insert("name".into(), name.clone().into());
}
let Some(raw_version) = manifest_json.get("version").and_then(|v| v.as_str()) else {
let mut archive = build_archive_with_package_json(
pkg_dir,
Some(serde_json::to_vec_pretty(&manifest_json).into_diagnostic()?),
)?;
archive.name = name;
archive.filename = tarball_filename(&archive.name, &archive.version);
return Ok(archive);
};
let version = normalize_publish_version(raw_version);
if let Some(obj) = manifest_json.as_object_mut() {
obj.insert("version".into(), version.into());
}
let mut package_json = serde_json::to_vec_pretty(&manifest_json).into_diagnostic()?;
package_json.push(b'\n');
let mut archive = build_archive_with_package_json(pkg_dir, Some(package_json))?;
archive.name = name;
normalize_archive_for_publish(&mut archive);
Ok(archive)
}
fn normalize_publish_version(version: &str) -> String {
node_semver::Version::parse(version)
.map(|v| v.to_string())
.unwrap_or_else(|_| version.to_string())
}
#[derive(Debug, Deserialize)]
struct GitHubOidcResponse {
value: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NpmOidcExchangeResponse {
token: Option<String>,
}
async fn trusted_publish_token(
client: &RegistryClient,
registry_url: &str,
package_name: &str,
) -> miette::Result<Option<String>> {
let Some(id_token) = npm_oidc_id_token(client, registry_url).await? else {
return Ok(None);
};
exchange_npm_oidc_token(client, registry_url, package_name, &id_token).await
}
async fn npm_oidc_id_token(
client: &RegistryClient,
registry_url: &str,
) -> miette::Result<Option<String>> {
if let Ok(token) = std::env::var("NPM_ID_TOKEN")
&& !token.trim().is_empty()
{
return Ok(Some(token));
}
if std::env::var("GITHUB_ACTIONS").is_err() {
return Ok(None);
}
let Ok(request_url) = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL") else {
return Ok(None);
};
let Ok(request_token) = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN") else {
return Ok(None);
};
if request_url.trim().is_empty() || request_token.trim().is_empty() {
return Ok(None);
}
let registry = Url::parse(registry_url)
.into_diagnostic()
.wrap_err_with(|| format!("invalid registry URL for npm OIDC: {registry_url}"))?;
let host = registry
.host_str()
.ok_or_else(|| miette!("invalid registry URL for npm OIDC: missing host"))?;
let audience = format!("npm:{host}");
let mut url = Url::parse(&request_url)
.into_diagnostic()
.wrap_err("invalid ACTIONS_ID_TOKEN_REQUEST_URL")?;
url.query_pairs_mut().append_pair("audience", &audience);
let resp = match client
.request(reqwest::Method::GET, url.as_str(), registry_url)
.header(reqwest::header::ACCEPT, "application/json")
.bearer_auth(request_token)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::debug!(
error = %e,
"GitHub Actions OIDC token request failed; falling back to configured registry auth"
);
return Ok(None);
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::debug!(
%status,
body = body.trim(),
"GitHub Actions OIDC token request failed; falling back to configured registry auth"
);
return Ok(None);
}
let Ok(body) = resp.json::<GitHubOidcResponse>().await else {
tracing::debug!(
"failed to parse GitHub Actions OIDC token response; falling back to configured registry auth"
);
return Ok(None);
};
Ok(body.value.filter(|token| !token.trim().is_empty()))
}
async fn exchange_npm_oidc_token(
client: &RegistryClient,
registry_url: &str,
package_name: &str,
id_token: &str,
) -> miette::Result<Option<String>> {
let endpoint = format!(
"{}/-/npm/v1/oidc/token/exchange/package/{}",
registry_url.trim_end_matches('/'),
encode_package_name(package_name)
);
let resp = client
.request(reqwest::Method::POST, &endpoint, registry_url)
.bearer_auth(id_token)
.send()
.await
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to exchange npm OIDC token at {}",
aube_util::url::redact_url(&endpoint)
)
})?;
if !resp.status().is_success() {
tracing::debug!(
status = %resp.status(),
"npm OIDC token exchange failed; falling back to configured registry auth"
);
return Ok(None);
}
let body = resp
.json::<NpmOidcExchangeResponse>()
.await
.into_diagnostic()
.wrap_err("failed to parse npm OIDC token exchange response")?;
Ok(body.token.filter(|token| !token.trim().is_empty()))
}
struct PublishHttpFailure {
status: reqwest::StatusCode,
body: String,
}
async fn send_publish_put(
client: &RegistryClient,
url: &str,
registry_url: &str,
name: &str,
body: Vec<u8>,
trusted_publish_token: Option<&str>,
otp: Option<&str>,
) -> miette::Result<Result<(), PublishHttpFailure>> {
let mut req = if let Some(token) = trusted_publish_token {
client
.request(reqwest::Method::PUT, url, registry_url)
.bearer_auth(token)
} else {
client.authed_request_for_package(reqwest::Method::PUT, url, registry_url, name)
}
.header("content-type", "application/json")
.body(body);
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 {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Ok(Err(PublishHttpFailure { status, body }));
}
Ok(Ok(()))
}
fn publish_failure_report(failure: PublishHttpFailure) -> miette::Report {
miette!(
"publish failed: {}: {}",
failure.status,
failure.body.trim()
)
}
fn publish_failure_needs_otp(failure: &PublishHttpFailure) -> bool {
if failure.status != reqwest::StatusCode::UNAUTHORIZED
&& failure.status != reqwest::StatusCode::FORBIDDEN
{
return false;
}
let body = failure.body.to_ascii_lowercase();
let requires = body.contains("required") || body.contains("requires");
let two_factor =
body.contains("two-factor") || body.contains("two factor") || body.contains("2fa");
body.contains("eotp")
|| body.contains("npm-otp")
|| body.contains("one-time password")
|| body.contains("one-time pass")
|| body.contains("one time password")
|| body.contains("one time pass")
|| (body.contains("otp") && requires)
|| (two_factor && requires)
}
fn read_publish_otp(name: &str, version: &str) -> miette::Result<String> {
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return Err(miette!(
"publish requires a one-time password (OTP) for {name}@{version}\n\
help: pass --otp <CODE> when running non-interactively"
));
}
let description = format!("Enter one-time password for {name}@{version}");
let otp = demand::Input::new("OTP")
.description(&description)
.mask_on_submit(true)
.run()
.into_diagnostic()
.wrap_err("failed to read OTP")?;
let otp = otp.trim().to_string();
if otp.is_empty() {
return Err(miette!("no OTP entered"));
}
Ok(otp)
}
async fn run_publish_lifecycle_pre(
pkg_dir: &Path,
manifest: &PackageJson,
ignore_scripts: bool,
) -> miette::Result<()> {
if ignore_scripts {
return Ok(());
}
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepublishOnly").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepublish").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepack").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepare").await?;
Ok(())
}
async fn run_publish_lifecycle_post(
pkg_dir: &Path,
manifest: &PackageJson,
ignore_scripts: bool,
) -> miette::Result<()> {
if ignore_scripts {
return Ok(());
}
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "publish").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "postpublish").await?;
Ok(())
}
async fn version_on_registry(
client: &RegistryClient,
registry_url: &str,
name: &str,
version: &str,
) -> bool {
let url = put_url(registry_url, name);
let Ok(resp) = client
.authed_request_for_package(reqwest::Method::GET, &url, registry_url, name)
.send()
.await
else {
return false;
};
if !resp.status().is_success() {
return false;
}
let Ok(doc) = resp.json::<serde_json::Value>().await else {
return false;
};
doc.get("versions").and_then(|v| v.get(version)).is_some()
}
fn emit_outcome(outcome: &PublishOutcome, as_json: bool) -> miette::Result<()> {
if as_json {
emit_json_single(outcome)
} else {
emit_outcome_line(outcome);
Ok(())
}
}
fn emit_outcome_line(outcome: &PublishOutcome) {
match outcome.status {
PublishStatus::DryRun => {
println!("{}", dry_run_outcome_line(outcome));
if let Some(archive) = &outcome.archive {
for f in &archive.files {
println!(" {f}");
}
}
}
PublishStatus::Published => {
println!("+ {}@{}", outcome.name, outcome.version);
}
PublishStatus::AlreadyPublished => {
println!(
"= {}@{} (already on registry, skipping)",
outcome.name, outcome.version
);
}
}
}
fn dry_run_outcome_line(outcome: &PublishOutcome) -> String {
format!(
"+ {}@{} (dry run, would PUT to {})",
outcome.name,
outcome.version,
aube_util::url::redact_url(&put_url(&outcome.registry_url, &outcome.name))
)
}
fn emit_json_outcomes(outcomes: &[PublishOutcome]) -> miette::Result<()> {
let out = serde_json::to_string_pretty(&publish_outcomes_json(outcomes)).into_diagnostic()?;
println!("{out}");
Ok(())
}
fn emit_json_single(outcome: &PublishOutcome) -> miette::Result<()> {
let out = serde_json::to_string_pretty(&publish_outcome_json(outcome)).into_diagnostic()?;
println!("{out}");
Ok(())
}
fn publish_outcomes_json(outcomes: &[PublishOutcome]) -> serde_json::Value {
match outcomes {
[outcome] => publish_outcome_json(outcome),
_ => publish_outcomes_json_array(outcomes),
}
}
fn publish_outcomes_json_array(outcomes: &[PublishOutcome]) -> serde_json::Value {
serde_json::Value::Array(outcomes.iter().map(publish_outcome_json).collect())
}
fn publish_outcome_json(outcome: &PublishOutcome) -> serde_json::Value {
let status = match outcome.status {
PublishStatus::Published => "published",
PublishStatus::AlreadyPublished => "skipped",
PublishStatus::DryRun => "dry-run",
};
let mut obj = serde_json::json!({
"id": format!("{}@{}", outcome.name, outcome.version),
"name": outcome.name,
"version": outcome.version,
"status": status,
});
if let Some(archive) = &outcome.archive {
let (shasum, integrity) = archive_hashes(archive);
let m = obj.as_object_mut().expect("json object");
m.insert("size".into(), archive.tarball.len().into());
m.insert("unpackedSize".into(), archive.unpacked_size.into());
m.insert("shasum".into(), shasum.into());
m.insert("integrity".into(), integrity.into());
m.insert("filename".into(), archive.filename.clone().into());
m.insert(
"files".into(),
serde_json::Value::Array(
archive
.files
.iter()
.map(|p| serde_json::json!({"path": p}))
.collect(),
),
);
m.insert("entryCount".into(), archive.files.len().into());
m.insert("bundled".into(), serde_json::Value::Array(Vec::new()));
}
obj
}
fn put_url(registry: &str, name: &str) -> String {
let base = registry.trim_end_matches('/');
format!("{base}/{}", encode_package_name(name))
}
fn build_publish_body(
archive: &BuiltArchive,
manifest: &PackageJson,
registry_url: &str,
tag: &str,
access: Option<&str>,
provenance_bundle_json: Option<&str>,
) -> miette::Result<serde_json::Value> {
let (shasum, integrity) = archive_hashes(archive);
let b64_tarball = base64::engine::general_purpose::STANDARD.encode(&archive.tarball);
let tarball_url = format!(
"{}/{}/-/{}",
registry_url.trim_end_matches('/'),
archive.name,
archive.filename
);
let mut version_doc = serde_json::to_value(manifest).into_diagnostic()?;
normalize_publish_manifest(&mut version_doc);
let obj = version_doc
.as_object_mut()
.ok_or_else(|| miette!("manifest did not serialize to a JSON object"))?;
obj.insert("name".into(), archive.name.clone().into());
obj.insert("version".into(), archive.version.clone().into());
obj.insert(
"_id".into(),
format!("{}@{}", archive.name, archive.version).into(),
);
obj.insert(
"dist".into(),
serde_json::json!({
"shasum": shasum,
"integrity": integrity,
"tarball": tarball_url,
}),
);
let mut body = serde_json::json!({
"_id": archive.name,
"name": archive.name,
"dist-tags": { tag: archive.version },
"versions": { archive.version.clone(): version_doc },
"_attachments": {
archive.filename.clone(): {
"content_type": "application/octet-stream",
"data": b64_tarball,
"length": archive.tarball.len(),
}
}
});
if let Some(access) = access {
body.as_object_mut()
.unwrap()
.insert("access".into(), access.into());
}
if let Some(bundle_json) = provenance_bundle_json {
let attachment_name = format!("{}-{}.sigstore", archive.name, archive.version);
let length = bundle_json.len();
body.as_object_mut()
.unwrap()
.get_mut("_attachments")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| miette!("publish body missing _attachments object"))?
.insert(
attachment_name,
serde_json::json!({
"content_type": "application/vnd.dev.sigstore.bundle+json;version=0.3",
"data": bundle_json,
"length": length,
}),
);
}
Ok(body)
}
fn normalize_publish_manifest(manifest: &mut serde_json::Value) {
let Some(obj) = manifest.as_object_mut() else {
return;
};
let Some(repository) = obj.get_mut("repository") else {
return;
};
let Some(url) = repository.as_str().and_then(normalize_repository_url) else {
return;
};
*repository = serde_json::json!({
"type": "git",
"url": url,
});
}
fn normalize_repository_url(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if raw.contains("://") || raw.starts_with("git@") {
return Some(raw.to_string());
}
for (prefix, host) in [
("github:", "github.com"),
("gitlab:", "gitlab.com"),
("bitbucket:", "bitbucket.org"),
] {
if let Some(path) = raw.strip_prefix(prefix) {
return hosted_repository_url(host, path);
}
}
if raw.split('/').count() == 2 && !raw.contains(':') {
return hosted_repository_url("github.com", raw);
}
Some(raw.to_string())
}
fn hosted_repository_url(host: &str, path: &str) -> Option<String> {
let path = path.trim_matches('/');
if path.is_empty() {
return None;
}
let (path, suffix) = path.split_once('#').unwrap_or((path, ""));
let fragment = if suffix.is_empty() {
String::new()
} else {
format!("#{suffix}")
};
let git_path = if path.ends_with(".git") {
path.to_string()
} else {
format!("{path}.git")
};
Some(format!("https://{host}/{git_path}{fragment}"))
}
fn archive_hashes(archive: &BuiltArchive) -> (String, String) {
let shasum = hex::encode(sha1::Sha1::digest(&archive.tarball));
let digest = Sha512::digest(&archive.tarball);
let integrity = format!(
"sha512-{}",
base64::engine::general_purpose::STANDARD.encode(digest)
);
(shasum, integrity)
}
#[cfg(test)]
mod tests {
use super::*;
use aube_registry::config::registry_uri_key_pub;
fn write_test_tarball(path: &Path, entries: &[(&str, &[u8])]) {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
for (entry_path, contents) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, entry_path, *contents)
.unwrap();
}
let encoder = builder.into_inner().unwrap();
std::fs::write(path, encoder.finish().unwrap()).unwrap();
}
#[test]
fn put_url_encodes_scoped_slash() {
assert_eq!(
put_url("https://registry.npmjs.org/", "@scope/pkg"),
"https://registry.npmjs.org/@scope%2Fpkg"
);
}
#[test]
fn put_url_plain_name() {
assert_eq!(
put_url("https://registry.npmjs.org", "lodash"),
"https://registry.npmjs.org/lodash"
);
}
#[test]
fn publish_failure_detects_npm_otp_challenge() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::UNAUTHORIZED,
body: r#"{"error":"EOTP","reason":"This operation requires a one-time password."}"#
.into(),
};
assert!(publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_detects_npm_one_time_pass_challenge() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::UNAUTHORIZED,
body: r#"{"error":"You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA."}"#
.into(),
};
assert!(publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_detects_npm_otp_header_hint() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::FORBIDDEN,
body: "missing npm-otp header".into(),
};
assert!(publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_detects_two_factor_required_challenge() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::FORBIDDEN,
body: "Package requires two-factor authentication for publishing".into(),
};
assert!(publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_detects_2fa_required_challenge() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::FORBIDDEN,
body: "2FA is required for this operation".into(),
};
assert!(publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_does_not_prompt_for_plain_auth_failure() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::UNAUTHORIZED,
body: "invalid npm token".into(),
};
assert!(!publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_does_not_prompt_for_unrelated_two_factor_text() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::FORBIDDEN,
body: "two-factor authentication is disabled for this account".into(),
};
assert!(!publish_failure_needs_otp(&failure));
}
#[test]
fn publish_failure_does_not_prompt_for_server_error() {
let failure = PublishHttpFailure {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
body: "EOTP".into(),
};
assert!(!publish_failure_needs_otp(&failure));
}
fn write_manifest(dir: &Path, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let p = dir.join("package.json");
std::fs::write(&p, body).unwrap();
dir.to_path_buf()
}
#[test]
fn select_skips_private_packages() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(&tmp.path().join("a"), r#"{"name":"a","version":"1.0.0"}"#);
let b = write_manifest(
&tmp.path().join("b"),
r#"{"name":"b","version":"1.0.0","private":true}"#,
);
let out = select_workspace_packages(
tmp.path(),
&[a.clone(), b],
&aube_workspace::selector::EffectiveFilter::default(),
)
.unwrap();
assert_eq!(out, vec![a]);
}
#[test]
fn select_respects_filter_exact_name() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(
&tmp.path().join("a"),
r#"{"name":"@scope/a","version":"1.0.0"}"#,
);
let b = write_manifest(&tmp.path().join("b"), r#"{"name":"b","version":"1.0.0"}"#);
let out = select_workspace_packages(
tmp.path(),
&[a, b.clone()],
&aube_workspace::selector::EffectiveFilter::from_filters(["b"]),
)
.unwrap();
assert_eq!(out, vec![b]);
}
#[test]
fn select_respects_filter_glob() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(
&tmp.path().join("a"),
r#"{"name":"@scope/a","version":"1.0.0"}"#,
);
let b = write_manifest(
&tmp.path().join("b"),
r#"{"name":"@scope/b","version":"1.0.0"}"#,
);
let c = write_manifest(
&tmp.path().join("c"),
r#"{"name":"other","version":"1.0.0"}"#,
);
let out = select_workspace_packages(
tmp.path(),
&[a.clone(), b.clone(), c],
&aube_workspace::selector::EffectiveFilter::from_filters(["@scope/*"]),
)
.unwrap();
assert_eq!(out, vec![a, b]);
}
#[test]
fn select_skips_manifest_without_version() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(&tmp.path().join("a"), r#"{"name":"a"}"#);
assert!(
select_workspace_packages(
tmp.path(),
&[a],
&aube_workspace::selector::EffectiveFilter::default(),
)
.unwrap()
.is_empty()
);
}
#[test]
fn publish_json_single_uses_npm_compatible_object_shape() {
let archive = BuiltArchive {
name: "demo".to_string(),
version: "1.2.3".to_string(),
filename: "demo-1.2.3.tgz".to_string(),
files: vec!["package.json".to_string(), "index.js".to_string()],
unpacked_size: 42,
tarball: b"archive bytes".to_vec(),
};
let outcome = PublishOutcome {
name: "demo".to_string(),
version: "1.2.3".to_string(),
registry_url: "https://registry.npmjs.org/".to_string(),
archive: Some(archive),
status: PublishStatus::Published,
};
let json = publish_outcome_json(&outcome);
assert_eq!(json.get("id").and_then(|v| v.as_str()), Some("demo@1.2.3"));
assert_eq!(json.get("name").and_then(|v| v.as_str()), Some("demo"));
assert_eq!(json.get("version").and_then(|v| v.as_str()), Some("1.2.3"));
assert_eq!(
json.get("filename").and_then(|v| v.as_str()),
Some("demo-1.2.3.tgz")
);
assert_eq!(json.get("entryCount").and_then(|v| v.as_u64()), Some(2));
assert_eq!(json.get("unpackedSize").and_then(|v| v.as_u64()), Some(42));
assert!(json.get("shasum").and_then(|v| v.as_str()).is_some());
assert!(
json.get("integrity")
.and_then(|v| v.as_str())
.is_some_and(|s| s.starts_with("sha512-"))
);
}
#[test]
fn publish_normalizes_v_prefixed_semver_like_npm() {
assert_eq!(normalize_publish_version("v2026.5.16"), "2026.5.16");
assert_eq!(normalize_publish_version("1.2.3-beta.1"), "1.2.3-beta.1");
assert_eq!(normalize_publish_version("not-semver"), "not-semver");
}
#[test]
fn publish_body_uses_normalized_version_for_registry_metadata() {
let mut archive = BuiltArchive {
name: "@jdxcode/mise-linux-x64".to_string(),
version: "v2026.5.16".to_string(),
filename: "jdxcode-mise-linux-x64-v2026.5.16.tgz".to_string(),
files: vec!["package.json".to_string()],
unpacked_size: 42,
tarball: b"archive bytes".to_vec(),
};
normalize_archive_for_publish(&mut archive);
let manifest: PackageJson = serde_json::from_value(serde_json::json!({
"name": "@jdxcode/mise-linux-x64",
"version": "v2026.5.16"
}))
.unwrap();
let body = build_publish_body(
&archive,
&manifest,
"https://registry.npmjs.org/",
"latest",
Some("public"),
None,
)
.unwrap();
assert_eq!(archive.version, "2026.5.16");
assert_eq!(archive.filename, "jdxcode-mise-linux-x64-2026.5.16.tgz");
assert_eq!(body["dist-tags"]["latest"], "2026.5.16");
assert!(body["versions"].get("2026.5.16").is_some());
assert!(body["versions"].get("v2026.5.16").is_none());
assert_eq!(body["versions"]["2026.5.16"]["version"], "2026.5.16");
assert_eq!(
body["versions"]["2026.5.16"]["_id"],
"@jdxcode/mise-linux-x64@2026.5.16"
);
}
#[test]
fn publish_body_normalizes_string_repository() {
let archive = BuiltArchive {
name: "pkg".to_string(),
version: "1.0.0".to_string(),
filename: "pkg-1.0.0.tgz".to_string(),
files: vec!["package.json".to_string()],
unpacked_size: 42,
tarball: b"archive bytes".to_vec(),
};
let manifest: PackageJson = serde_json::from_value(serde_json::json!({
"name": "pkg",
"version": "1.0.0",
"repository": "https://codeberg.org/acme/pkg.git"
}))
.unwrap();
let body = build_publish_body(
&archive,
&manifest,
"https://registry.example/",
"latest",
None,
None,
)
.unwrap();
assert_eq!(
body["versions"]["1.0.0"]["repository"],
serde_json::json!({
"type": "git",
"url": "https://codeberg.org/acme/pkg.git"
})
);
}
#[test]
fn publish_body_preserves_repository_object() {
let archive = BuiltArchive {
name: "pkg".to_string(),
version: "1.0.0".to_string(),
filename: "pkg-1.0.0.tgz".to_string(),
files: vec!["package.json".to_string()],
unpacked_size: 42,
tarball: b"archive bytes".to_vec(),
};
let manifest: PackageJson = serde_json::from_value(serde_json::json!({
"name": "pkg",
"version": "1.0.0",
"repository": {
"type": "hg",
"url": "https://example.com/acme/pkg"
}
}))
.unwrap();
let body = build_publish_body(
&archive,
&manifest,
"https://registry.example/",
"latest",
None,
None,
)
.unwrap();
assert_eq!(
body["versions"]["1.0.0"]["repository"],
serde_json::json!({
"type": "hg",
"url": "https://example.com/acme/pkg"
})
);
}
#[test]
fn repository_url_normalizer_expands_npm_shorthands() {
assert_eq!(
normalize_repository_url("acme/pkg").as_deref(),
Some("https://github.com/acme/pkg.git")
);
assert_eq!(
normalize_repository_url("github:acme/pkg").as_deref(),
Some("https://github.com/acme/pkg.git")
);
assert_eq!(
normalize_repository_url("gitlab:platform/tools/pkg#main").as_deref(),
Some("https://gitlab.com/platform/tools/pkg.git#main")
);
assert_eq!(
normalize_repository_url("bitbucket:acme/pkg.git").as_deref(),
Some("https://bitbucket.org/acme/pkg.git")
);
}
#[test]
fn repository_url_normalizer_preserves_explicit_urls() {
assert_eq!(
normalize_repository_url("https://codeberg.org/acme/pkg.git").as_deref(),
Some("https://codeberg.org/acme/pkg.git")
);
assert_eq!(
normalize_repository_url("git@github.com:acme/pkg.git").as_deref(),
Some("git@github.com:acme/pkg.git")
);
}
#[test]
fn publish_archive_embeds_normalized_package_json_version() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("package.json"),
r#"{"name":"@jdxcode/mise-linux-x64","version":"v2026.5.16"}"#,
)
.unwrap();
std::fs::write(tmp.path().join("README.md"), "mise").unwrap();
let archive = build_archive_for_publish(tmp.path()).unwrap();
let gz = flate2::read::GzDecoder::new(archive.tarball.as_slice());
let mut tar = tar::Archive::new(gz);
let mut package_json = None;
for entry in tar.entries().unwrap() {
let mut entry = entry.unwrap();
if entry.path().unwrap() == std::path::Path::new("package/package.json") {
let mut contents = String::new();
std::io::Read::read_to_string(&mut entry, &mut contents).unwrap();
package_json = Some(contents);
break;
}
}
let package_json: serde_json::Value =
serde_json::from_str(&package_json.expect("package.json in tarball")).unwrap();
assert_eq!(archive.version, "2026.5.16");
assert_eq!(archive.filename, "jdxcode-mise-linux-x64-2026.5.16.tgz");
assert_eq!(package_json["version"], "2026.5.16");
}
#[test]
fn prebuilt_publish_archive_preserves_original_tarball_bytes() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("package.json"),
r#"{"name":"prebuilt","version":"1.2.3","files":["index.js"]}"#,
)
.unwrap();
std::fs::write(tmp.path().join("index.js"), "module.exports = 1").unwrap();
let packed = build_archive_for_publish(tmp.path()).unwrap();
let tarball_path = tmp.path().join("prebuilt-1.2.3.tgz");
std::fs::write(&tarball_path, &packed.tarball).unwrap();
let (loaded, manifest) = read_publish_tarball(&tarball_path).unwrap();
assert_eq!(loaded.tarball, packed.tarball);
assert_eq!(loaded.name, "prebuilt");
assert_eq!(loaded.version, "1.2.3");
assert_eq!(manifest.name.as_deref(), Some("prebuilt"));
assert!(loaded.files.iter().any(|path| path == "package.json"));
assert!(loaded.files.iter().any(|path| path == "index.js"));
}
#[test]
fn prebuilt_publish_archive_requires_top_level_package_json() {
let tmp = tempfile::tempdir().unwrap();
let tarball_path = tmp.path().join("missing-manifest.tgz");
write_test_tarball(&tarball_path, &[("package/index.js", b"export {}")]);
let err = read_publish_tarball(&tarball_path).unwrap_err();
assert!(
err.to_string()
.contains("archive has no top-level package.json"),
"{err:?}"
);
}
#[test]
fn prebuilt_publish_archive_requires_manifest_version() {
let tmp = tempfile::tempdir().unwrap();
let tarball_path = tmp.path().join("missing-version.tgz");
write_test_tarball(
&tarball_path,
&[("package/package.json", br#"{"name":"missing-version"}"#)],
);
let err = read_publish_tarball(&tarball_path).unwrap_err();
assert!(
err.to_string().contains("package.json has no `version`"),
"{err:?}"
);
}
#[test]
fn prebuilt_publish_archive_rejects_non_gzip_input() {
let tmp = tempfile::tempdir().unwrap();
let tarball_path = tmp.path().join("plain-text.tgz");
std::fs::write(&tarball_path, "not a gzip archive").unwrap();
let err = read_publish_tarball(&tarball_path).unwrap_err();
assert!(
err.to_string().contains("input is not gzip-compressed"),
"{err:?}"
);
}
#[test]
fn publish_config_name_renames_only_the_published_artifact() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("package.json"),
r#"{
"name": "workspace-name",
"version": "1.2.3",
"publishConfig": {"name": "@scope/published-name"}
}"#,
)
.unwrap();
std::fs::write(tmp.path().join("README.md"), "renamed").unwrap();
let manifest = PackageJson::from_path(&tmp.path().join("package.json")).unwrap();
assert_eq!(manifest.name.as_deref(), Some("workspace-name"));
assert_eq!(published_name(&manifest).unwrap(), "@scope/published-name");
let archive = build_archive_for_publish(tmp.path()).unwrap();
let gz = flate2::read::GzDecoder::new(archive.tarball.as_slice());
let mut tar = tar::Archive::new(gz);
let mut package_json = None;
for entry in tar.entries().unwrap() {
let mut entry = entry.unwrap();
if entry.path().unwrap() == std::path::Path::new("package/package.json") {
let mut contents = String::new();
std::io::Read::read_to_string(&mut entry, &mut contents).unwrap();
package_json = Some(contents);
break;
}
}
let package_json: serde_json::Value =
serde_json::from_str(&package_json.expect("package.json in tarball")).unwrap();
assert_eq!(archive.name, "@scope/published-name");
assert_eq!(archive.filename, "scope-published-name-1.2.3.tgz");
assert_eq!(package_json["name"], "@scope/published-name");
let on_disk = PackageJson::from_path(&tmp.path().join("package.json")).unwrap();
assert_eq!(on_disk.name.as_deref(), Some("workspace-name"));
}
#[test]
fn publish_json_outcomes_uses_array_only_for_multiple_packages() {
let first = PublishOutcome {
name: "one".to_string(),
version: "1.0.0".to_string(),
registry_url: "https://registry.npmjs.org/".to_string(),
archive: None,
status: PublishStatus::DryRun,
};
let second = PublishOutcome {
name: "two".to_string(),
version: "2.0.0".to_string(),
registry_url: "https://registry.npmjs.org/".to_string(),
archive: None,
status: PublishStatus::DryRun,
};
let single = publish_outcomes_json(std::slice::from_ref(&first));
assert_eq!(single.get("name").and_then(|v| v.as_str()), Some("one"));
assert!(!single.is_array());
let multiple = publish_outcomes_json(&[first, second]);
assert_eq!(multiple.as_array().map(Vec::len), Some(2));
}
#[test]
fn uri_key_matches_registry_helper() {
assert_eq!(
registry_uri_key_pub("https://registry.npmjs.org/"),
"//registry.npmjs.org/"
);
}
#[test]
fn publish_target_display_redacts_inline_registry_credentials() {
let outcome = PublishOutcome {
name: "@scope/pkg".to_string(),
version: "1.0.0".to_string(),
registry_url: "https://user:secret@registry.example/".to_string(),
archive: None,
status: PublishStatus::DryRun,
};
assert_eq!(
dry_run_outcome_line(&outcome),
"+ @scope/pkg@1.0.0 (dry run, would PUT to https://***@registry.example/@scope%2Fpkg)"
);
}
}