use crate::cli::ui::Loader;
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::{resolve_crates_token, resolve_npm_token};
use crate::logging::{log_file_only, log_process_output_file_only, LogLevel};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig, ServiceConfig, XbpConfig};
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(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) {
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 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.update(3, 3, "Publishing packages");
let mut outcomes = Vec::new();
for (target_index, target) in prepared.iter().enumerate() {
progress.update(
3,
3,
&format!(
"Publishing target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
if target.already_published {
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() {
let mut package_outcomes = run_workspace_publish_target(
target,
workspace_publish,
&options,
Some(progress),
)
.await?;
outcomes.append(&mut package_outcomes);
} else {
run_publish_target(target, &options, Some(progress)).await?;
wait_for_registry_version_with_mode(
target,
target.visibility_timeout,
target.visibility_poll,
true,
)
.await?;
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,
});
}
}
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 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 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(),
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 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()?;
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 configured XBP token.",
);
}
}
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),
};
let result = run_configured_command(
&target.publish_command,
&target.publish_command,
target,
publish_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);
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);
extra_env.insert(
"NPM_CONFIG_USERCONFIG".to_string(),
npmrc.path.to_string_lossy().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_configured_command(
"npm 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` 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,
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 {
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> {
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",
}
}
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 {
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,
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,
infer_command_success, looks_like_placeholder, normalize_target_filter, npm_auth_attempts,
parse_npm_owner_lines, prepare_publish_targets, prepend_shell_exports, 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, run_configured_command, supports_workspace_auto_resolution, tail_str,
truncate_for_transcript, CommandResult, CommandStage, NpmAuthMode, NpmFailureKind,
NpmOwnerPermission, PreparedPublishTarget, PublishCommandOptions, PublishKind,
RegistryLookup, 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 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 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,
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
}
}