use crate::cli::ui::Loader;
use crate::commands::service::load_xbp_config_with_root;
use crate::commands::version::{
resolve_manifest_workspace_publish, ManifestWorkspacePublishResolution,
};
use crate::config::{resolve_crates_token, resolve_npm_token};
use crate::logging::{log_file_only, log_process_output_file_only, LogLevel};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
use crate::utils::{
command_exists, resolve_cargo_package_version_required, resolve_env_placeholders,
};
use colored::Colorize;
use serde_json::Value as JsonValue;
use std::collections::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(300);
const WORKSPACE_PUBLISH_VISIBILITY_POLL: Duration = Duration::from_secs(5);
const PUBLISH_WATCH_MODE_GRACE: Duration = Duration::from_millis(750);
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 target: 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>,
}
#[derive(Debug, Clone)]
struct PublishPlanResult {
prepared: Vec<PreparedPublishTarget>,
}
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) {
self.loader.log(message);
}
}
#[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 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<(), 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(())
}
async fn run_publish_workflow(
options: PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<PublishPlanResult, String> {
let result: Result<PublishPlanResult, String> = async {
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.publish, &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, target) in prepared.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Checking registry target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
progress.log(&format!(
"Target {}/{}: {} (cwd: {}, runner: {})",
target_index + 1,
target_total,
target.identity(),
target.working_directory.display(),
target.runner_label()
));
if registry_version_exists(target).await? {
return Err(format!(
"{} {}@{} is already published on {}. Bump the version first or choose a different target.",
"Version already exists:".bright_red().bold(),
target.package_name,
target.version,
target.kind.label()
));
}
if options.force {
if !target.preflight_commands.is_empty() {
progress.log(&format!(
"Skipping {} configured preflight command(s) for {} because --force was supplied.",
target.preflight_commands.len(),
target.identity()
));
}
continue;
}
for (command_index, command) in target.preflight_commands.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Running preflight {}/{} for {}",
command_index + 1,
target.preflight_commands.len(),
target.identity()
),
);
let result = run_configured_command(
command,
command,
target,
None,
Some(progress),
CommandStage::Preflight,
)
.await?;
if !result.success {
return Err(format!(
"Preflight command failed for {}: `{}`\n{}",
target.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 });
}
progress.update(3, 3, "Publishing packages");
for (target_index, target) in prepared.iter().enumerate() {
progress.update(
3,
3,
&format!(
"Publishing target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
run_workspace_publish_target(target, workspace_publish, &options, Some(progress))
.await?;
} else {
run_publish_target(target, &options, Some(progress)).await?;
}
}
Ok(PublishPlanResult { prepared })
}
.await;
result
}
async fn prepare_publish_targets(
project_root: &Path,
current_dir: &Path,
config: &Option<PublishProjectConfig>,
options: &PublishCommandOptions,
) -> Result<Vec<PreparedPublishTarget>, String> {
let publish = config.as_ref().ok_or_else(|| {
"No `publish` section was found in the current XBP config. Configure one with `xbp config npm setup-release` or `xbp config crates setup-release`.".to_string()
})?;
let workflows = select_publish_workflows(publish, options.target.as_deref())?;
if workflows.is_empty() {
return Err("No enabled publish targets matched the requested filter.".to_string());
}
let manifest_override = options
.manifest_path
.as_deref()
.map(|path| resolve_requested_manifest_path(current_dir, path));
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 manifest_override = manifest_override
.as_deref()
.filter(|_| selected_workflows.len() == 1);
for workflow in selected_workflows {
prepared.push(prepare_publish_target(
project_root,
workflow,
options,
manifest_override,
)?);
}
Ok(prepared)
}
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));
Ok(PreparedPublishTarget {
kind: workflow.kind,
package_name,
version,
working_directory,
manifest_path,
preflight_commands,
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(),
PublishKind::Crates => resolve_crates_token(),
}
}
fn resolve_publish_placeholder(project_root: &Path, raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
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) {
None
} else {
Some(resolved)
}
}
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 entries = stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(8)
.collect::<Vec<_>>();
if entries.is_empty() {
return Ok(());
}
Err(format!(
"Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
entries.join(", ")
))
}
#[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 resolution =
resolve_manifest_workspace_publish(&target.manifest_path, options.include_prereqs).await?;
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(())
}
fn collect_workspace_publish_blockers(
resolution: &ManifestWorkspacePublishResolution,
) -> Vec<String> {
resolution
.packages
.iter()
.filter(|item| {
item.crates_io_visible != Some(true)
&& (!item.blocked_by.is_empty() || !item.publishable)
})
.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.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<(), 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();
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;
run_publish_target(&step_target, options, progress).await?;
let is_last = index + 1 == total;
wait_for_registry_version_with_mode(
&step_target,
WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
WORKSPACE_PUBLISH_VISIBILITY_POLL,
is_last,
)
.await?;
}
Ok(())
}
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 token = target.token.clone().ok_or_else(|| {
"No npm token resolved. Set `publish.npm.token: ${NPM_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config npm set-key`.".to_string()
})?;
let npmrc = TemporaryNpmrc::create()?;
let extra_env = build_npm_publish_env(token, &npmrc);
let authenticated_user =
run_npm_publish_preflight(target, &extra_env, progress).await?;
let result = run_configured_command(
&target.publish_command,
&target.publish_command,
target,
Some(&extra_env),
progress,
CommandStage::Publish,
)
.await?;
if !result.success {
return Err(format_npm_command_failure(
NpmCommandKind::Publish,
target,
Some(&authenticated_user),
&result,
));
}
Ok(())
}
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);
let publish_display_command =
append_allow_dirty_arg(&render_publish_command(target), options.allow_dirty);
let (publish_command, extra_env) = if uses_workspace_delegate {
let mut env = HashMap::new();
env.insert("CARGO_REGISTRY_TOKEN".to_string(), token.clone());
env.insert("CARGO_REGISTRIES_CRATES_IO_TOKEN".to_string(), token);
(publish_display_command.clone(), Some(env))
} else if publish_display_command.contains("--token") {
(publish_display_command.clone(), None)
} else {
(
format!(
"{} --token {}",
publish_display_command,
quote_for_shell(&token, target.use_wsl)
),
None,
)
};
let result = run_configured_command(
&publish_command,
&publish_display_command,
target,
extra_env.as_ref(),
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);
extra_env.insert(
"NPM_CONFIG_USERCONFIG".to_string(),
npmrc.path.to_string_lossy().to_string(),
);
extra_env
}
async fn run_npm_publish_preflight(
target: &PreparedPublishTarget,
extra_env: &HashMap<String, String>,
progress: Option<&PublishProgress<'_>>,
) -> Result<String, String> {
let whoami_result = run_configured_command(
"npm whoami",
"npm whoami",
target,
Some(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` and verify it in this shell with `npm whoami`.\n\n{}",
target.package_name,
target.version,
whoami_result.output_detail()
)
})?;
let owner_display_command = format!(
"npm owner ls {}",
quote_for_shell(
&target.package_name,
target.use_wsl || !cfg!(target_os = "windows")
)
);
let owner_result = run_configured_command(
&owner_display_command,
&owner_display_command,
target,
Some(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()
))
}
}
}
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 {
progress.log(&format!(
"Normalized {} command for non-interactive execution: {}",
stage.label(),
effective_command_line
));
}
}
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);
}
}
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,
}
impl CommandStreamAnalysis {
fn combined_output(&self) -> String {
if self.stdout.is_empty() {
return self.stderr.clone();
}
if self.stderr.is_empty() {
return self.stdout.clone();
}
format!("{}\n{}", self.stdout, self.stderr)
}
}
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(&guard.combined_output()) {
let _ = watch_tx.send(true);
}
}
Ok(())
}
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 _ = log_process_output_file_only("publish", &process_name, &result.stdout, &result.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> {
let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
fs::write(&path, "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n").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",
}
}
}
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 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 quote_for_shell(value: &str, use_posix_rules: bool) -> String {
if use_posix_rules {
format!("'{}'", value.replace('\'', "'\"'\"'"))
} else {
format!("\"{}\"", value.replace('"', "\\\""))
}
}
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("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. Update it with `xbp config npm set-key` and verify the active shell can authenticate 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{}\nUpdate the token with `xbp config npm set-key` and verify it in this shell with `npm whoami`.\n\n{}",
target.package_name,
target.version,
detail,
raw_output
)
}
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 => {
"Update the token with `xbp config npm set-key` and verify the active shell 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."
}
};
return format!(
"npm publish failed for {}@{}.\n{}{}\n{}\n\n{}",
target.package_name,
target.version,
user_line,
classification.detail,
hint,
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,
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, infer_command_success,
looks_like_placeholder, normalize_target_filter, parse_npm_owner_lines,
prepare_publish_targets, prepend_shell_exports, 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,
run_configured_command, supports_workspace_auto_resolution, CommandResult, CommandStage,
NpmFailureKind, NpmOwnerPermission, PreparedPublishTarget, PublishCommandOptions,
PublishKind, RegistryLookup, CRATES_IO_USER_AGENT,
};
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 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,
};
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 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,
};
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 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,
};
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 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\""
);
}
#[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,
};
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,
};
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,
};
let options = PublishCommandOptions {
dry_run: false,
allow_dirty: true,
force: true,
include_prereqs: false,
target: Some("crates".to_string()),
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,
}),
};
let options = PublishCommandOptions {
dry_run: true,
allow_dirty: true,
force: true,
include_prereqs: false,
target: Some("crates".to_string()),
manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
expected_version: None,
};
let prepared = prepare_publish_targets(&temp_dir, &temp_dir, &Some(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"));
}
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
}
}