use crate::cli::ui::{format_duration_short, Loader, TimedTaskBar};
use crate::commands::version::release_timings;
use crate::commands::service::load_xbp_config_with_root;
use crate::commands::version::{
heal_publish_surface_for_release, parse_version, resolve_manifest_workspace_publish,
ManifestWorkspacePublishResolution,
};
use crate::config::{
describe_npm_token_sources, resolve_crates_token_from, resolve_npm_token_from,
NPM_TOKEN_ENV_KEYS,
};
use crate::logging::{log_file_only, log_process_output_file_only, LogLevel};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig, ServiceConfig, XbpConfig};
use crate::utils::{
command_exists, env_reference_name, first_dotenv_value_upwards,
resolve_cargo_package_version_required, resolve_env_placeholders,
};
use colored::Colorize;
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{stderr, stdout, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::Command;
use tokio::sync::{watch, Mutex};
use tokio::time::sleep;
use tokio::time::MissedTickBehavior;
use toml::Value as TomlValue;
const WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT: Duration = Duration::from_secs(180);
const WORKSPACE_PUBLISH_VISIBILITY_POLL: Duration = Duration::from_secs(5);
const PUBLISH_WATCH_MODE_GRACE: Duration = Duration::from_millis(750);
const WATCH_MODE_SCAN_TAIL_BYTES: usize = 16 * 1024;
const PUBLISH_TRANSCRIPT_MAX_CHARS: usize = 64 * 1024;
const CRATES_IO_USER_AGENT: &str = concat!(
"xbp/",
env!("CARGO_PKG_VERSION"),
" (https://github.com/xylex-group/xbp)"
);
#[derive(Debug, Clone)]
pub struct PublishCommandOptions {
pub dry_run: bool,
pub allow_dirty: bool,
pub force: bool,
pub include_prereqs: bool,
pub auto_fix: bool,
pub target: Option<String>,
pub service: Option<String>,
pub manifest_path: Option<PathBuf>,
pub expected_version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublishKind {
Npm,
Crates,
}
#[derive(Debug, Clone)]
struct PublishWorkflow {
kind: PublishKind,
config: PublishTargetConfig,
}
#[derive(Debug, Clone)]
struct PreparedPublishTarget {
kind: PublishKind,
package_name: String,
version: String,
working_directory: PathBuf,
manifest_path: PathBuf,
preflight_commands: Vec<String>,
publish_command: String,
use_wsl: bool,
wsl_distribution: Option<String>,
token: Option<String>,
workspace_publish: Option<ManifestWorkspacePublishResolution>,
already_published: bool,
retry_no_verify: bool,
no_verify_retry_packages: Vec<String>,
visibility_timeout: Duration,
visibility_poll: Duration,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackagePublishOutcome {
pub package: String,
pub version: String,
pub kind: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone)]
struct PublishPlanResult {
prepared: Vec<PreparedPublishTarget>,
outcomes: Vec<PackagePublishOutcome>,
}
#[derive(Debug, Clone, Default)]
pub struct PublishRunReport {
pub outcomes: Vec<PackagePublishOutcome>,
}
struct PublishProgress<'a> {
loader: &'a Loader,
step_prefix: Option<String>,
}
impl<'a> PublishProgress<'a> {
fn standalone(loader: &'a Loader) -> Self {
Self {
loader,
step_prefix: None,
}
}
fn prefixed(loader: &'a Loader, step_prefix: String) -> Self {
Self {
loader,
step_prefix: Some(step_prefix),
}
}
fn update(&self, step: usize, total: usize, detail: &str) {
self.loader.update(&render_publish_status(
self.step_prefix.as_deref(),
step,
total,
detail,
));
}
fn log(&self, message: &str) {
let line = if message.trim_start().starts_with('{') {
format!(" {} {}", "·".bright_black(), "(suppressed JSON output)".dimmed())
} else {
format!(" {} {}", "·".bright_black(), message.bright_white())
};
self.loader.log(&line);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommandStage {
Preflight,
Publish,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NpmCommandKind {
Whoami,
OwnerLs,
Publish,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NpmAuthMode {
Ambient,
Token,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DirectCommandSpec {
program: String,
args: Vec<String>,
env: HashMap<String, String>,
display_command: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NpmFailureKind {
Auth,
Access,
TwoFactor,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NpmFailureClassification {
kind: NpmFailureKind,
detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum NpmOwnerPermission {
Authorized,
PackageMissing,
Unauthorized { owners: Vec<String> },
Indeterminate,
}
impl CommandStage {
fn label(self) -> &'static str {
match self {
Self::Preflight => "preflight",
Self::Publish => "publish",
}
}
}
pub async fn run_publish_command(options: PublishCommandOptions) -> Result<(), String> {
let dry_run = options.dry_run;
let loader = Loader::start(if options.dry_run {
"Planning publish workflow"
} else {
"Publishing configured packages"
});
let progress = PublishProgress::standalone(&loader);
let result = run_publish_workflow(options, &progress).await;
match result {
Ok(plan) => {
if dry_run {
loader.success_with("Publish plan ready");
} else {
loader.success_with("Publish complete");
}
print_publish_completion(&plan, dry_run);
Ok(())
}
Err(error) => {
loader.fail(&error);
Err(error)
}
}
}
pub(crate) async fn run_publish_command_with_progress_prefix(
options: PublishCommandOptions,
loader: &Loader,
step_prefix: String,
) -> Result<PublishRunReport, String> {
let dry_run = options.dry_run;
let progress = PublishProgress::prefixed(loader, step_prefix);
let plan = run_publish_workflow(options, &progress).await?;
print_publish_completion(&plan, dry_run);
Ok(PublishRunReport {
outcomes: plan.outcomes,
})
}
async fn run_publish_workflow(
options: PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<PublishPlanResult, String> {
Box::pin(run_publish_workflow_inner(options, progress)).await
}
async fn run_publish_workflow_inner(
options: PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<PublishPlanResult, String> {
let current_dir =
std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let (project_root, config) = load_xbp_config_with_root().await?;
let mut prepared =
prepare_publish_targets(&project_root, ¤t_dir, &config, &options).await?;
let target_total = prepared.len();
progress.update(1, 3, "Validating publish prerequisites");
if !options.allow_dirty {
ensure_clean_git_worktree(&project_root)?;
}
progress.update(
2,
3,
if options.force {
"Checking registry state and skipping preflight commands (--force)"
} else {
"Checking registry state and running preflight commands"
},
);
for target_index in 0..prepared.len() {
let identity = prepared[target_index].identity();
let working_directory = prepared[target_index].working_directory.display().to_string();
progress.update(
2,
3,
&format!(
"Checking registry target {}/{}: {}",
target_index + 1,
target_total,
identity
),
);
progress.log(&format!(
"Target {}/{}: {} (cwd: {}, runner: {})",
target_index + 1,
target_total,
identity,
working_directory,
prepared[target_index].runner_label()
));
if registry_version_exists(&prepared[target_index]).await? {
if options.force || options.include_prereqs || options.expected_version.is_some() {
progress.log(&format!(
"Skipping {}@{} — already visible on {} (will not re-publish).",
prepared[target_index].package_name,
prepared[target_index].version,
prepared[target_index].kind.label()
));
prepared[target_index].already_published = true;
continue;
}
return Err(format!(
"{} {}@{} is already published on {}. Bump the version first, or pass `--force` / `--include-prereqs` to skip already-published packages.",
"Version already exists:".bright_red().bold(),
prepared[target_index].package_name,
prepared[target_index].version,
prepared[target_index].kind.label()
));
}
if options.force {
if !prepared[target_index].preflight_commands.is_empty() {
progress.log(&format!(
"Skipping {} configured preflight command(s) for {} because --force was supplied.",
prepared[target_index].preflight_commands.len(),
prepared[target_index].identity()
));
}
continue;
}
let preflight_commands = prepared[target_index].preflight_commands.clone();
for (command_index, command) in preflight_commands.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Running preflight {}/{} for {}",
command_index + 1,
preflight_commands.len(),
prepared[target_index].identity()
),
);
let result = run_configured_command(
command,
command,
&prepared[target_index],
None,
Some(progress),
CommandStage::Preflight,
)
.await?;
if !result.success {
return Err(format!(
"Preflight command failed for {}: `{}`\n{}",
prepared[target_index].package_name,
command,
result.output_detail()
));
}
}
}
for (target_index, target) in prepared.iter_mut().enumerate() {
progress.update(
2,
3,
&format!(
"Resolving publish closure {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
maybe_attach_workspace_publish_resolution(target, &options, progress).await?;
}
if options.dry_run {
print_publish_plan(&prepared, options.force);
return Ok(PublishPlanResult {
prepared,
outcomes: Vec::new(),
});
}
progress.loader.finish_for_handoff();
let mut timings_store = release_timings::load_release_timings(&project_root);
let mut bar = TimedTaskBar::start("publish", target_total.max(1) as u64);
let mut remaining_ids: Vec<String> = prepared
.iter()
.map(|t| {
release_timings::publish_step_id(t.kind.wire_label(), &t.package_name)
})
.collect();
let mut outcomes = Vec::new();
for target in prepared.iter() {
let step_id =
release_timings::publish_step_id(target.kind.wire_label(), &target.package_name);
let unit_label = format!(
"{} {}@{}",
target.kind.label(),
target.package_name,
target.version
);
let eta_ms = release_timings::estimate_remaining_ms(&timings_store, &remaining_ids);
bar.begin_unit(
&unit_label,
if eta_ms > 0 {
Some(Duration::from_millis(eta_ms))
} else {
None
},
);
let started = Instant::now();
if target.already_published {
let elapsed = started.elapsed();
remaining_ids.retain(|id| id != &step_id);
bar.finish_unit(
&unit_label,
true,
elapsed,
&format!("skipped · already on {}", target.kind.label()),
);
release_timings::record_step_timing(
&mut timings_store,
&step_id,
elapsed,
true,
BTreeMap::new(),
);
outcomes.push(PackagePublishOutcome {
package: target.package_name.clone(),
version: target.version.clone(),
kind: target.kind.wire_label().to_string(),
status: "skipped_already_visible".to_string(),
message: Some(format!("already visible on {}", target.kind.label())),
});
continue;
}
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
match run_workspace_publish_target(
target,
workspace_publish,
&options,
Some(progress),
)
.await
{
Ok(package_outcomes) => {
let elapsed = started.elapsed();
remaining_ids.retain(|id| id != &step_id);
let n = package_outcomes.len();
bar.finish_unit(
&unit_label,
true,
elapsed,
&format!("{n} package(s) in closure"),
);
release_timings::record_step_timing(
&mut timings_store,
&step_id,
elapsed,
true,
std::collections::BTreeMap::new(),
);
outcomes.extend(package_outcomes);
}
Err(error) => {
let elapsed = started.elapsed();
let short = error.lines().next().unwrap_or(error.as_str());
bar.finish_unit(&unit_label, false, elapsed, short);
release_timings::record_step_timing(
&mut timings_store,
&step_id,
elapsed,
false,
std::collections::BTreeMap::new(),
);
let _ = release_timings::save_release_timings(&project_root, &timings_store);
bar.fail(short);
return Err(error);
}
}
} else {
match run_publish_target(target, &options, Some(progress)).await {
Ok(()) => {
if let Err(error) = wait_for_registry_version_with_mode(
target,
target.visibility_timeout,
target.visibility_poll,
true,
)
.await
{
let elapsed = started.elapsed();
let short = error.lines().next().unwrap_or(error.as_str());
bar.finish_unit(&unit_label, false, elapsed, short);
release_timings::record_step_timing(
&mut timings_store,
&step_id,
elapsed,
false,
std::collections::BTreeMap::new(),
);
let _ = release_timings::save_release_timings(&project_root, &timings_store);
bar.fail(short);
return Err(error);
}
let elapsed = started.elapsed();
remaining_ids.retain(|id| id != &step_id);
bar.finish_unit(
&unit_label,
true,
elapsed,
&format!("published · {}", format_duration_short(elapsed)),
);
release_timings::record_step_timing(
&mut timings_store,
&step_id,
elapsed,
true,
std::collections::BTreeMap::new(),
);
outcomes.push(PackagePublishOutcome {
package: target.package_name.clone(),
version: target.version.clone(),
kind: target.kind.wire_label().to_string(),
status: "published".to_string(),
message: None,
});
}
Err(error) => {
let elapsed = started.elapsed();
let short = error.lines().next().unwrap_or(error.as_str());
bar.finish_unit(&unit_label, false, elapsed, short);
release_timings::record_step_timing(
&mut timings_store,
&step_id,
elapsed,
false,
std::collections::BTreeMap::new(),
);
let _ = release_timings::save_release_timings(&project_root, &timings_store);
bar.fail(short);
return Err(error);
}
}
}
}
bar.success_with(&format!(
"publish · {} package(s)",
outcomes
.iter()
.filter(|o| o.status == "published" || o.status == "skipped_already_visible")
.count()
));
let _ = release_timings::save_release_timings(&project_root, &timings_store);
if !options.dry_run {
notify_publish_outcomes_to_discord(&project_root, &config, &outcomes).await;
}
Ok(PublishPlanResult { prepared, outcomes })
}
async fn notify_publish_outcomes_to_discord(
project_root: &Path,
config: &XbpConfig,
outcomes: &[PackagePublishOutcome],
) {
for outcome in outcomes {
if outcome.status != "published" {
continue;
}
let notification = crate::commands::discord_notify::registry_publish_notification(
&outcome.kind,
&outcome.package,
&outcome.version,
&outcome.status,
outcome.message.as_deref(),
);
crate::commands::discord_notify::notify_discord_for_project(
project_root,
Some(config),
None,
notification,
)
.await;
}
}
async fn prepare_publish_targets(
project_root: &Path,
current_dir: &Path,
config: &XbpConfig,
options: &PublishCommandOptions,
) -> Result<Vec<PreparedPublishTarget>, String> {
let selected_service = options
.service
.as_deref()
.map(|name| resolve_publish_service(config, name))
.transpose()?;
let mut publish = config.publish.clone().unwrap_or_default();
if let Some(service) = selected_service {
let kind = publish_kind_for_service(service, options.target.as_deref())?;
match kind {
PublishKind::Npm if publish.npm.is_none() => {
publish.npm = Some(PublishTargetConfig::default())
}
PublishKind::Crates if publish.crates.is_none() => {
publish.crates = Some(PublishTargetConfig::default())
}
_ => {}
}
} else if config.publish.is_none() {
return Err("No `publish` section was found in the current XBP config. Configure one with `xbp config npm setup-release` or `xbp config crates setup-release`, or select a service with `--service`.".to_string());
}
let service_target = selected_service
.map(|service| publish_kind_for_service(service, options.target.as_deref()))
.transpose()?;
let target_filter = service_target
.map(PublishKind::label)
.or(options.target.as_deref());
let workflows = select_publish_workflows(&publish, target_filter)?;
if workflows.is_empty() {
return Err("No enabled publish targets matched the requested filter.".to_string());
}
let explicit_manifest = options
.manifest_path
.as_deref()
.map(|path| resolve_requested_manifest_path(current_dir, path));
let service_manifest = selected_service
.zip(service_target)
.map(|(service, kind)| resolve_service_publish_manifest(project_root, service, kind))
.transpose()?;
if explicit_manifest.is_some() && service_manifest.is_some() {
return Err(
"Use either `--service` or `--manifest-path` to scope publishing, not both."
.to_string(),
);
}
let manifest_override = explicit_manifest.or(service_manifest);
let selected_workflows = if let Some(requested_manifest) = manifest_override.as_deref() {
let matched = workflows
.iter()
.filter(|workflow| {
workflow_targets_manifest(project_root, workflow, requested_manifest)
})
.cloned()
.collect::<Vec<_>>();
if !matched.is_empty() {
matched
} else if workflows.len() == 1 {
workflows.clone()
} else {
return Err(format!(
"No enabled publish target in `.xbp/xbp.yaml` matched manifest path `{}`. Use `--target` to narrow the provider or update the configured `publish.<provider>.manifest_path`.",
requested_manifest.display()
));
}
} else {
workflows
};
let mut prepared = Vec::new();
let single_workflow = selected_workflows.len() == 1;
let manifest_override = manifest_override
.as_deref()
.filter(|_| single_workflow);
for workflow in selected_workflows {
let entries = workflow.config.package_entries();
if manifest_override.is_some() && entries.len() <= 1 {
prepared.push(prepare_publish_target(
project_root,
workflow,
options,
manifest_override,
)?);
continue;
}
if let Some(requested) = manifest_override {
let requested_norm = requested.to_string_lossy().replace('\\', "/");
let mut matched = false;
for entry in &entries {
let entry_manifest = entry
.manifest_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| {
default_manifest_path(
&entry
.working_directory
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| project_root.to_path_buf()),
workflow.kind,
)
});
let abs = if entry_manifest.is_absolute() {
entry_manifest.clone()
} else {
project_root.join(&entry_manifest)
};
let abs_norm = abs.to_string_lossy().replace('\\', "/");
if abs_norm == requested_norm
|| abs.ends_with(requested)
|| requested.ends_with(&entry_manifest)
{
let scoped = PublishWorkflow {
kind: workflow.kind,
config: workflow.config.for_package_entry(entry),
};
prepared.push(prepare_publish_target(
project_root,
scoped,
options,
Some(requested),
)?);
matched = true;
break;
}
}
if !matched {
prepared.push(prepare_publish_target(
project_root,
workflow,
options,
Some(requested),
)?);
}
continue;
}
for entry in entries {
let scoped = PublishWorkflow {
kind: workflow.kind,
config: workflow.config.for_package_entry(&entry),
};
prepared.push(prepare_publish_target(
project_root,
scoped,
options,
None,
)?);
}
}
Ok(prepared)
}
fn resolve_publish_service<'a>(
config: &'a XbpConfig,
name: &str,
) -> Result<&'a ServiceConfig, String> {
config
.services
.as_deref()
.unwrap_or_default()
.iter()
.find(|service| service.name == name)
.ok_or_else(|| {
format!("Unknown service `{name}`. Run `xbp services` to list configured services.")
})
}
fn publish_kind_for_service(
service: &ServiceConfig,
requested_target: Option<&str>,
) -> Result<PublishKind, String> {
let inferred = match service.target.trim().to_ascii_lowercase().as_str() {
"rust" | "cargo" | "crate" | "crates" => PublishKind::Crates,
"node" | "nodejs" | "npm" | "javascript" | "typescript" | "nextjs" | "react"
| "express" | "expressjs" => PublishKind::Npm,
target => return Err(format!("Service `{}` has target `{target}`, which is not publishable through npm or crates.io.", service.name)),
};
if let Some(requested) = normalize_target_filter(requested_target)? {
if requested != inferred {
return Err(format!(
"Service `{}` is a {} service and cannot be published with `--target {}`.",
service.name,
inferred.label(),
requested.label()
));
}
}
Ok(inferred)
}
fn resolve_service_publish_manifest(
project_root: &Path,
service: &ServiceConfig,
kind: PublishKind,
) -> Result<PathBuf, String> {
let manifest_name = match kind {
PublishKind::Npm => "package.json",
PublishKind::Crates => "Cargo.toml",
};
let configured = service
.version_targets
.as_deref()
.unwrap_or_default()
.iter()
.map(PathBuf::from)
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(manifest_name));
let manifest = configured.unwrap_or_else(|| {
PathBuf::from(service.root_directory.as_deref().unwrap_or(".")).join(manifest_name)
});
let manifest = if manifest.is_absolute() {
manifest
} else {
project_root.join(manifest)
};
if !manifest.exists() {
return Err(format!(
"Publish manifest for service `{}` does not exist: {}",
service.name,
manifest.display()
));
}
Ok(manifest)
}
fn select_publish_workflows(
config: &PublishProjectConfig,
target: Option<&str>,
) -> Result<Vec<PublishWorkflow>, String> {
let mut workflows = Vec::new();
match normalize_target_filter(target)? {
None => {
if let Some(npm) = config.npm.clone().filter(is_enabled_target) {
workflows.push(PublishWorkflow {
kind: PublishKind::Npm,
config: npm,
});
}
if let Some(crates) = config.crates.clone().filter(is_enabled_target) {
workflows.push(PublishWorkflow {
kind: PublishKind::Crates,
config: crates,
});
}
}
Some(PublishKind::Npm) => {
let Some(npm) = config.npm.clone().filter(is_enabled_target) else {
return Err(
"No enabled npm publish target is configured in `.xbp/xbp.yaml`.".to_string(),
);
};
workflows.push(PublishWorkflow {
kind: PublishKind::Npm,
config: npm,
});
}
Some(PublishKind::Crates) => {
let Some(crates) = config.crates.clone().filter(is_enabled_target) else {
return Err(
"No enabled crates publish target is configured in `.xbp/xbp.yaml`."
.to_string(),
);
};
workflows.push(PublishWorkflow {
kind: PublishKind::Crates,
config: crates,
});
}
}
Ok(workflows)
}
fn normalize_target_filter(target: Option<&str>) -> Result<Option<PublishKind>, String> {
match target.map(|value| value.trim().to_ascii_lowercase()) {
None => Ok(None),
Some(value) if value.is_empty() => Ok(None),
Some(value) if value == "npm" => Ok(Some(PublishKind::Npm)),
Some(value) if value == "crates" || value == "crates.io" => Ok(Some(PublishKind::Crates)),
Some(value) => Err(format!(
"Unsupported publish target `{}`. Use `npm` or `crates`.",
value
)),
}
}
fn is_enabled_target(config: &PublishTargetConfig) -> bool {
config.enabled.unwrap_or(true)
}
fn prepare_publish_target(
project_root: &Path,
workflow: PublishWorkflow,
options: &PublishCommandOptions,
manifest_override: Option<&Path>,
) -> Result<PreparedPublishTarget, String> {
let configured_working_directory = workflow
.config
.working_directory
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| project_root.to_path_buf());
let manifest_path = manifest_override
.map(PathBuf::from)
.or_else(|| workflow.config.manifest_path.clone().map(PathBuf::from))
.unwrap_or_else(|| default_manifest_path(&configured_working_directory, workflow.kind));
let working_directory = manifest_override
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(configured_working_directory);
if !manifest_path.exists() {
return Err(format!(
"Configured manifest path does not exist for {}: {}",
workflow.kind.label(),
manifest_path.display()
));
}
let package_name = if manifest_override.is_some() {
detect_package_name(workflow.kind, &manifest_path).unwrap_or_else(|| "package".to_string())
} else {
workflow
.config
.package_name
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
detect_package_name(workflow.kind, &manifest_path)
.unwrap_or_else(|| "package".to_string())
})
};
let version = detect_package_version(workflow.kind, &manifest_path)?;
if let Some(expected_version) = &options.expected_version {
if expected_version.trim() != version {
return Err(format!(
"Configured {} publish target resolved version {} but release target is {}.",
workflow.kind.label(),
version,
expected_version.trim()
));
}
}
let token = resolve_publish_token(
project_root,
workflow.kind,
workflow.config.token.as_deref(),
);
let preflight_commands = workflow
.config
.preflight_commands
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
let publish_command = workflow
.config
.publish_command
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| default_publish_command(workflow.kind, &workflow.config));
let visibility_timeout = workflow
.config
.visibility_timeout_seconds
.map(|secs| Duration::from_secs_f64(secs.max(1.0)))
.unwrap_or(WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT);
let visibility_poll = workflow
.config
.visibility_poll_seconds
.map(|secs| Duration::from_secs_f64(secs.max(0.5)))
.unwrap_or(WORKSPACE_PUBLISH_VISIBILITY_POLL);
let retry_no_verify = workflow.config.retry_no_verify.unwrap_or(true);
let no_verify_retry_packages = workflow.config.no_verify_retry_packages.clone();
Ok(PreparedPublishTarget {
kind: workflow.kind,
package_name,
version,
working_directory,
manifest_path,
preflight_commands,
already_published: false,
retry_no_verify,
no_verify_retry_packages,
visibility_timeout,
visibility_poll,
publish_command,
use_wsl: workflow.config.use_wsl.unwrap_or(false),
wsl_distribution: workflow.config.wsl_distribution.clone(),
token,
workspace_publish: None,
})
}
fn default_manifest_path(working_directory: &Path, kind: PublishKind) -> PathBuf {
match kind {
PublishKind::Npm => working_directory.join("package.json"),
PublishKind::Crates => working_directory.join("Cargo.toml"),
}
}
fn detect_package_name(kind: PublishKind, manifest_path: &Path) -> Option<String> {
match kind {
PublishKind::Npm => {
let content = fs::read_to_string(manifest_path).ok()?;
let json: JsonValue = serde_json::from_str(&content).ok()?;
json.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
PublishKind::Crates => {
let content = fs::read_to_string(manifest_path).ok()?;
let toml: TomlValue = toml::from_str(&content).ok()?;
toml.get("package")
.and_then(|package| package.get("name"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
}
}
fn detect_package_version(kind: PublishKind, manifest_path: &Path) -> Result<String, String> {
match kind {
PublishKind::Npm => {
let content = fs::read_to_string(manifest_path).map_err(|e| {
format!(
"Failed to read npm manifest {}: {}",
manifest_path.display(),
e
)
})?;
let json: JsonValue = serde_json::from_str(&content).map_err(|e| {
format!(
"Failed to parse npm manifest {}: {}",
manifest_path.display(),
e
)
})?;
json.get("version")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
format!(
"Could not resolve package.version from {}.",
manifest_path.display()
)
})
}
PublishKind::Crates => resolve_cargo_package_version_required(manifest_path),
}
}
fn resolve_publish_token(
project_root: &Path,
kind: PublishKind,
configured: Option<&str>,
) -> Option<String> {
let token_from_config =
configured.and_then(|raw| resolve_publish_placeholder(project_root, raw));
if token_from_config.is_some() {
return token_from_config;
}
match kind {
PublishKind::Npm => resolve_npm_token_from(Some(project_root)),
PublishKind::Crates => resolve_crates_token_from(Some(project_root)),
}
}
fn resolve_publish_placeholder(project_root: &Path, raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if !looks_like_placeholder(trimmed) {
return Some(trimmed.to_string());
}
let mut env_map = HashMap::new();
env_map.insert("TOKEN".to_string(), trimmed.to_string());
let resolved = resolve_env_placeholders(project_root, &env_map)
.remove("TOKEN")
.unwrap_or_else(|| trimmed.to_string());
if !looks_like_placeholder(&resolved) {
return Some(resolved);
}
let key = env_reference_name(trimmed).unwrap_or("NPM_TOKEN");
first_dotenv_value_upwards(project_root, &[key], 12)
.map(|(value, _)| value)
.or_else(|| {
if key == "TOKEN" || key == "NPM_TOKEN" {
first_dotenv_value_upwards(project_root, NPM_TOKEN_ENV_KEYS, 12)
.map(|(value, _)| value)
} else {
None
}
})
}
fn looks_like_placeholder(value: &str) -> bool {
let trimmed = value.trim();
trimmed.starts_with("${") && trimmed.ends_with('}') || trimmed.starts_with('$')
}
fn default_publish_command(kind: PublishKind, config: &PublishTargetConfig) -> String {
match kind {
PublishKind::Npm => {
if let Some(access) = config
.access
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("npm publish --access {}", access)
} else {
"npm publish".to_string()
}
}
PublishKind::Crates => "cargo publish".to_string(),
}
}
fn ensure_clean_git_worktree(project_root: &Path) -> Result<(), String> {
if !command_exists("git") {
return Ok(());
}
let output = std::process::Command::new("git")
.current_dir(project_root)
.args(["status", "--porcelain=v1", "--untracked-files=all"])
.output()
.map_err(|e| format!("Failed to run `git status`: {}", e))?;
if !output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut noise = 0usize;
let entries: Vec<&str> = stdout
.lines()
.map(str::trim_end)
.filter(|line| !line.trim().is_empty())
.filter(|line| {
if crate::commands::deploy_engine::history_ext::porcelain_line_is_xbp_ledger_noise(line)
{
noise += 1;
false
} else {
true
}
})
.take(8)
.collect();
if entries.is_empty() {
if noise > 0 {
eprintln!(
"{} ignoring {noise} dirty .xbp ledger path(s) for publish (deployments/releases/deploy.lock)",
"·"
);
}
return Ok(());
}
Err(format!(
"Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}{}",
entries.join(", "),
if noise > 0 {
format!(" (also ignored {noise} .xbp ledger path(s))")
} else {
String::new()
}
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RegistryLookup {
Found,
NotFound,
Retryable,
}
fn crates_io_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent(CRATES_IO_USER_AGENT)
.build()
.map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
}
fn classify_registry_http_status(status: reqwest::StatusCode) -> RegistryLookup {
if status.is_success() {
RegistryLookup::Found
} else if status == reqwest::StatusCode::NOT_FOUND {
RegistryLookup::NotFound
} else if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
RegistryLookup::Retryable
} else if status == reqwest::StatusCode::FORBIDDEN {
RegistryLookup::Retryable
} else {
RegistryLookup::NotFound
}
}
async fn registry_version_lookup(target: &PreparedPublishTarget) -> Result<RegistryLookup, String> {
match target.kind {
PublishKind::Npm => {
let client = reqwest::Client::builder()
.user_agent(CRATES_IO_USER_AGENT)
.build()
.map_err(|e| format!("Failed to build npm HTTP client: {}", e))?;
let encoded = target.package_name.replace('/', "%2f");
let url = format!("https://registry.npmjs.org/{}/{}", encoded, target.version);
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to query npm registry: {}", e))?;
Ok(classify_registry_http_status(response.status()))
}
PublishKind::Crates => {
let client = crates_io_http_client()?;
let url = format!(
"https://crates.io/api/v1/crates/{}/{}",
target.package_name, target.version
);
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to query crates.io: {}", e))?;
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
return Err(format!(
"crates.io rejected the visibility lookup for {}@{} with HTTP 403. \
Ensure XBP sends a descriptive User-Agent (crates.io requires one). \
If cargo already printed `Published`, the crate is live — this is a probe failure, not a failed upload.",
target.package_name, target.version
));
}
Ok(classify_registry_http_status(status))
}
}
}
async fn registry_version_exists(target: &PreparedPublishTarget) -> Result<bool, String> {
Ok(matches!(
registry_version_lookup(target).await?,
RegistryLookup::Found
))
}
async fn maybe_attach_workspace_publish_resolution(
target: &mut PreparedPublishTarget,
options: &PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<(), String> {
if target.kind != PublishKind::Crates {
return Ok(());
}
let mut resolution =
resolve_manifest_workspace_publish(&target.manifest_path, options.include_prereqs).await?;
if options.auto_fix {
if let Some(healed) =
maybe_auto_fix_workspace_publish_surface(target, options, progress, &resolution).await?
{
resolution = healed;
}
}
let missing_prereqs = collect_missing_workspace_prereqs(&resolution);
let needs_expansion = resolution.publish_order.len() > 1;
if missing_prereqs.is_empty() && !needs_expansion {
let blockers = collect_workspace_publish_blockers(&resolution);
if !blockers.is_empty() {
return Err(render_workspace_publish_blocker_error(
target,
&resolution,
&blockers,
options,
));
}
return Ok(());
}
if !supports_workspace_auto_resolution(&target.publish_command) {
if delegates_workspace_publish(&target.publish_command) {
progress.log(&format!(
"Using configured workspace publish delegate for {} ({} package closure); running publish command as-is.",
target.identity(),
resolution.publish_order.len()
));
return Ok(());
}
return Err(render_custom_workspace_publish_error(target, &resolution));
}
if !missing_prereqs.is_empty() && !options.include_prereqs {
return Err(render_workspace_prereq_required_error(
target,
&resolution,
&missing_prereqs,
options,
));
}
let blockers = collect_workspace_publish_blockers(&resolution);
if !blockers.is_empty() {
return Err(render_workspace_publish_blocker_error(
target,
&resolution,
&blockers,
options,
));
}
if !resolution.included_prereqs.is_empty() {
progress.log(&format!(
"Resolved workspace prerequisite closure for {}: {}",
target.identity(),
resolution.required_closure.join(" -> ")
));
}
target.workspace_publish = Some(resolution);
Ok(())
}
async fn maybe_auto_fix_workspace_publish_surface(
target: &PreparedPublishTarget,
options: &PublishCommandOptions,
progress: &PublishProgress<'_>,
resolution: &ManifestWorkspacePublishResolution,
) -> Result<Option<ManifestWorkspacePublishResolution>, String> {
let blockers = collect_workspace_publish_blockers(resolution);
if blockers.is_empty() {
return Ok(None);
}
let looks_surface = blockers.iter().any(|blocker| {
blocker.contains("publish disabled")
|| blocker.contains("missing version pins")
|| blocker.contains("auto-fix")
}) || resolution.packages.iter().any(|item| {
!item.publishable
|| item.reason.contains("publish disabled")
|| item.reason.contains("missing version pins")
});
if !looks_surface {
return Ok(None);
}
let expected = options
.expected_version
.clone()
.or_else(|| Some(target.version.clone()))
.ok_or_else(|| "Missing expected version for publish-surface auto-fix.".to_string())?;
let expected_version = parse_version(&expected)?;
let only = Some(resolution.requested_package.as_str());
progress.log(&format!(
"Auto-fixing crates.io publish surface for {}@{}…",
resolution.requested_package, expected
));
let files = heal_publish_surface_for_release(
&resolution.workspace_root,
&expected_version,
only,
)?;
if files.is_empty() {
progress.log("Publish-surface auto-fix found nothing to write.");
return Ok(None);
}
progress.log(&format!(
"Publish-surface auto-fix updated {} file(s): {}",
files.len(),
files.join(", ")
));
let healed =
resolve_manifest_workspace_publish(&target.manifest_path, options.include_prereqs).await?;
Ok(Some(healed))
}
fn collect_workspace_publish_blockers(
resolution: &ManifestWorkspacePublishResolution,
) -> Vec<String> {
let blocking_deps = resolution
.packages
.iter()
.filter(|item| item.crates_io_visible != Some(true) && !item.blocked_by.is_empty())
.flat_map(|item| item.blocked_by.iter().cloned())
.collect::<std::collections::BTreeSet<_>>();
resolution
.packages
.iter()
.filter(|item| {
if item.crates_io_visible == Some(true) {
return false;
}
if !item.blocked_by.is_empty() {
return true;
}
if !item.publishable {
return blocking_deps.contains(&item.package);
}
item.reason.contains("missing version pins")
|| item.reason.contains("publish excluded")
})
.map(|item| {
if !item.blocked_by.is_empty() {
format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
} else {
format!("{} {}", item.package, item.reason)
}
})
.collect()
}
fn collect_missing_workspace_prereqs(
resolution: &ManifestWorkspacePublishResolution,
) -> Vec<String> {
let mut missing = Vec::new();
for item in &resolution.packages {
for dependency in &item.blocked_by {
if !missing.contains(dependency) {
missing.push(dependency.clone());
}
}
}
missing
}
fn render_workspace_publish_blocker_error(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
blockers: &[String],
options: &PublishCommandOptions,
) -> String {
let mut message = String::new();
message.push_str(&format!(
"Workspace publish is blocked for {}.\n",
target.identity()
));
message.push_str(&format!(
"Requested package: {}\n",
resolution.requested_package
));
if !resolution.included_prereqs.is_empty() {
message.push_str(&format!(
"Auto-included prerequisites: {}\n",
resolution.included_prereqs.join(", ")
));
}
if !resolution.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
resolution.required_closure.join(" -> ")
));
}
message.push_str("Blockers:\n");
for blocker in blockers {
message.push_str(&format!("- {}\n", blocker));
}
message.push_str("Inspect with:\n");
message.push_str(&format!(
" {}\n",
render_workspace_publish_plan_command(resolution, options)
));
message.push_str("Auto-fix publish-surface issues with:\n");
message.push_str(&format!(
" xbp version workspace publish heal --repo {} --only {} --include-prereqs --write\n",
quote_path_for_message(&resolution.workspace_root),
resolution.requested_package
));
if !options.auto_fix {
message.push_str("Or re-run with publish auto-fix enabled (release uses this by default).\n");
}
message.trim_end().to_string()
}
fn render_workspace_prereq_required_error(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
missing_prereqs: &[String],
options: &PublishCommandOptions,
) -> String {
let mut message = String::new();
message.push_str(&format!(
"Workspace prerequisite closure detected for {}.\n",
target.identity()
));
message.push_str(&format!(
"Requested package: {}\n",
resolution.requested_package
));
if !resolution.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
resolution.required_closure.join(" -> ")
));
}
if !missing_prereqs.is_empty() {
message.push_str(&format!(
"Missing internal prerequisites: {}\n",
missing_prereqs.join(", ")
));
}
message.push_str("Rerun with:\n");
message.push_str(&format!(
" {}\n",
render_publish_include_prereqs_command(target, options)
));
message.push_str("Or inspect with:\n");
message.push_str(&format!(
" {}\n",
render_workspace_publish_plan_command(resolution, options)
));
message.trim_end().to_string()
}
fn render_custom_workspace_publish_error(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
) -> String {
let mut message = String::new();
message.push_str(&format!(
"Workspace closure detected for {}, but the configured crates publish command cannot be safely expanded.\n",
target.identity()
));
message.push_str(&format!(
"Configured publish command: {}\n",
target.publish_command
));
message.push_str(&format!(
"Requested package: {}\n",
resolution.requested_package
));
if !resolution.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
resolution.required_closure.join(" -> ")
));
}
message.push_str("Use the workspace publish workflow instead:\n");
message.push_str(&format!(
" xbp version workspace publish plan --repo {} --only {} --include-prereqs\n",
quote_path_for_message(&resolution.workspace_root),
resolution.requested_package
));
message.push_str(&format!(
" xbp version workspace publish run --repo {} --only {} --include-prereqs",
quote_path_for_message(&resolution.workspace_root),
resolution.requested_package
));
message
}
fn delegates_workspace_publish(command: &str) -> bool {
let normalized = command.to_ascii_lowercase();
normalized.contains("publish_workspace.py")
|| normalized.contains("version workspace publish run")
}
fn supports_workspace_auto_resolution(command: &str) -> bool {
let normalized = command.trim();
if normalized.is_empty() {
return false;
}
if normalized.contains("&&")
|| normalized.contains("||")
|| normalized.contains(';')
|| normalized.contains('|')
|| normalized.contains('\n')
|| normalized.contains('\r')
|| normalized.contains('`')
{
return false;
}
if normalized.contains("--manifest-path") {
return false;
}
is_cargo_publish_command(normalized)
}
async fn run_workspace_publish_target(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
options: &PublishCommandOptions,
progress: Option<&PublishProgress<'_>>,
) -> Result<Vec<PackagePublishOutcome>, String> {
if let Some(progress) = progress {
progress.log(&format!(
"Publishing workspace closure for {} in order: {}",
target.identity(),
resolution.required_closure.join(" -> ")
));
}
let total = resolution.publish_order.len();
let mut outcomes = Vec::new();
for (index, publish_target) in resolution.publish_order.iter().enumerate() {
let mut step_target = target.clone();
step_target.package_name = publish_target.package.clone();
step_target.version = publish_target.version.clone();
step_target.manifest_path = publish_target.manifest_path.clone();
step_target.workspace_publish = None;
if registry_version_exists(&step_target).await? {
if let Some(progress) = progress {
progress.log(&format!(
"Skipping {}@{} — already visible on crates.io.",
step_target.package_name, step_target.version
));
}
outcomes.push(PackagePublishOutcome {
package: step_target.package_name.clone(),
version: step_target.version.clone(),
kind: step_target.kind.wire_label().to_string(),
status: "skipped_already_visible".to_string(),
message: Some("already visible on crates.io".to_string()),
});
continue;
}
if let Err(error) = run_publish_target(&step_target, options, progress).await {
if should_retry_cargo_publish_no_verify(&step_target, &error) {
if let Some(progress) = progress {
progress.log(&format!(
"Retrying {}@{} with --no-verify after local verification failed.",
step_target.package_name, step_target.version
));
}
run_publish_target_with_no_verify(&step_target, options, progress).await?;
} else {
return Err(error);
}
}
let is_last = index + 1 == total;
wait_for_registry_version_with_mode(
&step_target,
step_target.visibility_timeout,
step_target.visibility_poll,
is_last,
)
.await?;
outcomes.push(PackagePublishOutcome {
package: step_target.package_name.clone(),
version: step_target.version.clone(),
kind: step_target.kind.wire_label().to_string(),
status: "published".to_string(),
message: None,
});
}
Ok(outcomes)
}
fn should_retry_cargo_publish_no_verify(target: &PreparedPublishTarget, error: &str) -> bool {
if target.kind != PublishKind::Crates || !target.retry_no_verify {
return false;
}
if target
.no_verify_retry_packages
.iter()
.any(|name| name == &target.package_name)
{
return true;
}
if target.package_name == "athena_rs" {
return true;
}
let lower = error.to_ascii_lowercase();
lower.contains("failed to verify package tarball")
|| lower.contains("verification failed")
|| lower.contains("failed to compile")
&& (lower.contains("package") || lower.contains("tarball"))
}
async fn run_publish_target_with_no_verify(
target: &PreparedPublishTarget,
options: &PublishCommandOptions,
progress: Option<&PublishProgress<'_>>,
) -> Result<(), String> {
let token = target.token.clone().ok_or_else(|| {
"No crates.io token resolved for --no-verify retry.".to_string()
})?;
let mut target = target.clone();
target.publish_command = "cargo publish --no-verify".to_string();
if let Some(mut spec) = build_direct_cargo_publish_spec(&target, options.allow_dirty, &token) {
if !spec.args.iter().any(|arg| arg == "--no-verify") {
spec.args.push("--no-verify".to_string());
}
if !spec.display_command.contains("--no-verify") {
spec.display_command.push_str(" --no-verify");
}
let result = run_direct_command(spec, &target, progress, CommandStage::Publish).await?;
if !result.success {
return Err(format!(
"cargo publish --no-verify failed for {}@{}.\n{}",
target.package_name,
target.version,
result.output_detail()
));
}
return Ok(());
}
run_publish_target(&target, options, progress).await
}
async fn wait_for_registry_version_with_mode(
target: &PreparedPublishTarget,
timeout: Duration,
poll: Duration,
soft_on_timeout: bool,
) -> Result<(), String> {
let deadline = Instant::now() + timeout;
let mut saw_retryable = false;
loop {
match registry_version_lookup(target).await? {
RegistryLookup::Found => return Ok(()),
RegistryLookup::Retryable => {
saw_retryable = true;
}
RegistryLookup::NotFound => {}
}
if Instant::now() >= deadline {
let hint = if saw_retryable {
" Registry probes hit transient errors (rate limit / 5xx); the upload may already be live."
} else {
""
};
let message = format!(
"{}@{} was published, but did not become visible on {} within {}s.{}",
target.package_name,
target.version,
target.kind.label(),
timeout.as_secs(),
hint
);
if soft_on_timeout {
eprintln!(
"{} {}",
"Warning:".bright_yellow().bold(),
format!(
"registry visibility still pending for {} — continuing because cargo already reported a successful publish.{}",
target.identity(),
if target.kind == PublishKind::Crates {
" Check https://crates.io/crates/".to_string()
+ &target.package_name
} else {
String::new()
}
)
.bright_white()
);
return Ok(());
}
return Err(format!(
"{} {}",
"Timed out waiting for registry visibility:"
.bright_red()
.bold(),
message
));
}
sleep(poll).await;
}
}
fn render_publish_include_prereqs_command(
target: &PreparedPublishTarget,
options: &PublishCommandOptions,
) -> String {
let mut parts = vec!["xbp publish".to_string(), "--target crates".to_string()];
if options.allow_dirty {
parts.push("--allow-dirty".to_string());
}
if options.force {
parts.push("--force".to_string());
}
parts.push("--include-prereqs".to_string());
parts.push(format!(
"--manifest-path {}",
quote_path_for_message(&target.manifest_path)
));
parts.join(" ")
}
fn render_workspace_publish_plan_command(
resolution: &ManifestWorkspacePublishResolution,
options: &PublishCommandOptions,
) -> String {
let mut parts = vec![
"xbp version workspace publish plan".to_string(),
format!(
"--repo {}",
quote_path_for_message(&resolution.workspace_root)
),
format!("--only {}", resolution.requested_package),
];
if options.include_prereqs || resolution.required_closure.len() > 1 {
parts.push("--include-prereqs".to_string());
}
parts.join(" ")
}
fn quote_path_for_message(path: &Path) -> String {
let value = path.to_string_lossy();
if value.contains(' ') {
format!("\"{}\"", value)
} else {
value.to_string()
}
}
async fn run_publish_target(
target: &PreparedPublishTarget,
options: &PublishCommandOptions,
progress: Option<&PublishProgress<'_>>,
) -> Result<(), String> {
match target.kind {
PublishKind::Npm => {
let mut token_auth = None;
let mut failures = Vec::new();
let mut authenticated_user = None;
for mode in npm_auth_attempts(target.token.is_some()) {
if mode == NpmAuthMode::Token {
let token = target
.token
.clone()
.expect("token auth attempt requires a resolved token");
if token_auth.is_none() {
let npmrc = TemporaryNpmrc::create_with_token(&token)?;
let env = build_npm_publish_env(token, &npmrc);
token_auth = Some((npmrc, env));
}
if let Some(progress) = progress {
progress.log(
"Existing npm login did not pass publish preflight; retrying with the resolved XBP/npm token (process env, project .env, or global config).",
);
}
}
let extra_env = match mode {
NpmAuthMode::Ambient => None,
NpmAuthMode::Token => token_auth.as_ref().map(|(_, env)| env),
};
match run_npm_publish_preflight(target, extra_env, progress).await {
Ok(user) => {
authenticated_user = Some((user, mode));
break;
}
Err(error) => failures.push((mode, error)),
}
}
let (authenticated_user, auth_mode) = authenticated_user.ok_or_else(|| {
failures
.into_iter()
.map(|(mode, error)| format!("{} credentials:\n{}", mode.label(), error))
.collect::<Vec<_>>()
.join("\n\n")
})?;
let publish_env = match auth_mode {
NpmAuthMode::Ambient => None,
NpmAuthMode::Token => token_auth.as_ref().map(|(_, env)| env),
};
if is_standard_npm_publish_command(&target.publish_command) {
return run_npm_pack_then_publish(
target,
publish_env,
progress,
Some(&authenticated_user),
)
.await;
}
let result = run_configured_command(
&target.publish_command,
&target.publish_command,
target,
publish_env,
progress,
CommandStage::Publish,
)
.await?;
if result.success {
return Ok(());
}
if is_npm_workspace_arborist_failure(&result) {
if let Some(progress) = progress {
progress.log(
"npm publish hit a workspace arborist failure; retrying via pack-then-publish (tarball).",
);
}
return run_npm_pack_then_publish(
target,
publish_env,
progress,
Some(&authenticated_user),
)
.await;
}
Err(format_npm_command_failure(
NpmCommandKind::Publish,
target,
Some(&authenticated_user),
&result,
))
}
PublishKind::Crates => {
let token = target.token.clone().ok_or_else(|| {
"No crates.io token resolved. Set `publish.crates.token: ${CARGO_REGISTRY_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config crates set-key`.".to_string()
})?;
let uses_workspace_delegate = delegates_workspace_publish(&target.publish_command);
if let Some(spec) = build_direct_cargo_publish_spec(target, options.allow_dirty, &token)
{
let result =
run_direct_command(spec, target, progress, CommandStage::Publish).await?;
if !result.success {
return Err(format!(
"cargo publish failed for {}@{}.\n{}",
target.package_name,
target.version,
result.output_detail()
));
}
return Ok(());
}
let publish_display_command =
append_allow_dirty_arg(&render_publish_command(target), options.allow_dirty);
let mut env = HashMap::new();
env.insert("CARGO_REGISTRY_TOKEN".to_string(), token.clone());
env.insert("CARGO_REGISTRIES_CRATES_IO_TOKEN".to_string(), token);
let result = run_configured_command(
&publish_display_command,
&publish_display_command,
target,
Some(&env),
progress,
CommandStage::Publish,
)
.await?;
if !result.success {
let action = if uses_workspace_delegate {
"workspace publish command failed"
} else {
"cargo publish failed"
};
return Err(format!(
"{} for {}@{}.\n{}",
action,
target.package_name,
target.version,
result.output_detail()
));
}
Ok(())
}
}
}
fn build_npm_publish_env(token: String, npmrc: &TemporaryNpmrc) -> HashMap<String, String> {
let mut extra_env = HashMap::new();
extra_env.insert("NPM_TOKEN".to_string(), token.clone());
extra_env.insert("NODE_AUTH_TOKEN".to_string(), token);
extra_env.insert(
"NPM_CONFIG_USERCONFIG".to_string(),
npmrc.path.to_string_lossy().to_string(),
);
extra_env.insert("npm_config_yes".to_string(), "true".to_string());
extra_env
}
fn npm_auth_attempts(has_token: bool) -> Vec<NpmAuthMode> {
let mut attempts = vec![NpmAuthMode::Ambient];
if has_token {
attempts.push(NpmAuthMode::Token);
}
attempts
}
impl NpmAuthMode {
fn label(self) -> &'static str {
match self {
Self::Ambient => "existing npm login",
Self::Token => "configured XBP token",
}
}
}
async fn run_npm_publish_preflight(
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
progress: Option<&PublishProgress<'_>>,
) -> Result<String, String> {
let whoami_result = run_direct_npm_command(
&["whoami"],
"npm whoami",
target,
extra_env,
progress,
CommandStage::Preflight,
)
.await?;
if !whoami_result.success {
return Err(format_npm_command_failure(
NpmCommandKind::Whoami,
target,
None,
&whoami_result,
));
}
let authenticated_user =
parse_npm_whoami_username(&whoami_result.stdout).ok_or_else(|| {
format!(
"npm auth preflight failed for {}@{}.\n`npm whoami` did not return a username. \
Update the token with `xbp config npm set-key`, put NPM_TOKEN in `.env` / `.env.local`, \
or export NPM_TOKEN / NODE_AUTH_TOKEN, then verify with `npm whoami`.\n\n{}\n\n{}",
target.package_name,
target.version,
whoami_result.output_detail(),
format_npm_token_source_hint(&target.working_directory)
)
})?;
let owner_display = format!("npm owner ls {}", target.package_name);
let owner_result = run_direct_npm_command(
&["owner", "ls", target.package_name.as_str()],
&owner_display,
target,
extra_env,
progress,
CommandStage::Preflight,
)
.await?;
match classify_npm_owner_permission(&owner_result, &authenticated_user) {
NpmOwnerPermission::Authorized => Ok(authenticated_user),
NpmOwnerPermission::PackageMissing => {
if let Some(progress) = progress {
progress.log(&format!(
"npm owner preflight could not confirm existing owners for {}. Continuing because the package may not exist yet.",
target.package_name
));
}
Ok(authenticated_user)
}
NpmOwnerPermission::Unauthorized { owners } => Err(format!(
"npm publish access preflight failed for {}@{}.\nAuthenticated as npm user `{}`, but that user does not appear in `npm owner ls` for this package.\nCurrent owners: {}.\nVerify package ownership or team access on npm, or update the token with `xbp config npm set-key` if it belongs to a different account.\n\n{}",
target.package_name,
target.version,
authenticated_user,
owners.join(", "),
owner_result.output_detail()
)),
NpmOwnerPermission::Indeterminate => {
if !owner_result.success {
return Err(format_npm_command_failure(
NpmCommandKind::OwnerLs,
target,
Some(&authenticated_user),
&owner_result,
));
}
Err(format!(
"npm publish access preflight failed for {}@{}.\nAuthenticated as npm user `{}`, but XBP could not determine package owners from `npm owner ls` output.\n\n{}",
target.package_name,
target.version,
authenticated_user,
owner_result.output_detail()
))
}
}
}
fn is_standard_npm_publish_command(command: &str) -> bool {
let core = strip_leading_shell_env_exports(command.trim());
let lower = core.to_ascii_lowercase();
lower == "npm publish" || lower.starts_with("npm publish ")
}
fn is_npm_workspace_arborist_failure(result: &CommandResult) -> bool {
let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
(combined.contains("cannot read properties of null")
&& (combined.contains("reading 'package'") || combined.contains("reading \"package\"")))
|| (combined.contains("arborist") && combined.contains("reading 'package'"))
}
fn strip_leading_shell_env_exports(command: &str) -> String {
let mut rest = command.trim().to_string();
loop {
let lower = rest.to_ascii_lowercase();
if let Some(stripped) = lower.strip_prefix("export ") {
if let Some(sep) = stripped.find(';') {
rest = rest[("export ".len() + sep + 1)..].trim().to_string();
continue;
}
}
if let Some(stripped) = lower.strip_prefix("set ") {
if let Some(sep) = stripped.find("&&") {
rest = rest[("set ".len() + sep + 2)..].trim().to_string();
continue;
}
}
break;
}
rest
}
fn npm_publish_extra_args(command: &str) -> Vec<String> {
let core = strip_leading_shell_env_exports(command.trim());
let tokens = shell_tokenize_publish_args(&core);
if tokens.len() < 2 {
return Vec::new();
}
if !tokens[0].eq_ignore_ascii_case("npm") || !tokens[1].eq_ignore_ascii_case("publish") {
return Vec::new();
}
tokens.into_iter().skip(2).collect()
}
fn shell_tokenize_publish_args(command: &str) -> Vec<String> {
command
.split_whitespace()
.map(str::to_string)
.collect()
}
fn expected_npm_tarball_filename(package_name: &str, version: &str) -> String {
let base = package_name.trim_start_matches('@').replace('/', "-");
format!("{base}-{version}.tgz")
}
async fn run_npm_pack_then_publish(
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
progress: Option<&PublishProgress<'_>>,
authenticated_user: Option<&str>,
) -> Result<(), String> {
let tarball_name = expected_npm_tarball_filename(&target.package_name, &target.version);
let tarball_path = target.working_directory.join(&tarball_name);
if tarball_path.is_file() {
let _ = fs::remove_file(&tarball_path);
}
if let Some(progress) = progress {
progress.log(&format!(
"Packing {}@{} for workspace-safe npm publish ({})",
target.package_name, target.version, tarball_name
));
}
let pack_result = run_npm_pack_command(target, extra_env, progress).await?;
if !pack_result.success {
return Err(format!(
"npm pack failed for {}@{}.\n{}",
target.package_name,
target.version,
pack_result.output_detail()
));
}
let resolved_tarball = if tarball_path.is_file() {
tarball_path.clone()
} else {
resolve_packed_tarball(&target.working_directory, &tarball_name).ok_or_else(|| {
format!(
"Pack succeeded for {}@{} but tarball `{}` was not found in {}.\n{}",
target.package_name,
target.version,
tarball_name,
target.working_directory.display(),
pack_result.output_detail()
)
})?
};
let mut publish_args = vec![
"publish".to_string(),
resolved_tarball
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| tarball_name.clone()),
];
let mut extra = npm_publish_extra_args(&target.publish_command);
if target.package_name.starts_with('@')
&& !extra
.iter()
.any(|a| a.eq_ignore_ascii_case("--access") || a.eq_ignore_ascii_case("-access"))
{
extra.push("--access".to_string());
extra.push("public".to_string());
}
publish_args.extend(extra);
let display = format!("npm {}", publish_args.join(" "));
let arg_refs: Vec<&str> = publish_args.iter().map(String::as_str).collect();
let publish_result = run_direct_npm_command(
&arg_refs,
&display,
target,
extra_env,
progress,
CommandStage::Publish,
)
.await;
let _ = fs::remove_file(&resolved_tarball);
let publish_result = publish_result?;
if !publish_result.success {
return Err(format_npm_command_failure(
NpmCommandKind::Publish,
target,
authenticated_user,
&publish_result,
));
}
Ok(())
}
async fn run_npm_pack_command(
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
progress: Option<&PublishProgress<'_>>,
) -> Result<CommandResult, String> {
if command_exists("bun") {
if let Some(progress) = progress {
progress.log(&format!(
"Running pack for {} via bun pm pack",
target.identity()
));
}
let mut command = Command::new("bun");
command
.current_dir(&target.working_directory)
.args(["pm", "pack"]);
if let Some(extra_env) = extra_env {
command.envs(extra_env);
}
let result = run_spawned_command(
command,
"bun pm pack",
"bun pm pack",
target,
progress,
CommandStage::Publish,
)
.await?;
if result.success {
return Ok(result);
}
if let Some(progress) = progress {
progress.log("bun pm pack failed; falling back to npm pack");
}
}
run_direct_npm_command(
&["pack"],
"npm pack",
target,
extra_env,
progress,
CommandStage::Publish,
)
.await
}
fn resolve_packed_tarball(dir: &Path, preferred_name: &str) -> Option<PathBuf> {
let preferred = dir.join(preferred_name);
if preferred.is_file() {
return Some(preferred);
}
let mut candidates = Vec::new();
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("tgz"))
{
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
candidates.push((modified, path));
}
}
}
}
candidates.sort_by_key(|(modified, _)| *modified);
candidates.pop().map(|(_, path)| path)
}
async fn run_direct_npm_command(
args: &[&str],
display_command: &str,
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
progress: Option<&PublishProgress<'_>>,
stage: CommandStage,
) -> Result<CommandResult, String> {
if target.use_wsl {
let shell_line = format!(
"npm {}",
args.iter()
.map(|arg| quote_for_shell(arg, true))
.collect::<Vec<_>>()
.join(" ")
);
return run_configured_command(
&shell_line,
display_command,
target,
extra_env,
progress,
stage,
)
.await;
}
if !command_exists("npm") {
return Err(
"npm was not found on PATH. Install Node.js/npm or fix PATH before publishing."
.to_string(),
);
}
if let Some(progress) = progress {
progress.log(&format!(
"Running {} for {} via direct npm: {}",
stage.label(),
target.identity(),
display_command
));
}
let mut command = Command::new("npm");
command
.current_dir(&target.working_directory)
.args(args);
if let Some(extra_env) = extra_env {
command.envs(extra_env);
}
run_spawned_command(
command,
display_command,
display_command,
target,
progress,
stage,
)
.await
}
fn format_npm_token_source_hint(start_dir: &Path) -> String {
let mut roots = vec![start_dir.to_path_buf()];
if let Some(parent) = start_dir.parent() {
roots.push(parent.to_path_buf());
}
let search = roots
.into_iter()
.find(|p| p.join(".xbp").is_dir())
.unwrap_or_else(|| start_dir.to_path_buf());
let lines = describe_npm_token_sources(Some(&search));
format!(
"Token sources checked:\n{}",
lines
.into_iter()
.map(|line| format!(" • {line}"))
.collect::<Vec<_>>()
.join("\n")
)
}
async fn run_configured_command(
command_line: &str,
display_command: &str,
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
progress: Option<&PublishProgress<'_>>,
stage: CommandStage,
) -> Result<CommandResult, String> {
if let Some(progress) = progress {
progress.log(&format!(
"Running {} for {} via {}: {}",
stage.label(),
target.identity(),
target.runner_label(),
display_command
));
}
let mut effective_command_line = if target.use_wsl {
extra_env
.map(|env| prepend_shell_exports(command_line, env))
.unwrap_or_else(|| command_line.to_string())
} else {
command_line.to_string()
};
let use_posix_rules = target.use_wsl || !cfg!(target_os = "windows");
effective_command_line =
ensure_non_interactive_publish_command(&effective_command_line, use_posix_rules, stage);
if effective_command_line != command_line {
if let Some(progress) = progress {
let normalized_display = extra_env
.map(|env| redact_env_values(&effective_command_line, env))
.unwrap_or_else(|| effective_command_line.clone());
progress.log(&format!(
"Normalized {} command for non-interactive execution: {}",
stage.label(),
normalized_display
));
}
}
let mut command = build_shell_command(
&effective_command_line,
&target.working_directory,
target.use_wsl,
target.wsl_distribution.as_deref(),
)?;
if !target.use_wsl {
if let Some(extra_env) = extra_env {
command.envs(extra_env);
}
}
run_spawned_command(
command,
command_line,
display_command,
target,
progress,
stage,
)
.await
}
async fn run_direct_command(
spec: DirectCommandSpec,
target: &PreparedPublishTarget,
progress: Option<&PublishProgress<'_>>,
stage: CommandStage,
) -> Result<CommandResult, String> {
if let Some(progress) = progress {
progress.log(&format!(
"Running {} for {} via direct process: {}",
stage.label(),
target.identity(),
spec.display_command
));
}
let mut command = Command::new(&spec.program);
command
.current_dir(&target.working_directory)
.args(&spec.args)
.envs(&spec.env);
run_spawned_command(
command,
&spec.display_command,
&spec.display_command,
target,
progress,
stage,
)
.await
}
async fn run_spawned_command(
mut command: Command,
command_line: &str,
display_command: &str,
target: &PreparedPublishTarget,
progress: Option<&PublishProgress<'_>>,
stage: CommandStage,
) -> Result<CommandResult, String> {
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("Failed to run `{}`: {}", command_line, e))?;
let child_stdout = child
.stdout
.take()
.ok_or_else(|| format!("Failed to capture stdout for `{}`.", command_line))?;
let child_stderr = child
.stderr
.take()
.ok_or_else(|| format!("Failed to capture stderr for `{}`.", command_line))?;
let analysis = Arc::new(Mutex::new(CommandStreamAnalysis::default()));
let (watch_tx, mut watch_rx) = watch::channel(false);
let stdout_capture = tokio::spawn(mirror_command_stream(
child_stdout,
stdout(),
CommandStreamKind::Stdout,
Arc::clone(&analysis),
watch_tx.clone(),
));
let stderr_capture = tokio::spawn(mirror_command_stream(
child_stderr,
stderr(),
CommandStreamKind::Stderr,
Arc::clone(&analysis),
watch_tx,
));
let started_at = Instant::now();
let mut terminated_watch_mode = false;
let status = if let Some(progress) = progress {
let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
let mut poll = tokio::time::interval(Duration::from_millis(250));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
poll.set_missed_tick_behavior(MissedTickBehavior::Delay);
heartbeat.tick().await;
poll.tick().await;
loop {
tokio::select! {
changed = watch_rx.changed() => {
if changed.is_ok() && *watch_rx.borrow_and_update() && !terminated_watch_mode {
terminated_watch_mode = true;
progress.log(&format!(
"Detected watch-mode test runner during {}; terminating so publish can continue.",
stage.label()
));
sleep(PUBLISH_WATCH_MODE_GRACE).await;
let _ = child.kill().await;
}
}
_ = heartbeat.tick() => {
progress.log(&format!(
"Still running {} for {} via {} (elapsed {}): {}",
stage.label(),
target.identity(),
target.runner_label(),
format_elapsed(started_at.elapsed()),
display_command
));
}
_ = poll.tick() => {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(error) => {
return Err(format!("Failed to wait for `{}`: {}", command_line, error));
}
}
}
}
}
} else {
let mut poll = tokio::time::interval(Duration::from_millis(250));
poll.set_missed_tick_behavior(MissedTickBehavior::Delay);
poll.tick().await;
loop {
tokio::select! {
changed = watch_rx.changed() => {
if changed.is_ok() && *watch_rx.borrow_and_update() && !terminated_watch_mode {
terminated_watch_mode = true;
sleep(PUBLISH_WATCH_MODE_GRACE).await;
let _ = child.kill().await;
}
}
_ = poll.tick() => {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(error) => {
return Err(format!("Failed to wait for `{}`: {}", command_line, error));
}
}
}
}
}
};
stdout_capture.await.map_err(|e| {
format!(
"Failed to join stdout capture for `{}`: {}",
command_line, e
)
})??;
stderr_capture.await.map_err(|e| {
format!(
"Failed to join stderr capture for `{}`: {}",
command_line, e
)
})??;
let captured = analysis.lock().await.clone();
if let Some(progress) = progress {
progress.log(&format!(
"Completed {} for {} in {}.",
stage.label(),
target.identity(),
format_elapsed(started_at.elapsed())
));
}
let result = CommandResult {
success: infer_command_success(status.success(), &captured.stdout, &captured.stderr),
exit_code: status.code(),
stdout: captured.stdout,
stderr: captured.stderr,
};
persist_command_transcript(
stage,
target,
display_command,
started_at.elapsed(),
&result,
)
.await;
Ok(result)
}
#[derive(Debug, Clone, Copy)]
enum CommandStreamKind {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Default)]
struct CommandStreamAnalysis {
stdout: String,
stderr: String,
}
async fn mirror_command_stream<R, W>(
mut reader: R,
mut writer: W,
stream: CommandStreamKind,
analysis: Arc<Mutex<CommandStreamAnalysis>>,
watch_tx: watch::Sender<bool>,
) -> Result<(), String>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut buffer = [0_u8; 8192];
loop {
let read_len = reader
.read(&mut buffer)
.await
.map_err(|e| format!("Failed to read command output: {e}"))?;
if read_len == 0 {
break;
}
writer
.write_all(&buffer[..read_len])
.await
.map_err(|e| format!("Failed to write command output: {e}"))?;
writer
.flush()
.await
.map_err(|e| format!("Failed to flush command output: {e}"))?;
let chunk = String::from_utf8_lossy(&buffer[..read_len]);
let mut guard = analysis.lock().await;
match stream {
CommandStreamKind::Stdout => guard.stdout.push_str(&chunk),
CommandStreamKind::Stderr => guard.stderr.push_str(&chunk),
}
if detect_watch_mode_in_output(&chunk)
|| detect_watch_mode_in_output(&tail_str(
match stream {
CommandStreamKind::Stdout => &guard.stdout,
CommandStreamKind::Stderr => &guard.stderr,
},
WATCH_MODE_SCAN_TAIL_BYTES,
))
{
let _ = watch_tx.send(true);
}
}
Ok(())
}
fn tail_str(value: &str, max_bytes: usize) -> &str {
if value.len() <= max_bytes {
return value;
}
let mut start = value.len() - max_bytes;
while start < value.len() && !value.is_char_boundary(start) {
start += 1;
}
&value[start..]
}
fn truncate_for_transcript(value: &str, max_chars: usize) -> String {
let char_count = value.chars().count();
if char_count <= max_chars {
return value.to_string();
}
let omitted = char_count.saturating_sub(max_chars);
let head_chars = max_chars / 2;
let tail_chars = max_chars.saturating_sub(head_chars);
let head: String = value.chars().take(head_chars).collect();
let tail: String = value
.chars()
.rev()
.take(tail_chars)
.collect::<String>()
.chars()
.rev()
.collect();
format!(
"{head}\n\n… truncated {omitted} characters of process output for logging …\n\n{tail}"
)
}
fn ensure_non_interactive_publish_command(
command: &str,
use_posix_rules: bool,
stage: CommandStage,
) -> String {
let trimmed = command.trim();
if trimmed.is_empty() {
return trimmed.to_string();
}
let augmented = if stage == CommandStage::Preflight {
augment_test_runner_for_single_run(trimmed)
} else {
trimmed.to_string()
};
if has_ci_mode_enabled(&augmented) {
return augmented;
}
if use_posix_rules {
format!("export CI=1; {augmented}")
} else {
format!("set CI=1&& {augmented}")
}
}
fn has_ci_mode_enabled(command: &str) -> bool {
let lower = command.to_ascii_lowercase();
lower.contains("ci=1")
|| lower.contains("ci=true")
|| lower.contains("ci=\"1\"")
|| lower.contains("ci='1'")
}
fn augment_test_runner_for_single_run(command: &str) -> String {
if command.contains("&&") {
return command
.split("&&")
.map(|segment| augment_test_runner_segment(segment.trim()))
.collect::<Vec<_>>()
.join(" && ");
}
augment_test_runner_segment(command)
}
fn augment_test_runner_segment(command: &str) -> String {
let trimmed = command.trim();
if trimmed.is_empty() || test_runner_already_single_run(trimmed) {
return trimmed.to_string();
}
let lower = trimmed.to_ascii_lowercase();
if is_package_manager_test_command(trimmed) && !trimmed.contains(" -- ") {
return format!("{trimmed} -- --run");
}
if lower.starts_with("vitest")
|| lower.contains(" vitest ")
|| lower.contains("npx vitest")
|| lower.contains("pnpm vitest")
|| lower.contains("npm exec vitest")
{
return format!("{trimmed} --run");
}
trimmed.to_string()
}
fn test_runner_already_single_run(command: &str) -> bool {
let lower = command.to_ascii_lowercase();
lower.contains("--run")
|| lower.contains(" vitest run")
|| lower.contains(" jest --ci")
|| lower.contains(" jest -ci")
|| lower.contains("--watch=false")
|| lower.contains("--no-watch")
|| lower.contains("test:ci")
|| lower.contains("test --run")
|| lower.contains("test run")
}
fn is_package_manager_test_command(command: &str) -> bool {
let lower = command.to_ascii_lowercase();
[
"pnpm test",
"npm test",
"yarn test",
"bun test",
"bun run test",
"pnpm run test",
"npm run test",
"yarn run test",
]
.iter()
.any(|prefix| lower == *prefix || lower.starts_with(&format!("{prefix} ")))
}
fn detect_watch_mode_in_output(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
lower.contains("watching for file changes")
|| lower.contains("waiting for file changes")
|| lower.contains("press h to show help, press q to quit")
}
fn detect_failure_in_output(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
if [
"[elifecycle]",
"unhandled error",
"unhandled errors",
"fail tests failed",
"tests failed. watching",
"command failed",
"exit status 1",
"exited with code 1",
"error: command",
]
.iter()
.any(|marker| lower.contains(marker))
{
return true;
}
if lower.contains("test files") && lower.contains("failed") {
return true;
}
lower.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with("errors") && trimmed.contains("error") && !trimmed.contains("0 error")
})
}
fn infer_command_success(exit_success: bool, stdout: &str, stderr: &str) -> bool {
let combined = format!("{stdout}\n{stderr}");
if detect_failure_in_output(&combined) {
return false;
}
exit_success
}
fn build_shell_command(
command_line: &str,
working_directory: &Path,
use_wsl: bool,
wsl_distribution: Option<&str>,
) -> Result<Command, String> {
#[cfg(target_os = "windows")]
{
if use_wsl {
if !command_exists("wsl.exe") {
return Err(
"WSL was requested for this publish target, but `wsl.exe` is not available."
.to_string(),
);
}
let mut command = Command::new("wsl.exe");
if let Some(distribution) = wsl_distribution
.map(str::trim)
.filter(|value| !value.is_empty())
{
command.args(["-d", distribution]);
}
let wsl_dir = windows_path_to_wsl(working_directory)?;
let script = format!("cd {} && {}", quote_for_shell(&wsl_dir, true), command_line);
command.args(["sh", "-lc", &script]);
return Ok(command);
}
let mut command = Command::new("cmd");
command
.current_dir(working_directory)
.args(["/C", command_line]);
Ok(command)
}
#[cfg(not(target_os = "windows"))]
{
let _ = use_wsl;
let _ = wsl_distribution;
let mut command = Command::new("sh");
command
.current_dir(working_directory)
.args(["-lc", command_line]);
Ok(command)
}
}
#[cfg(target_os = "windows")]
fn windows_path_to_wsl(path: &Path) -> Result<String, String> {
let rendered = path.to_string_lossy().replace('\\', "/");
let mut chars = rendered.chars();
let drive = chars
.next()
.ok_or_else(|| format!("Could not convert {} to a WSL path.", path.display()))?;
if chars.next() != Some(':') {
return Err(format!(
"Could not convert {} to a WSL path.",
path.display()
));
}
let remainder = chars.as_str().trim_start_matches('/');
Ok(format!("/mnt/{}/{}", drive.to_ascii_lowercase(), remainder))
}
fn render_publish_status(
step_prefix: Option<&str>,
step: usize,
total: usize,
detail: &str,
) -> String {
match step_prefix {
Some(prefix) => format!("{} {}", prefix, detail),
None => format!("[{}/{}] {}", step, total, detail),
}
}
fn print_publish_completion(plan: &PublishPlanResult, dry_run: bool) {
if dry_run {
return;
}
for target in &plan.prepared {
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
for publish_target in &workspace_publish.publish_order {
println!(
"{} {}@{} via {}",
"Published".bright_green().bold(),
publish_target.package.bright_white(),
publish_target.version.bright_white(),
target.kind.label()
);
}
continue;
}
println!(
"{} {}@{} via {}",
"Published".bright_green().bold(),
target.package_name.bright_white(),
target.version.bright_white(),
target.kind.label()
);
}
}
fn format_elapsed(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds == 0 {
"<1s".to_string()
} else if seconds < 60 {
format!("{}s", seconds)
} else {
format!("{}m{}s", seconds / 60, seconds % 60)
}
}
fn print_publish_plan(prepared: &[PreparedPublishTarget], force: bool) {
println!("{}", "Publish plan".bright_cyan().bold());
for target in prepared {
println!(
" {} {}@{}",
target.kind.label().bright_white().bold(),
target.package_name,
target.version
);
println!(" cwd: {}", target.working_directory.display());
println!(" manifest: {}", target.manifest_path.display());
if let Some(preflight_line) = render_preflight_plan(&target.preflight_commands, force) {
println!(" preflight: {}", preflight_line);
}
println!(" publish: {}", render_publish_command(target));
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
println!(" requested: {}", workspace_publish.requested_package);
println!(
" required closure: {}",
workspace_publish.required_closure.join(" -> ")
);
if !workspace_publish.included_prereqs.is_empty() {
println!(
" auto-prereqs: {}",
workspace_publish.included_prereqs.join(", ")
);
}
if !workspace_publish.publish_order.is_empty() {
println!(
" expanded publish: {}",
workspace_publish
.publish_order
.iter()
.map(|item| format!("{}@{}", item.package, item.version))
.collect::<Vec<_>>()
.join(", ")
);
}
}
if target.use_wsl {
println!(
" runner: WSL{}",
target
.wsl_distribution
.as_deref()
.map(|value| format!(" ({})", value))
.unwrap_or_default()
);
}
}
}
fn render_preflight_plan(preflight_commands: &[String], force: bool) -> Option<String> {
if preflight_commands.is_empty() {
return None;
}
let joined = preflight_commands.join(" && ");
if force {
Some(format!("skipped via --force (configured: {})", joined))
} else {
Some(joined)
}
}
async fn persist_command_transcript(
stage: CommandStage,
target: &PreparedPublishTarget,
display_command: &str,
elapsed: Duration,
result: &CommandResult,
) {
let process_name = render_process_log_name(stage, target, display_command);
let summary = render_process_log_summary(target, result);
let level = if result.success {
LogLevel::Info
} else {
LogLevel::Warning
};
let _ = log_file_only(
level,
"publish",
&format!("Completed {}", process_name),
Some(&summary),
Some(elapsed.as_millis() as u64),
)
.await;
let stdout = truncate_for_transcript(&result.stdout, PUBLISH_TRANSCRIPT_MAX_CHARS);
let stderr = truncate_for_transcript(&result.stderr, PUBLISH_TRANSCRIPT_MAX_CHARS);
let _ = log_process_output_file_only("publish", &process_name, &stdout, &stderr).await;
}
fn render_process_log_name(
stage: CommandStage,
target: &PreparedPublishTarget,
display_command: &str,
) -> String {
format!(
"{} {} command `{}`",
stage.label(),
target.identity(),
display_command
)
}
fn render_process_log_summary(target: &PreparedPublishTarget, result: &CommandResult) -> String {
format!(
"runner={} cwd={} success={} exit_code={}",
target.runner_label(),
target.working_directory.display(),
result.success,
result
.exit_code
.map(|value| value.to_string())
.unwrap_or_else(|| "signal".to_string())
)
}
struct CommandResult {
success: bool,
exit_code: Option<i32>,
stdout: String,
stderr: String,
}
impl CommandResult {
fn output_detail(&self) -> String {
match (self.stdout.trim().is_empty(), self.stderr.trim().is_empty()) {
(false, false) => format!("stdout:\n{}\n\nstderr:\n{}", self.stdout, self.stderr),
(false, true) => format!("stdout:\n{}", self.stdout),
(true, false) => format!("stderr:\n{}", self.stderr),
(true, true) => "Command produced no output.".to_string(),
}
}
}
struct TemporaryNpmrc {
path: PathBuf,
}
impl TemporaryNpmrc {
fn create() -> Result<Self, String> {
Self::create_with_token_placeholder()
}
fn create_with_token_placeholder() -> Result<Self, String> {
let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
let body = "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\nalways-auth=true\n";
fs::write(&path, body).map_err(|e| {
format!(
"Failed to create temporary .npmrc {}: {}",
path.display(),
e
)
})?;
Ok(Self { path })
}
fn create_with_token(token: &str) -> Result<Self, String> {
let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
let token = token.trim();
if token.is_empty() {
return Err("Cannot create npm userconfig: token is empty.".to_string());
}
let body = format!(
"//registry.npmjs.org/:_authToken={token}\nalways-auth=true\n@*:registry=https://registry.npmjs.org/\n"
);
fs::write(&path, body).map_err(|e| {
format!(
"Failed to create temporary .npmrc {}: {}",
path.display(),
e
)
})?;
Ok(Self { path })
}
}
impl Drop for TemporaryNpmrc {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl PublishKind {
fn label(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Crates => "crates.io",
}
}
fn wire_label(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Crates => "crates",
}
}
}
impl PreparedPublishTarget {
fn identity(&self) -> String {
format!(
"{} {}@{}",
self.kind.label(),
self.package_name,
self.version
)
}
fn runner_label(&self) -> String {
if self.use_wsl {
self.wsl_distribution
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("WSL ({})", value))
.unwrap_or_else(|| "WSL".to_string())
} else if cfg!(target_os = "windows") {
"Windows shell".to_string()
} else {
"local shell".to_string()
}
}
}
fn workflow_targets_manifest(
project_root: &Path,
workflow: &PublishWorkflow,
requested_manifest: &Path,
) -> bool {
let working_directory = workflow
.config
.working_directory
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| project_root.to_path_buf());
let manifest_path = workflow
.config
.manifest_path
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| default_manifest_path(&working_directory, workflow.kind));
paths_match(&manifest_path, requested_manifest)
}
fn render_publish_command(target: &PreparedPublishTarget) -> String {
match target.kind {
PublishKind::Npm => target.publish_command.clone(),
PublishKind::Crates => {
if delegates_workspace_publish(&target.publish_command) {
return target.publish_command.clone();
}
append_manifest_path_arg(
&target.publish_command,
&target.manifest_path,
target.use_wsl,
)
}
}
}
fn build_direct_cargo_publish_spec(
target: &PreparedPublishTarget,
allow_dirty: bool,
token: &str,
) -> Option<DirectCommandSpec> {
if target.use_wsl {
return None;
}
let words = target
.publish_command
.split_ascii_whitespace()
.collect::<Vec<_>>();
let program = words.first().copied()?;
if !matches!(program.to_ascii_lowercase().as_str(), "cargo" | "cargo.exe")
|| words
.get(1)
.is_none_or(|value| !value.eq_ignore_ascii_case("publish"))
|| words.iter().any(|word| {
word.chars()
.any(|ch| matches!(ch, '"' | '\'' | '&' | '|' | ';' | '<' | '>'))
})
{
return None;
}
let manifest_path = target.manifest_path.to_string_lossy().into_owned();
let mut args = words[1..]
.iter()
.map(|value| (*value).to_string())
.collect::<Vec<_>>();
if !args.iter().any(|arg| arg == "--manifest-path") {
args.push("--manifest-path".to_string());
args.push(manifest_path.clone());
}
if allow_dirty && !args.iter().any(|arg| arg == "--allow-dirty") {
args.push("--allow-dirty".to_string());
}
let mut env = HashMap::new();
env.insert("CI".to_string(), "1".to_string());
env.insert("CARGO_REGISTRY_TOKEN".to_string(), token.to_string());
env.insert(
"CARGO_REGISTRIES_CRATES_IO_TOKEN".to_string(),
token.to_string(),
);
let mut display_command = format!(
"{} publish --manifest-path {}",
program,
quote_path_for_message(Path::new(&manifest_path))
);
if allow_dirty {
display_command.push_str(" --allow-dirty");
}
Some(DirectCommandSpec {
program: program.to_string(),
args,
env,
display_command,
})
}
fn append_manifest_path_arg(command_line: &str, manifest_path: &Path, use_wsl: bool) -> String {
if command_line.contains("--manifest-path") {
return command_line.to_string();
}
let rendered_path = render_manifest_path_for_runner(manifest_path, use_wsl);
format!(
"{} --manifest-path {}",
command_line,
quote_for_shell(&rendered_path, use_wsl)
)
}
fn render_manifest_path_for_runner(manifest_path: &Path, _use_wsl: bool) -> String {
#[cfg(target_os = "windows")]
if _use_wsl {
return windows_path_to_wsl(manifest_path)
.unwrap_or_else(|_| manifest_path.to_string_lossy().replace('\\', "/"));
}
manifest_path.to_string_lossy().into_owned()
}
fn is_cargo_publish_command(command: &str) -> bool {
let normalized = command_after_leading_env_assignments(command)
.trim()
.to_ascii_lowercase();
normalized == "cargo publish"
|| normalized.starts_with("cargo publish ")
|| normalized == "cargo.exe publish"
|| normalized.starts_with("cargo.exe publish ")
}
fn command_after_leading_env_assignments(command: &str) -> &str {
let mut rest = command.trim_start();
loop {
let Some((token, after_token)) = split_first_token(rest) else {
return if is_env_assignment_token(rest) {
""
} else {
rest
};
};
if !is_env_assignment_token(token) {
return rest;
}
rest = after_token.trim_start();
}
}
fn split_first_token(value: &str) -> Option<(&str, &str)> {
let trimmed = value.trim_start();
let split_at = trimmed.find(char::is_whitespace)?;
Some((&trimmed[..split_at], &trimmed[split_at..]))
}
fn is_env_assignment_token(token: &str) -> bool {
let Some((name, _value)) = token.split_once('=') else {
return false;
};
let mut chars = name.chars();
matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn append_allow_dirty_arg(command_line: &str, allow_dirty: bool) -> String {
if !allow_dirty || !is_cargo_publish_command(command_line) {
return command_line.to_string();
}
if command_line.contains("--allow-dirty") {
return command_line.to_string();
}
format!("{} --allow-dirty", command_line.trim_end())
}
fn resolve_requested_manifest_path(current_dir: &Path, requested_path: &Path) -> PathBuf {
let candidate = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
current_dir.join(requested_path)
};
normalize_windows_verbatim_path(fs::canonicalize(&candidate).unwrap_or(candidate))
}
fn paths_match(left: &Path, right: &Path) -> bool {
fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
== fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
}
fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
}
fn strip_windows_verbatim_prefix(input: &str) -> &str {
input.strip_prefix(r"\\?\").unwrap_or(input)
}
fn prepend_shell_exports(command_line: &str, env: &HashMap<String, String>) -> String {
let exports = env
.iter()
.map(|(key, value)| format!("export {}={}", key, quote_for_shell(value, true)))
.collect::<Vec<_>>()
.join(" && ");
if exports.is_empty() {
command_line.to_string()
} else {
format!("{exports} && {command_line}")
}
}
fn redact_env_values(command_line: &str, env: &HashMap<String, String>) -> String {
env.values()
.filter(|value| !value.is_empty())
.fold(command_line.to_string(), |redacted, value| {
redacted.replace(value, "<redacted>")
})
}
fn quote_for_shell(value: &str, use_posix_rules: bool) -> String {
if use_posix_rules {
format!("'{}'", value.replace('\'', "'\"'\"'"))
} else {
if needs_windows_cmd_quotes(value) {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
}
fn needs_windows_cmd_quotes(value: &str) -> bool {
value.is_empty()
|| value.chars().any(|c| {
matches!(
c,
' ' | '\t'
| '"'
| '&'
| '|'
| '<'
| '>'
| '^'
| '%'
| '('
| ')'
| '!'
| ','
| ';'
| '='
)
})
}
fn parse_npm_whoami_username(output: &str) -> Option<String> {
output
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.to_ascii_lowercase().starts_with("npm "))
.map(str::to_string)
}
fn classify_npm_owner_permission(
result: &CommandResult,
authenticated_user: &str,
) -> NpmOwnerPermission {
let combined = format!("{}\n{}", result.stdout, result.stderr);
if npm_output_mentions_missing_package(&combined) {
return NpmOwnerPermission::PackageMissing;
}
let owners = parse_npm_owner_lines(&result.stdout);
if owners.is_empty() {
return NpmOwnerPermission::Indeterminate;
}
if owners
.iter()
.any(|owner| owner.eq_ignore_ascii_case(authenticated_user))
{
NpmOwnerPermission::Authorized
} else {
NpmOwnerPermission::Unauthorized { owners }
}
}
fn parse_npm_owner_lines(output: &str) -> Vec<String> {
output
.lines()
.map(str::trim)
.filter(|line| {
!line.is_empty()
&& !line.to_ascii_lowercase().starts_with("npm ")
&& !line.starts_with("http ")
})
.filter_map(|line| line.split_whitespace().next())
.map(|owner| owner.trim_end_matches(':').to_string())
.filter(|owner| !owner.is_empty())
.collect()
}
fn npm_output_mentions_missing_package(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
(lower.contains("e404") || lower.contains("404"))
&& (lower.contains("not in this registry")
|| lower.contains("package not found")
|| lower.contains("404 not found - get")
|| lower.contains("404 not found - get")
|| lower.contains("no match found for version")
|| lower.contains("not_found"))
}
fn classify_npm_failure(result: &CommandResult) -> Option<NpmFailureClassification> {
let combined = format!("{}\n{}", result.stdout, result.stderr);
let lower = combined.to_ascii_lowercase();
if lower.contains("one-time password")
|| lower.contains("otp")
|| lower.contains("two-factor")
|| lower.contains("2fa")
|| lower.contains("auth-and-writes")
{
return Some(NpmFailureClassification {
kind: NpmFailureKind::TwoFactor,
detail: "The npm account or token was challenged for 2FA/OTP. For non-interactive publishing, use a token with write access and bypass-2FA capability, or publish with an account that satisfies the package's 2FA policy.".to_string(),
});
}
if lower.contains("einvalidpackagename")
|| lower.contains("invalid package name")
&& (lower.contains("@") || lower.contains("url-friendly"))
{
return Some(NpmFailureClassification {
kind: NpmFailureKind::Auth,
detail: "npm rejected the package name (often Windows shell quoting of scoped names like @scope/pkg). XBP now runs `npm owner ls` / publish preflight via a direct npm process; upgrade xbp if you still see this.".to_string(),
});
}
if lower.contains("eneedauth")
|| lower.contains("e401")
|| lower.contains("401 unauthorized")
|| lower.contains("unable to authenticate")
|| lower.contains("requires you to be logged in")
|| lower.contains("login first")
|| lower.contains("authentication token")
|| lower.contains("/-/whoami")
{
return Some(NpmFailureClassification {
kind: NpmFailureKind::Auth,
detail: "The npm token appears missing, invalid, expired, or not accepted by the registry. Prefer `NPM_TOKEN` in process env or repo `.env` / `.env.local` (walked up to the git root), or refresh with `xbp config npm set-key`, then verify with `npm whoami`.".to_string(),
});
}
if lower.contains("e404")
&& lower.contains("could not be found or you do not have permission to access it")
{
return Some(NpmFailureClassification {
kind: NpmFailureKind::Access,
detail: "The npm registry accepted the request but denied publish access. This usually means the authenticated user or granular token does not have write rights to the package or scope, or the token is not allowed for this package.".to_string(),
});
}
if lower.contains("you do not have permission to publish")
|| lower.contains("not authorized to publish")
|| lower.contains("requires write access to package")
{
return Some(NpmFailureClassification {
kind: NpmFailureKind::Access,
detail: "The authenticated npm identity does not appear to have publish rights for this package.".to_string(),
});
}
None
}
fn format_npm_command_failure(
command: NpmCommandKind,
target: &PreparedPublishTarget,
authenticated_user: Option<&str>,
result: &CommandResult,
) -> String {
let raw_output = result.output_detail();
let classification = classify_npm_failure(result);
match command {
NpmCommandKind::Whoami => {
let detail = classification
.as_ref()
.map(|item| item.detail.as_str())
.unwrap_or(
"XBP could not authenticate to the npm registry with the resolved token.",
);
format!(
"npm auth preflight failed for {}@{}.\n{}\n\
Put a valid token in process env (`NPM_TOKEN` / `NODE_AUTH_TOKEN`), repo `.env` / `.env.local` \
(XBP walks parents to the git root), or `xbp config npm set-key`, then verify with `npm whoami`.\n\n{}\n\n{}",
target.package_name,
target.version,
detail,
raw_output,
format_npm_token_source_hint(&target.working_directory)
)
}
NpmCommandKind::OwnerLs => {
let user_line = authenticated_user
.map(|user| format!("Authenticated as npm user `{}`.\n", user))
.unwrap_or_default();
let detail = classification
.as_ref()
.map(|item| item.detail.as_str())
.unwrap_or(
"XBP could not verify publish access for the package with `npm owner ls`.",
);
format!(
"npm publish access preflight failed for {}@{}.\n{}{}\n\n{}",
target.package_name, target.version, user_line, detail, raw_output
)
}
NpmCommandKind::Publish => {
if let Some(classification) = classification {
let user_line = authenticated_user
.map(|user| format!("Authenticated as npm user `{}`.\n", user))
.unwrap_or_default();
let hint = match classification.kind {
NpmFailureKind::Auth => {
"Put NPM_TOKEN in `.env` / env, or run `xbp config npm set-key`, then verify with `npm whoami`."
}
NpmFailureKind::Access => {
"Verify npm package ownership or team access for the package/scope, and confirm any granular token includes this package with write permissions."
}
NpmFailureKind::TwoFactor => {
"Use a publish-capable token with bypass 2FA enabled, or publish with an account that satisfies the package's 2FA policy."
}
};
let sources = if classification.kind == NpmFailureKind::Auth {
format!(
"\n\n{}",
format_npm_token_source_hint(&target.working_directory)
)
} else {
String::new()
};
return format!(
"npm publish failed for {}@{}.\n{}{}\n{}{}\n\n{}",
target.package_name,
target.version,
user_line,
classification.detail,
hint,
sources,
raw_output
);
}
format!(
"npm publish failed for {}@{}.\n{}",
target.package_name, target.version, raw_output
)
}
}
}
#[cfg(test)]
mod tests {
use super::{
append_allow_dirty_arg, append_manifest_path_arg, augment_test_runner_for_single_run,
build_direct_cargo_publish_spec, classify_npm_failure, classify_npm_owner_permission,
classify_registry_http_status, crates_io_http_client, delegates_workspace_publish,
detect_failure_in_output, detect_watch_mode_in_output, ensure_non_interactive_publish_command,
expected_npm_tarball_filename, infer_command_success, is_npm_workspace_arborist_failure,
is_standard_npm_publish_command, looks_like_placeholder, needs_windows_cmd_quotes,
normalize_target_filter, npm_auth_attempts, npm_publish_extra_args, parse_npm_owner_lines,
prepare_publish_targets, prepend_shell_exports, quote_for_shell, redact_env_values,
registry_version_exists, render_preflight_plan, render_process_log_name,
render_process_log_summary, render_publish_command, render_publish_include_prereqs_command,
render_publish_status, resolve_publish_placeholder, run_configured_command,
strip_leading_shell_env_exports, supports_workspace_auto_resolution, tail_str,
truncate_for_transcript, CommandResult, CommandStage, NpmAuthMode, NpmFailureKind,
NpmOwnerPermission, PreparedPublishTarget, PublishCommandOptions, PublishKind,
RegistryLookup, TemporaryNpmrc, CRATES_IO_USER_AGENT, PUBLISH_TRANSCRIPT_MAX_CHARS,
};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn target_filter_accepts_supported_aliases() {
assert_eq!(
normalize_target_filter(Some("npm")).expect("npm filter"),
Some(PublishKind::Npm)
);
assert_eq!(
normalize_target_filter(Some("crates.io")).expect("crates filter"),
Some(PublishKind::Crates)
);
}
#[test]
fn truncate_for_transcript_keeps_small_output_intact() {
let sample = "hello publish";
assert_eq!(
truncate_for_transcript(sample, PUBLISH_TRANSCRIPT_MAX_CHARS),
sample
);
}
#[test]
fn truncate_for_transcript_bounds_large_output() {
let sample = "a".repeat(200_000);
let truncated = truncate_for_transcript(&sample, 1_000);
assert!(truncated.len() < sample.len());
assert!(truncated.contains("truncated"));
assert!(truncated.starts_with('a'));
assert!(truncated.ends_with('a'));
}
#[test]
fn tail_str_returns_suffix_without_splitting_utf8() {
let value = "hello 🦀 world";
let tail = tail_str(value, 8);
assert!(value.ends_with(tail));
assert!(std::str::from_utf8(tail.as_bytes()).is_ok());
}
#[test]
fn crates_io_status_classification_distinguishes_found_missing_and_retryable() {
assert_eq!(
classify_registry_http_status(reqwest::StatusCode::OK),
RegistryLookup::Found
);
assert_eq!(
classify_registry_http_status(reqwest::StatusCode::NOT_FOUND),
RegistryLookup::NotFound
);
assert_eq!(
classify_registry_http_status(reqwest::StatusCode::TOO_MANY_REQUESTS),
RegistryLookup::Retryable
);
assert_eq!(
classify_registry_http_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
RegistryLookup::Retryable
);
assert_eq!(
classify_registry_http_status(reqwest::StatusCode::FORBIDDEN),
RegistryLookup::Retryable
);
assert!(CRATES_IO_USER_AGENT.contains("xbp/"));
assert!(CRATES_IO_USER_AGENT.contains("github.com/xylex-group/xbp"));
}
#[tokio::test]
async fn crates_io_client_can_see_live_crate_version() {
let client = crates_io_http_client().expect("client");
let response = client
.get("https://crates.io/api/v1/crates/serde/1.0.0")
.send()
.await
.expect("request");
assert!(
response.status().is_success(),
"expected crates.io success with User-Agent, got {}",
response.status()
);
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "serde".to_string(),
version: "1.0.0".to_string(),
working_directory: std::env::temp_dir(),
manifest_path: PathBuf::from("Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: String::new(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
assert!(
registry_version_exists(&target).await.expect("lookup"),
"serde@1.0.0 should be visible on crates.io"
);
}
#[test]
fn placeholder_detection_handles_env_style_tokens() {
assert!(looks_like_placeholder("${NPM_TOKEN}"));
assert!(looks_like_placeholder("$CARGO_REGISTRY_TOKEN"));
assert!(!looks_like_placeholder("plain-token"));
}
#[test]
fn native_cargo_publish_uses_structured_arguments_and_env_token() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "mollie-rs".to_string(),
version: "0.3.1".to_string(),
working_directory: PathBuf::from(r"C:\Users\floris\documents\github\mollie-api-rust"),
manifest_path: PathBuf::from(
r"C:\Users\floris\documents\github\mollie-api-rust\Cargo.toml",
),
preflight_commands: Vec::new(),
publish_command: "cargo publish".to_string(),
use_wsl: false,
wsl_distribution: None,
token: Some("secret-token".to_string()),
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
let spec = build_direct_cargo_publish_spec(&target, true, "secret-token")
.expect("default native cargo publish should be direct");
assert_eq!(spec.program, "cargo");
assert_eq!(
spec.args,
vec![
"publish".to_string(),
"--manifest-path".to_string(),
target.manifest_path.to_string_lossy().into_owned(),
"--allow-dirty".to_string(),
]
);
assert_eq!(
spec.env.get("CARGO_REGISTRY_TOKEN").map(String::as_str),
Some("secret-token")
);
assert!(!spec.display_command.contains("secret-token"));
assert!(!spec.args.iter().any(|arg| arg.contains('"')));
}
#[test]
fn npm_auth_prefers_existing_login_before_configured_token() {
assert_eq!(
npm_auth_attempts(true),
vec![NpmAuthMode::Ambient, NpmAuthMode::Token]
);
assert_eq!(npm_auth_attempts(false), vec![NpmAuthMode::Ambient]);
}
#[test]
fn normalized_shell_command_logs_redact_registry_tokens() {
let mut env = HashMap::new();
env.insert(
"CARGO_REGISTRY_TOKEN".to_string(),
"secret-token".to_string(),
);
let command = prepend_shell_exports("cargo publish", &env);
let redacted = redact_env_values(&command, &env);
assert!(redacted.contains("<redacted>"));
assert!(!redacted.contains("secret-token"));
}
#[test]
fn prefixed_progress_reuses_parent_release_step() {
assert_eq!(
render_publish_status(
Some("[2/9]"),
2,
3,
"Running preflight for crates.io xbp@1.2.3"
),
"[2/9] Running preflight for crates.io xbp@1.2.3"
);
assert_eq!(
render_publish_status(None, 2, 3, "Running preflight for crates.io xbp@1.2.3"),
"[2/3] Running preflight for crates.io xbp@1.2.3"
);
}
#[tokio::test]
async fn run_configured_command_captures_stdout_and_stderr() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "fixture".to_string(),
version: "0.1.0".to_string(),
working_directory: std::env::temp_dir(),
manifest_path: PathBuf::from("Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: String::new(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
let command_line = if cfg!(target_os = "windows") {
"echo compiling crate-a && echo compiling crate-b && echo compile failed 1>&2 && exit /b 7"
} else {
"printf 'compiling crate-a\\ncompiling crate-b\\n'; printf 'compile failed\\n' 1>&2; exit 7"
};
let result = run_configured_command(
command_line,
command_line,
&target,
None,
None,
CommandStage::Preflight,
)
.await
.expect("command result");
assert!(!result.success);
assert!(result.stdout.contains("compiling crate-a"));
assert!(result.stdout.contains("compiling crate-b"));
assert!(result.stderr.contains("compile failed"));
}
#[test]
fn standard_npm_publish_command_detection() {
assert!(is_standard_npm_publish_command("npm publish"));
assert!(is_standard_npm_publish_command("npm publish --access public"));
assert!(is_standard_npm_publish_command("export CI=1; npm publish"));
assert!(is_standard_npm_publish_command("set CI=1&& npm publish --access public"));
assert!(!is_standard_npm_publish_command("bun run package:publish"));
assert!(!is_standard_npm_publish_command("pnpm publish"));
assert!(!is_standard_npm_publish_command("npm run publish"));
}
#[test]
fn npm_publish_extra_args_and_tarball_name() {
assert_eq!(
npm_publish_extra_args("npm publish --access public"),
vec!["--access".to_string(), "public".to_string()]
);
assert!(npm_publish_extra_args("npm publish").is_empty());
assert_eq!(
expected_npm_tarball_filename("@xylex-group/athena-auth-ui", "2.6.0"),
"xylex-group-athena-auth-ui-2.6.0.tgz"
);
assert_eq!(
strip_leading_shell_env_exports("export CI=1; npm publish"),
"npm publish"
);
}
#[test]
fn npm_arborist_workspace_failure_detection() {
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: "TypeError: Cannot read properties of null (reading 'package')\n at set root"
.to_string(),
};
assert!(is_npm_workspace_arborist_failure(&result));
assert!(classify_npm_failure(&result).is_none());
}
#[test]
fn preflight_commands_gain_ci_and_single_run_flags() {
assert_eq!(
ensure_non_interactive_publish_command("pnpm test", true, CommandStage::Preflight),
"export CI=1; pnpm test -- --run"
);
assert_eq!(
ensure_non_interactive_publish_command("pnpm test", false, CommandStage::Preflight),
"set CI=1&& pnpm test -- --run"
);
assert_eq!(
ensure_non_interactive_publish_command(
"cargo test && pnpm test",
true,
CommandStage::Preflight
),
"export CI=1; cargo test && pnpm test -- --run"
);
assert_eq!(
ensure_non_interactive_publish_command(
"pnpm test -- --run",
true,
CommandStage::Preflight
),
"export CI=1; pnpm test -- --run"
);
assert_eq!(
ensure_non_interactive_publish_command("npm publish", true, CommandStage::Publish),
"export CI=1; npm publish"
);
}
#[test]
fn augment_test_runner_skips_already_non_watch_commands() {
assert_eq!(
augment_test_runner_for_single_run("vitest run"),
"vitest run"
);
assert_eq!(
augment_test_runner_for_single_run("pnpm test:ci"),
"pnpm test:ci"
);
}
#[test]
fn detect_publish_command_failures_from_vitest_output() {
let output = r#"
FAIL Tests failed. Watching for file changes...
press h to show help, press q to quit
[ELIFECYCLE] Test failed. See above for more details.
stderr:
Loaded vitest@4.1.7 and @vitest/browser@4.1.8 .
⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯
Error: Failed to run the test C:/repo/packages/heroui/tests/username.test.tsx.
Test Files (28)
Tests no tests
Errors 1 error
"#;
assert!(detect_watch_mode_in_output(output));
assert!(detect_failure_in_output(output));
assert!(!infer_command_success(true, output, ""));
}
#[test]
fn render_preflight_plan_marks_forced_skips() {
let preflight_commands = vec!["pnpm test".to_string()];
let forced =
render_preflight_plan(&preflight_commands, true).expect("forced preflight line");
let normal =
render_preflight_plan(&preflight_commands, false).expect("normal preflight line");
assert_eq!(normal, "pnpm test");
assert_eq!(forced, "skipped via --force (configured: pnpm test)");
}
#[test]
fn render_process_log_helpers_include_publish_context() {
let target = PreparedPublishTarget {
kind: PublishKind::Npm,
package_name: "@xylex-group/athena-auth-ui".to_string(),
version: "1.4.0".to_string(),
working_directory: PathBuf::from("C:/repo"),
manifest_path: PathBuf::from("C:/repo/package.json"),
preflight_commands: vec!["pnpm test".to_string()],
publish_command: "npm publish".to_string(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: "stdout".to_string(),
stderr: "stderr".to_string(),
};
let name = render_process_log_name(CommandStage::Preflight, &target, "pnpm test");
let summary = render_process_log_summary(&target, &result);
assert_eq!(
name,
"preflight npm @xylex-group/athena-auth-ui@1.4.0 command `pnpm test`"
);
assert!(summary.contains("runner=Windows shell") || summary.contains("runner=local shell"));
assert!(summary.contains("cwd=C:\\repo") || summary.contains("cwd=C:/repo"));
assert!(summary.contains("success=false"));
assert!(summary.contains("exit_code=1"));
}
#[test]
fn classify_npm_publish_failure_handles_permission_denied_e404() {
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: "npm error code E404\nnpm error 404 The requested resource '@xylex-group/athena-auth-ui@1.17.0' could not be found or you do not have permission to access it.".to_string(),
};
let classified = classify_npm_failure(&result).expect("classification");
assert_eq!(classified.kind, NpmFailureKind::Access);
assert!(classified.detail.contains("denied publish access"));
}
#[test]
fn classify_npm_publish_failure_handles_invalid_auth() {
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: "npm ERR! code E401\nnpm ERR! 401 Unauthorized - GET https://registry.npmjs.org/-/whoami".to_string(),
};
let classified = classify_npm_failure(&result).expect("classification");
assert_eq!(classified.kind, NpmFailureKind::Auth);
assert!(classified.detail.contains("missing, invalid, expired"));
}
#[test]
fn classify_npm_publish_failure_handles_two_factor_challenge() {
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: "npm ERR! This operation requires a one-time password.\nnpm ERR! Enter OTP:"
.to_string(),
};
let classified = classify_npm_failure(&result).expect("classification");
assert_eq!(classified.kind, NpmFailureKind::TwoFactor);
assert!(classified.detail.contains("2FA/OTP"));
}
#[test]
fn windows_cmd_does_not_quote_scoped_npm_package_names() {
let scoped = "@xylex-group/athena-auth-ui";
assert!(
!needs_windows_cmd_quotes(scoped),
"scoped npm names must stay unquoted for cmd /C"
);
assert_eq!(quote_for_shell(scoped, false), scoped);
assert_eq!(
quote_for_shell("npm publish --access public", false),
"\"npm publish --access public\""
);
}
#[test]
fn temporary_npmrc_writes_expanded_auth_token() {
let npmrc = TemporaryNpmrc::create_with_token("npm_test_token_value")
.expect("temp npmrc should create");
let body = fs::read_to_string(&npmrc.path).expect("read npmrc");
assert!(
body.contains("//registry.npmjs.org/:_authToken=npm_test_token_value"),
"token must be expanded, not left as ${{NPM_TOKEN}}: {body}"
);
assert!(body.contains("always-auth=true"));
assert!(!body.contains("${NPM_TOKEN}"));
}
#[test]
fn resolve_publish_placeholder_walks_parent_dotenv_for_npm_token() {
let root = std::env::temp_dir().join(format!(
"xbp-publish-token-{}",
uuid::Uuid::new_v4()
));
let nested = root.join("packages").join("ui");
fs::create_dir_all(&nested).expect("dirs");
fs::write(
root.join(".env"),
"XBP_TEST_NPM_PUBLISH_TOKEN=npm_from_monorepo_root\n",
)
.expect("env");
let resolved =
resolve_publish_placeholder(&nested, "${XBP_TEST_NPM_PUBLISH_TOKEN}")
.expect("should resolve from parent .env");
assert_eq!(resolved, "npm_from_monorepo_root");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn classify_npm_invalid_package_name_from_quoting() {
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: "npm error code EINVALIDPACKAGENAME\nnpm error Invalid package name \"\"\" of package \"\"@xylex-group/athena-auth-ui\"\"".to_string(),
};
let classified = classify_npm_failure(&result).expect("classification");
assert!(classified.detail.to_ascii_lowercase().contains("quot"));
}
#[test]
fn owner_list_preflight_accepts_matching_user() {
let result = CommandResult {
success: true,
exit_code: Some(0),
stdout: "floris <floris@example.com>\nother-user <other@example.com>\n".to_string(),
stderr: String::new(),
};
assert_eq!(
classify_npm_owner_permission(&result, "floris"),
NpmOwnerPermission::Authorized
);
}
#[test]
fn owner_list_preflight_fails_when_user_absent() {
let result = CommandResult {
success: true,
exit_code: Some(0),
stdout: "alice <alice@example.com>\nbob <bob@example.com>\n".to_string(),
stderr: String::new(),
};
assert_eq!(
classify_npm_owner_permission(&result, "floris"),
NpmOwnerPermission::Unauthorized {
owners: vec!["alice".to_string(), "bob".to_string()],
}
);
}
#[test]
fn owner_list_preflight_allows_missing_package_to_continue() {
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: String::new(),
stderr: "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/-/package/@xylex-group%2fathena-auth-ui/collaborators?format=cli\nnpm ERR! 404 '@xylex-group/athena-auth-ui@*' is not in this registry.".to_string(),
};
assert_eq!(
classify_npm_owner_permission(&result, "floris"),
NpmOwnerPermission::PackageMissing
);
}
#[test]
fn parse_npm_owner_lines_ignores_noise() {
let owners = parse_npm_owner_lines(
"npm notice log line\nfloris <floris@example.com>\nother-user <other@example.com>\n",
);
assert_eq!(owners, vec!["floris".to_string(), "other-user".to_string()]);
}
#[test]
fn crates_publish_command_includes_manifest_path() {
let command = append_manifest_path_arg(
"cargo publish",
Path::new("C:/repo/crates/cli/Cargo.toml"),
false,
);
assert_eq!(
command,
"cargo publish --manifest-path C:/repo/crates/cli/Cargo.toml"
);
let spaced = append_manifest_path_arg(
"cargo publish",
Path::new("C:/repo with spaces/Cargo.toml"),
false,
);
assert_eq!(
spaced,
"cargo publish --manifest-path \"C:/repo with spaces/Cargo.toml\""
);
}
#[test]
#[cfg(target_os = "windows")]
fn crates_publish_command_converts_manifest_path_for_wsl() {
let command = append_manifest_path_arg(
"cargo publish",
Path::new("C:/repo/crates/cli/Cargo.toml"),
true,
);
assert_eq!(
command,
"cargo publish --manifest-path '/mnt/c/repo/crates/cli/Cargo.toml'"
);
}
#[test]
fn append_allow_dirty_arg_adds_flag_to_cargo_publish_with_manifest_path() {
let command = append_allow_dirty_arg(
"cargo publish --manifest-path '/mnt/c/repo/crates/cli/Cargo.toml'",
true,
);
assert_eq!(
command,
"cargo publish --manifest-path '/mnt/c/repo/crates/cli/Cargo.toml' --allow-dirty"
);
}
#[test]
fn rendered_publish_command_preserves_existing_manifest_path() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "xbp".to_string(),
version: "10.30.1".to_string(),
working_directory: PathBuf::from("C:/repo/crates/cli"),
manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: "cargo publish --manifest-path crates/cli/Cargo.toml".to_string(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
assert_eq!(
render_publish_command(&target),
"cargo publish --manifest-path crates/cli/Cargo.toml"
);
}
#[test]
fn prepend_shell_exports_builds_posix_export_prefix() {
let mut env = HashMap::new();
env.insert("CARGO_REGISTRY_TOKEN".to_string(), "abc123".to_string());
assert_eq!(
prepend_shell_exports("python3 scripts/publish_workspace.py", &env),
"export CARGO_REGISTRY_TOKEN='abc123' && python3 scripts/publish_workspace.py"
);
}
#[test]
fn workspace_delegate_publish_command_skips_manifest_path_suffix() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "athena_rs".to_string(),
version: "4.5.0".to_string(),
working_directory: PathBuf::from("C:/repo"),
manifest_path: PathBuf::from("C:/repo/Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: "python3 scripts/publish_workspace.py --allow-dirty".to_string(),
use_wsl: true,
wsl_distribution: None,
token: None,
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
assert_eq!(
render_publish_command(&target),
"python3 scripts/publish_workspace.py --allow-dirty"
);
}
#[test]
fn workspace_delegate_detection_accepts_publish_workspace_scripts() {
assert!(delegates_workspace_publish(
"export CARGO_TARGET_DIR=/tmp/athena-publish-target && python3 scripts/publish_workspace.py --allow-dirty"
));
assert!(delegates_workspace_publish(
"xbp version workspace publish run --repo . --only athena_rs --include-prereqs"
));
assert!(!delegates_workspace_publish("cargo publish"));
}
#[test]
fn workspace_auto_resolution_accepts_plain_cargo_publish_commands() {
assert!(supports_workspace_auto_resolution("cargo publish"));
assert!(supports_workspace_auto_resolution(
"cargo publish --no-verify"
));
assert!(supports_workspace_auto_resolution(
"TMPDIR=/tmp/xbp CARGO_ENCODED_RUSTFLAGS= cargo publish"
));
assert!(!supports_workspace_auto_resolution(
"cargo publish --manifest-path crates/cli/Cargo.toml"
));
assert!(!supports_workspace_auto_resolution(
"cargo publish && cargo owner --list"
));
}
#[test]
fn env_prefixed_cargo_publish_keeps_allow_dirty_support() {
assert_eq!(
append_allow_dirty_arg(
"TMPDIR=/tmp/xbp CARGO_ENCODED_RUSTFLAGS= cargo publish",
true
),
"TMPDIR=/tmp/xbp CARGO_ENCODED_RUSTFLAGS= cargo publish --allow-dirty"
);
}
#[test]
fn include_prereqs_rerun_command_preserves_publish_flags() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "xbp".to_string(),
version: "10.30.1".to_string(),
working_directory: PathBuf::from("C:/repo/crates/cli"),
manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: "cargo publish".to_string(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
already_published: false,
retry_no_verify: true,
no_verify_retry_packages: Vec::new(),
visibility_timeout: super::WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
visibility_poll: super::WORKSPACE_PUBLISH_VISIBILITY_POLL,
};
let options = PublishCommandOptions {
dry_run: false,
allow_dirty: true,
force: true,
include_prereqs: false,
auto_fix: true,
target: Some("crates".to_string()),
service: None,
manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
expected_version: None,
};
let command = render_publish_include_prereqs_command(&target, &options);
assert!(command.contains("xbp publish"));
assert!(command.contains("--target crates"));
assert!(command.contains("--allow-dirty"));
assert!(command.contains("--force"));
assert!(command.contains("--include-prereqs"));
assert!(command.contains("--manifest-path"));
}
#[tokio::test]
async fn manifest_override_retargets_crates_publish_workflow() {
let temp_dir = temp_dir("publish-manifest-override");
let cli_dir = temp_dir.join("crates").join("cli");
fs::create_dir_all(&cli_dir).expect("create cli dir");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.30.1\"\n",
)
.expect("write cargo");
let config = PublishProjectConfig {
npm: None,
crates: Some(PublishTargetConfig {
enabled: Some(true),
package_name: Some("workspace".to_string()),
working_directory: Some(temp_dir.to_string_lossy().to_string()),
manifest_path: None,
token: None,
preflight_commands: Vec::new(),
publish_command: Some("cargo publish".to_string()),
use_wsl: Some(false),
wsl_distribution: None,
generate_npmrc: None,
access: None,
..Default::default()
}),
dist: None,
};
let options = PublishCommandOptions {
dry_run: true,
allow_dirty: true,
force: true,
include_prereqs: false,
auto_fix: true,
target: Some("crates".to_string()),
service: None,
manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
expected_version: None,
};
let mut project_config: crate::strategies::XbpConfig =
serde_yaml::from_str("project_name: test\nversion: 1.0.0\nport: 3000\nbuild_dir: ./\n")
.expect("minimal XBP config");
project_config.publish = Some(config);
let prepared = prepare_publish_targets(&temp_dir, &temp_dir, &project_config, &options)
.await
.expect("prepared targets");
assert_eq!(prepared.len(), 1);
assert_eq!(prepared[0].package_name, "xbp");
assert_eq!(prepared[0].working_directory, cli_dir);
assert_eq!(prepared[0].manifest_path, cli_dir.join("Cargo.toml"));
}
#[tokio::test]
async fn service_scope_resolves_its_manifest_without_root_publish_config() {
let temp_dir = temp_dir("publish-service-scope");
let package_dir = temp_dir.join("packages").join("sdk");
fs::create_dir_all(&package_dir).expect("create package dir");
fs::write(
package_dir.join("package.json"),
r#"{"name":"@example/sdk","version":"1.2.3"}"#,
)
.expect("write package manifest");
let config: crate::strategies::XbpConfig = serde_yaml::from_str(
r#"
project_name: test
version: 1.0.0
port: 3000
build_dir: ./
services:
- name: sdk
target: nodejs
branch: main
port: 3001
root_directory: packages/sdk
version_targets:
- packages/sdk/package.json
"#,
)
.expect("service config");
let options = PublishCommandOptions {
dry_run: true,
allow_dirty: true,
force: true,
include_prereqs: false,
auto_fix: true,
target: None,
service: Some("sdk".to_string()),
manifest_path: None,
expected_version: None,
};
let prepared = prepare_publish_targets(&temp_dir, &temp_dir, &config, &options)
.await
.expect("service-scoped target");
assert_eq!(prepared.len(), 1);
assert_eq!(prepared[0].kind, PublishKind::Npm);
assert_eq!(prepared[0].package_name, "@example/sdk");
assert_eq!(prepared[0].working_directory, package_dir);
assert_eq!(prepared[0].manifest_path, package_dir.join("package.json"));
}
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}