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 tokio::process::Command;
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>,
}
pub async fn run_publish_command(options: PublishCommandOptions) -> Result<(), String> {
let loader = Loader::start(if options.dry_run {
"Planning publish workflow"
} else {
"Publishing configured packages"
});
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?;
loader.update("[1/3] Validating publish prerequisites");
if !options.allow_dirty {
ensure_clean_git_worktree(&project_root)?;
}
loader.update("[2/3] Checking registry state and running preflight commands");
for target in &prepared {
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 in &target.preflight_commands {
let result = run_configured_command(command, target, None).await?;
if !result.success {
return Err(format!(
"Preflight command failed for {}: `{}`\n{}",
target.package_name, command, result.stderr
));
}
}
}
if options.dry_run {
print_publish_plan(&prepared);
return Ok(PublishPlanResult { prepared });
}
loader.update("[3/3] Publishing packages");
for target in &prepared {
run_publish_target(target).await?;
}
Ok(PublishPlanResult { prepared })
}
.await;
match result {
Ok(plan) => {
if options.dry_run {
loader.success_with("Publish plan ready");
} else {
loader.success_with("Publish complete");
}
if !options.dry_run {
for target in plan.prepared {
println!(
"{} {}@{} via {}",
"Published".bright_green().bold(),
target.package_name.bright_white(),
target.version.bright_white(),
target.kind.label()
);
}
}
Ok(())
}
Err(error) => {
loader.fail(&error);
Err(error)
}
}
}
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) -> 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, Some(&extra_env)).await?;
if !result.success {
return Err(format!(
"npm publish failed for {}@{}.\n{}",
target.package_name, target.version, result.stderr
));
}
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, None).await?;
if !result.success {
return Err(format!(
"cargo publish failed for {}@{}.\n{}",
target.package_name, target.version, result.stderr
));
}
Ok(())
}
}
}
async fn run_configured_command(
command_line: &str,
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
) -> Result<CommandResult, String> {
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::inherit());
command.stderr(Stdio::piped());
let output = command
.output()
.await
.map_err(|e| format!("Failed to run `{}`: {}", command_line, e))?;
Ok(CommandResult {
success: output.status.success(),
stderr: String::from_utf8_lossy(&output.stderr).trim().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 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 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,
stderr: 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 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, PublishKind};
#[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"));
}
}