use crate::cli::ui::Loader;
use crate::commands::service::load_xbp_config_with_root;
use crate::config::{resolve_crates_token, resolve_npm_token};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
use crate::utils::{command_exists, 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::time::{Duration, Instant};
use tokio::io::{stderr, stdout, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::Command;
use tokio::time::MissedTickBehavior;
use toml::Value as TomlValue;
#[derive(Debug, Clone)]
pub struct PublishCommandOptions {
pub dry_run: bool,
pub allow_dirty: bool,
pub target: Option<String>,
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>,
}
#[derive(Debug, Clone)]
struct PublishPlanResult {
prepared: Vec<PreparedPublishTarget>,
}
struct PublishProgress<'a> {
loader: &'a Loader,
step_prefix: Option<String>,
}
impl<'a> PublishProgress<'a> {
fn standalone(loader: &'a Loader) -> Self {
Self {
loader,
step_prefix: None,
}
}
fn prefixed(loader: &'a Loader, step_prefix: String) -> Self {
Self {
loader,
step_prefix: Some(step_prefix),
}
}
fn update(&self, step: usize, total: usize, detail: &str) {
self.loader.update(&render_publish_status(
self.step_prefix.as_deref(),
step,
total,
detail,
));
}
fn log(&self, message: &str) {
self.loader.log(message);
}
}
#[derive(Debug, Clone, Copy)]
enum CommandStage {
Preflight,
Publish,
}
impl CommandStage {
fn label(self) -> &'static str {
match self {
Self::Preflight => "preflight",
Self::Publish => "publish",
}
}
}
pub async fn run_publish_command(options: PublishCommandOptions) -> Result<(), String> {
let dry_run = options.dry_run;
let loader = Loader::start(if options.dry_run {
"Planning publish workflow"
} else {
"Publishing configured packages"
});
let progress = PublishProgress::standalone(&loader);
let result = run_publish_workflow(options, &progress).await;
match result {
Ok(plan) => {
if dry_run {
loader.success_with("Publish plan ready");
} else {
loader.success_with("Publish complete");
}
print_publish_completion(&plan, dry_run);
Ok(())
}
Err(error) => {
loader.fail(&error);
Err(error)
}
}
}
pub(crate) async fn run_publish_command_with_progress_prefix(
options: PublishCommandOptions,
loader: &Loader,
step_prefix: String,
) -> Result<(), String> {
let dry_run = options.dry_run;
let progress = PublishProgress::prefixed(loader, step_prefix);
let plan = run_publish_workflow(options, &progress).await?;
print_publish_completion(&plan, dry_run);
Ok(())
}
async fn run_publish_workflow(
options: PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<PublishPlanResult, String> {
let result: Result<PublishPlanResult, String> = async {
let (project_root, config) = load_xbp_config_with_root().await?;
let prepared = prepare_publish_targets(&project_root, &config.publish, &options).await?;
let target_total = prepared.len();
progress.update(1, 3, "Validating publish prerequisites");
if !options.allow_dirty {
ensure_clean_git_worktree(&project_root)?;
}
progress.update(2, 3, "Checking registry state and running preflight commands");
for (target_index, target) in prepared.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Checking registry target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
progress.log(&format!(
"Target {}/{}: {} (cwd: {}, runner: {})",
target_index + 1,
target_total,
target.identity(),
target.working_directory.display(),
target.runner_label()
));
if registry_version_exists(target).await? {
return Err(format!(
"{} {}@{} is already published on {}. Bump the version first or choose a different target.",
"Version already exists:".bright_red().bold(),
target.package_name,
target.version,
target.kind.label()
));
}
for (command_index, command) in target.preflight_commands.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Running preflight {}/{} for {}",
command_index + 1,
target.preflight_commands.len(),
target.identity()
),
);
let result = run_configured_command(
command,
command,
target,
None,
Some(progress),
CommandStage::Preflight,
)
.await?;
if !result.success {
return Err(format!(
"Preflight command failed for {}: `{}`\n{}",
target.package_name,
command,
result.output_detail()
));
}
}
}
if options.dry_run {
print_publish_plan(&prepared);
return Ok(PublishPlanResult { prepared });
}
progress.update(3, 3, "Publishing packages");
for (target_index, target) in prepared.iter().enumerate() {
progress.update(
3,
3,
&format!(
"Publishing target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
run_publish_target(target, Some(progress)).await?;
}
Ok(PublishPlanResult { prepared })
}
.await;
result
}
async fn prepare_publish_targets(
project_root: &Path,
config: &Option<PublishProjectConfig>,
options: &PublishCommandOptions,
) -> Result<Vec<PreparedPublishTarget>, String> {
let publish = config.as_ref().ok_or_else(|| {
"No `publish` section was found in the current XBP config. Configure one with `xbp config npm setup-release` or `xbp config crates setup-release`.".to_string()
})?;
let workflows = select_publish_workflows(publish, options.target.as_deref())?;
if workflows.is_empty() {
return Err("No enabled publish targets matched the requested filter.".to_string());
}
let mut prepared = Vec::new();
for workflow in workflows {
prepared.push(prepare_publish_target(project_root, workflow, options)?);
}
Ok(prepared)
}
fn select_publish_workflows(
config: &PublishProjectConfig,
target: Option<&str>,
) -> Result<Vec<PublishWorkflow>, String> {
let mut workflows = Vec::new();
match normalize_target_filter(target)? {
None => {
if let Some(npm) = config.npm.clone().filter(is_enabled_target) {
workflows.push(PublishWorkflow {
kind: PublishKind::Npm,
config: npm,
});
}
if let Some(crates) = config.crates.clone().filter(is_enabled_target) {
workflows.push(PublishWorkflow {
kind: PublishKind::Crates,
config: crates,
});
}
}
Some(PublishKind::Npm) => {
let Some(npm) = config.npm.clone().filter(is_enabled_target) else {
return Err(
"No enabled npm publish target is configured in `.xbp/xbp.yaml`.".to_string(),
);
};
workflows.push(PublishWorkflow {
kind: PublishKind::Npm,
config: npm,
});
}
Some(PublishKind::Crates) => {
let Some(crates) = config.crates.clone().filter(is_enabled_target) else {
return Err(
"No enabled crates publish target is configured in `.xbp/xbp.yaml`."
.to_string(),
);
};
workflows.push(PublishWorkflow {
kind: PublishKind::Crates,
config: crates,
});
}
}
Ok(workflows)
}
fn normalize_target_filter(target: Option<&str>) -> Result<Option<PublishKind>, String> {
match target.map(|value| value.trim().to_ascii_lowercase()) {
None => Ok(None),
Some(value) if value.is_empty() => Ok(None),
Some(value) if value == "npm" => Ok(Some(PublishKind::Npm)),
Some(value) if value == "crates" || value == "crates.io" => Ok(Some(PublishKind::Crates)),
Some(value) => Err(format!(
"Unsupported publish target `{}`. Use `npm` or `crates`.",
value
)),
}
}
fn is_enabled_target(config: &PublishTargetConfig) -> bool {
config.enabled.unwrap_or(true)
}
fn prepare_publish_target(
project_root: &Path,
workflow: PublishWorkflow,
options: &PublishCommandOptions,
) -> Result<PreparedPublishTarget, String> {
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));
if !manifest_path.exists() {
return Err(format!(
"Configured manifest path does not exist for {}: {}",
workflow.kind.label(),
manifest_path.display()
));
}
let package_name = workflow
.config
.package_name
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
detect_package_name(workflow.kind, &manifest_path)
.unwrap_or_else(|| "package".to_string())
});
let version = detect_package_version(workflow.kind, &manifest_path)?;
if let Some(expected_version) = &options.expected_version {
if expected_version.trim() != version {
return Err(format!(
"Configured {} publish target resolved version {} but release target is {}.",
workflow.kind.label(),
version,
expected_version.trim()
));
}
}
let token = resolve_publish_token(
project_root,
workflow.kind,
workflow.config.token.as_deref(),
);
let preflight_commands = workflow
.config
.preflight_commands
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
let publish_command = workflow
.config
.publish_command
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| default_publish_command(workflow.kind, &workflow.config));
Ok(PreparedPublishTarget {
kind: workflow.kind,
package_name,
version,
working_directory,
manifest_path,
preflight_commands,
publish_command,
use_wsl: workflow.config.use_wsl.unwrap_or(false),
wsl_distribution: workflow.config.wsl_distribution.clone(),
token,
})
}
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 => {
let content = fs::read_to_string(manifest_path).map_err(|e| {
format!(
"Failed to read Cargo manifest {}: {}",
manifest_path.display(),
e
)
})?;
let toml: TomlValue = toml::from_str(&content).map_err(|e| {
format!(
"Failed to parse Cargo manifest {}: {}",
manifest_path.display(),
e
)
})?;
toml.get("package")
.and_then(|package| package.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()
)
})
}
}
}
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(", ")
))
}
async fn registry_version_exists(target: &PreparedPublishTarget) -> Result<bool, String> {
let client = reqwest::Client::new();
match target.kind {
PublishKind::Npm => {
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(response.status().is_success())
}
PublishKind::Crates => {
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))?;
Ok(response.status().is_success())
}
}
}
async fn run_publish_target(
target: &PreparedPublishTarget,
progress: Option<&PublishProgress<'_>>,
) -> Result<(), String> {
match target.kind {
PublishKind::Npm => {
let token = target.token.clone().ok_or_else(|| {
"No npm token resolved. Set `publish.npm.token: ${NPM_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config npm set-key`.".to_string()
})?;
let npmrc = TemporaryNpmrc::create()?;
let 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(),
);
let result = run_configured_command(
&target.publish_command,
&target.publish_command,
target,
Some(&extra_env),
progress,
CommandStage::Publish,
)
.await?;
if !result.success {
return Err(format!(
"npm publish failed for {}@{}.\n{}",
target.package_name,
target.version,
result.output_detail()
));
}
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 publish_command = if target.publish_command.contains("--token") {
target.publish_command.clone()
} else {
format!(
"{} --token {}",
target.publish_command,
quote_for_shell(&token, target.use_wsl)
)
};
let result = run_configured_command(
&publish_command,
&target.publish_command,
target,
None,
progress,
CommandStage::Publish,
)
.await?;
if !result.success {
return Err(format!(
"cargo publish failed for {}@{}.\n{}",
target.package_name,
target.version,
result.output_detail()
));
}
Ok(())
}
}
}
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 command = build_shell_command(
command_line,
&target.working_directory,
target.use_wsl,
target.wsl_distribution.as_deref(),
)?;
if let Some(extra_env) = extra_env {
command.envs(extra_env);
}
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("Failed to run `{}`: {}", command_line, e))?;
let child_stdout = child
.stdout
.take()
.ok_or_else(|| format!("Failed to capture stdout for `{}`.", command_line))?;
let child_stderr = child
.stderr
.take()
.ok_or_else(|| format!("Failed to capture stderr for `{}`.", command_line))?;
let stdout_capture =
tokio::spawn(async move { mirror_command_stream(child_stdout, stdout()).await });
let stderr_capture =
tokio::spawn(async move { mirror_command_stream(child_stderr, stderr()).await });
let started_at = Instant::now();
let status = if let Some(progress) = progress {
let mut wait = Box::pin(child.wait());
let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
heartbeat.tick().await;
loop {
tokio::select! {
status = &mut wait => break status,
_ = heartbeat.tick() => {
progress.log(&format!(
"Still running {} for {} via {} (elapsed {}): {}",
stage.label(),
target.identity(),
target.runner_label(),
format_elapsed(started_at.elapsed()),
display_command
));
}
}
}
} else {
child.wait().await
};
let status = status.map_err(|e| format!("Failed to wait for `{}`: {}", command_line, e))?;
let stdout = stdout_capture.await.map_err(|e| {
format!(
"Failed to join stdout capture for `{}`: {}",
command_line, e
)
})??;
let stderr = stderr_capture.await.map_err(|e| {
format!(
"Failed to join stderr capture for `{}`: {}",
command_line, e
)
})??;
if let Some(progress) = progress {
progress.log(&format!(
"Completed {} for {} in {}.",
stage.label(),
target.identity(),
format_elapsed(started_at.elapsed())
));
}
Ok(CommandResult {
success: status.success(),
stdout,
stderr,
})
}
async fn mirror_command_stream<R, W>(mut reader: R, mut writer: W) -> Result<String, String>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut buffer = [0_u8; 8192];
let mut captured = Vec::new();
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}"))?;
captured.extend_from_slice(&buffer[..read_len]);
}
Ok(String::from_utf8_lossy(&captured).trim_end().to_string())
}
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 {
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]) {
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 !target.preflight_commands.is_empty() {
println!(" preflight: {}", target.preflight_commands.join(" && "));
}
println!(" publish: {}", target.publish_command);
if target.use_wsl {
println!(
" runner: WSL{}",
target
.wsl_distribution
.as_deref()
.map(|value| format!(" ({})", value))
.unwrap_or_default()
);
}
}
}
struct CommandResult {
success: bool,
stdout: String,
stderr: String,
}
impl CommandResult {
fn output_detail(&self) -> String {
match (self.stdout.trim().is_empty(), self.stderr.trim().is_empty()) {
(false, false) => format!("stdout:\n{}\n\nstderr:\n{}", self.stdout, self.stderr),
(false, true) => format!("stdout:\n{}", self.stdout),
(true, false) => format!("stderr:\n{}", self.stderr),
(true, true) => "Command produced no output.".to_string(),
}
}
}
struct TemporaryNpmrc {
path: PathBuf,
}
impl TemporaryNpmrc {
fn create() -> Result<Self, String> {
let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
fs::write(&path, "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n").map_err(|e| {
format!(
"Failed to create temporary .npmrc {}: {}",
path.display(),
e
)
})?;
Ok(Self { path })
}
}
impl Drop for TemporaryNpmrc {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl PublishKind {
fn label(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Crates => "crates.io",
}
}
}
impl PreparedPublishTarget {
fn identity(&self) -> String {
format!(
"{} {}@{}",
self.kind.label(),
self.package_name,
self.version
)
}
fn runner_label(&self) -> String {
if self.use_wsl {
self.wsl_distribution
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("WSL ({})", value))
.unwrap_or_else(|| "WSL".to_string())
} else if cfg!(target_os = "windows") {
"Windows shell".to_string()
} else {
"local shell".to_string()
}
}
}
fn quote_for_shell(value: &str, use_posix_rules: bool) -> String {
if use_posix_rules {
format!("'{}'", value.replace('\'', "'\"'\"'"))
} else {
format!("\"{}\"", value.replace('"', "\\\""))
}
}
#[cfg(test)]
mod tests {
use super::{
looks_like_placeholder, normalize_target_filter, render_publish_status,
run_configured_command, CommandStage, PreparedPublishTarget, PublishKind,
};
use std::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 placeholder_detection_handles_env_style_tokens() {
assert!(looks_like_placeholder("${NPM_TOKEN}"));
assert!(looks_like_placeholder("$CARGO_REGISTRY_TOKEN"));
assert!(!looks_like_placeholder("plain-token"));
}
#[test]
fn prefixed_progress_reuses_parent_release_step() {
assert_eq!(
render_publish_status(
Some("[2/9]"),
2,
3,
"Running preflight for crates.io xbp@1.2.3"
),
"[2/9] Running preflight for crates.io xbp@1.2.3"
);
assert_eq!(
render_publish_status(None, 2, 3, "Running preflight for crates.io xbp@1.2.3"),
"[2/3] Running preflight for crates.io xbp@1.2.3"
);
}
#[tokio::test]
async fn run_configured_command_captures_stdout_and_stderr() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "fixture".to_string(),
version: "0.1.0".to_string(),
working_directory: std::env::temp_dir(),
manifest_path: PathBuf::from("Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: String::new(),
use_wsl: false,
wsl_distribution: None,
token: None,
};
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"));
}
}