1use crate::cli::ui::Loader;
2use crate::commands::service::load_xbp_config_with_root;
3use crate::commands::version::{
4 resolve_manifest_workspace_publish, ManifestWorkspacePublishResolution,
5};
6use crate::config::{resolve_crates_token, resolve_npm_token};
7use crate::logging::{log_file_only, log_process_output_file_only, LogLevel};
8use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
9use crate::utils::{
10 command_exists, resolve_cargo_package_version_required, resolve_env_placeholders,
11};
12use colored::Colorize;
13use serde_json::Value as JsonValue;
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::process::Stdio;
18use std::time::{Duration, Instant};
19use tokio::io::{stderr, stdout, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
20use tokio::process::Command;
21use tokio::time::sleep;
22use tokio::time::MissedTickBehavior;
23use toml::Value as TomlValue;
24
25const WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT: Duration = Duration::from_secs(180);
26const WORKSPACE_PUBLISH_VISIBILITY_POLL: Duration = Duration::from_secs(5);
27
28#[derive(Debug, Clone)]
29pub struct PublishCommandOptions {
30 pub dry_run: bool,
31 pub allow_dirty: bool,
32 pub force: bool,
33 pub include_prereqs: bool,
34 pub target: Option<String>,
35 pub manifest_path: Option<PathBuf>,
36 pub expected_version: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum PublishKind {
41 Npm,
42 Crates,
43}
44
45#[derive(Debug, Clone)]
46struct PublishWorkflow {
47 kind: PublishKind,
48 config: PublishTargetConfig,
49}
50
51#[derive(Debug, Clone)]
52struct PreparedPublishTarget {
53 kind: PublishKind,
54 package_name: String,
55 version: String,
56 working_directory: PathBuf,
57 manifest_path: PathBuf,
58 preflight_commands: Vec<String>,
59 publish_command: String,
60 use_wsl: bool,
61 wsl_distribution: Option<String>,
62 token: Option<String>,
63 workspace_publish: Option<ManifestWorkspacePublishResolution>,
64}
65
66#[derive(Debug, Clone)]
67struct PublishPlanResult {
68 prepared: Vec<PreparedPublishTarget>,
69}
70
71struct PublishProgress<'a> {
72 loader: &'a Loader,
73 step_prefix: Option<String>,
74}
75
76impl<'a> PublishProgress<'a> {
77 fn standalone(loader: &'a Loader) -> Self {
78 Self {
79 loader,
80 step_prefix: None,
81 }
82 }
83
84 fn prefixed(loader: &'a Loader, step_prefix: String) -> Self {
85 Self {
86 loader,
87 step_prefix: Some(step_prefix),
88 }
89 }
90
91 fn update(&self, step: usize, total: usize, detail: &str) {
92 self.loader.update(&render_publish_status(
93 self.step_prefix.as_deref(),
94 step,
95 total,
96 detail,
97 ));
98 }
99
100 fn log(&self, message: &str) {
101 self.loader.log(message);
102 }
103}
104
105#[derive(Debug, Clone, Copy)]
106enum CommandStage {
107 Preflight,
108 Publish,
109}
110
111impl CommandStage {
112 fn label(self) -> &'static str {
113 match self {
114 Self::Preflight => "preflight",
115 Self::Publish => "publish",
116 }
117 }
118}
119
120pub async fn run_publish_command(options: PublishCommandOptions) -> Result<(), String> {
121 let dry_run = options.dry_run;
122 let loader = Loader::start(if options.dry_run {
123 "Planning publish workflow"
124 } else {
125 "Publishing configured packages"
126 });
127 let progress = PublishProgress::standalone(&loader);
128
129 let result = run_publish_workflow(options, &progress).await;
130
131 match result {
132 Ok(plan) => {
133 if dry_run {
134 loader.success_with("Publish plan ready");
135 } else {
136 loader.success_with("Publish complete");
137 }
138 print_publish_completion(&plan, dry_run);
139 Ok(())
140 }
141 Err(error) => {
142 loader.fail(&error);
143 Err(error)
144 }
145 }
146}
147
148pub(crate) async fn run_publish_command_with_progress_prefix(
149 options: PublishCommandOptions,
150 loader: &Loader,
151 step_prefix: String,
152) -> Result<(), String> {
153 let dry_run = options.dry_run;
154 let progress = PublishProgress::prefixed(loader, step_prefix);
155 let plan = run_publish_workflow(options, &progress).await?;
156 print_publish_completion(&plan, dry_run);
157 Ok(())
158}
159
160async fn run_publish_workflow(
161 options: PublishCommandOptions,
162 progress: &PublishProgress<'_>,
163) -> Result<PublishPlanResult, String> {
164 let result: Result<PublishPlanResult, String> = async {
165 let current_dir =
166 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
167 let (project_root, config) = load_xbp_config_with_root().await?;
168 let mut prepared =
169 prepare_publish_targets(&project_root, ¤t_dir, &config.publish, &options)
170 .await?;
171 let target_total = prepared.len();
172
173 progress.update(1, 3, "Validating publish prerequisites");
174 if !options.allow_dirty {
175 ensure_clean_git_worktree(&project_root)?;
176 }
177
178 progress.update(
179 2,
180 3,
181 if options.force {
182 "Checking registry state and skipping preflight commands (--force)"
183 } else {
184 "Checking registry state and running preflight commands"
185 },
186 );
187 for (target_index, target) in prepared.iter().enumerate() {
188 progress.update(
189 2,
190 3,
191 &format!(
192 "Checking registry target {}/{}: {}",
193 target_index + 1,
194 target_total,
195 target.identity()
196 ),
197 );
198 progress.log(&format!(
199 "Target {}/{}: {} (cwd: {}, runner: {})",
200 target_index + 1,
201 target_total,
202 target.identity(),
203 target.working_directory.display(),
204 target.runner_label()
205 ));
206 if registry_version_exists(target).await? {
207 return Err(format!(
208 "{} {}@{} is already published on {}. Bump the version first or choose a different target.",
209 "Version already exists:".bright_red().bold(),
210 target.package_name,
211 target.version,
212 target.kind.label()
213 ));
214 }
215
216 if options.force {
217 if !target.preflight_commands.is_empty() {
218 progress.log(&format!(
219 "Skipping {} configured preflight command(s) for {} because --force was supplied.",
220 target.preflight_commands.len(),
221 target.identity()
222 ));
223 }
224 continue;
225 }
226
227 for (command_index, command) in target.preflight_commands.iter().enumerate() {
228 progress.update(
229 2,
230 3,
231 &format!(
232 "Running preflight {}/{} for {}",
233 command_index + 1,
234 target.preflight_commands.len(),
235 target.identity()
236 ),
237 );
238 let result = run_configured_command(
239 command,
240 command,
241 target,
242 None,
243 Some(progress),
244 CommandStage::Preflight,
245 )
246 .await?;
247 if !result.success {
248 return Err(format!(
249 "Preflight command failed for {}: `{}`\n{}",
250 target.package_name,
251 command,
252 result.output_detail()
253 ));
254 }
255 }
256 }
257
258 for (target_index, target) in prepared.iter_mut().enumerate() {
259 progress.update(
260 2,
261 3,
262 &format!(
263 "Resolving publish closure {}/{}: {}",
264 target_index + 1,
265 target_total,
266 target.identity()
267 ),
268 );
269 maybe_attach_workspace_publish_resolution(target, &options, progress).await?;
270 }
271
272 if options.dry_run {
273 print_publish_plan(&prepared, options.force);
274 return Ok(PublishPlanResult { prepared });
275 }
276
277 progress.update(3, 3, "Publishing packages");
278 for (target_index, target) in prepared.iter().enumerate() {
279 progress.update(
280 3,
281 3,
282 &format!(
283 "Publishing target {}/{}: {}",
284 target_index + 1,
285 target_total,
286 target.identity()
287 ),
288 );
289 if let Some(workspace_publish) = target.workspace_publish.as_ref() {
290 run_workspace_publish_target(target, workspace_publish, &options, Some(progress))
291 .await?;
292 } else {
293 run_publish_target(target, &options, Some(progress)).await?;
294 }
295 }
296
297 Ok(PublishPlanResult { prepared })
298 }
299 .await;
300 result
301}
302
303async fn prepare_publish_targets(
304 project_root: &Path,
305 current_dir: &Path,
306 config: &Option<PublishProjectConfig>,
307 options: &PublishCommandOptions,
308) -> Result<Vec<PreparedPublishTarget>, String> {
309 let publish = config.as_ref().ok_or_else(|| {
310 "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()
311 })?;
312
313 let workflows = select_publish_workflows(publish, options.target.as_deref())?;
314 if workflows.is_empty() {
315 return Err("No enabled publish targets matched the requested filter.".to_string());
316 }
317
318 let manifest_override = options
319 .manifest_path
320 .as_deref()
321 .map(|path| resolve_requested_manifest_path(current_dir, path));
322 let selected_workflows = if let Some(requested_manifest) = manifest_override.as_deref() {
323 let matched = workflows
324 .iter()
325 .filter(|workflow| {
326 workflow_targets_manifest(project_root, workflow, requested_manifest)
327 })
328 .cloned()
329 .collect::<Vec<_>>();
330 if !matched.is_empty() {
331 matched
332 } else if workflows.len() == 1 {
333 workflows.clone()
334 } else {
335 return Err(format!(
336 "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`.",
337 requested_manifest.display()
338 ));
339 }
340 } else {
341 workflows
342 };
343
344 let mut prepared = Vec::new();
345 let manifest_override = manifest_override
346 .as_deref()
347 .filter(|_| selected_workflows.len() == 1);
348 for workflow in selected_workflows {
349 prepared.push(prepare_publish_target(
350 project_root,
351 workflow,
352 options,
353 manifest_override,
354 )?);
355 }
356 Ok(prepared)
357}
358
359fn select_publish_workflows(
360 config: &PublishProjectConfig,
361 target: Option<&str>,
362) -> Result<Vec<PublishWorkflow>, String> {
363 let mut workflows = Vec::new();
364
365 match normalize_target_filter(target)? {
366 None => {
367 if let Some(npm) = config.npm.clone().filter(is_enabled_target) {
368 workflows.push(PublishWorkflow {
369 kind: PublishKind::Npm,
370 config: npm,
371 });
372 }
373 if let Some(crates) = config.crates.clone().filter(is_enabled_target) {
374 workflows.push(PublishWorkflow {
375 kind: PublishKind::Crates,
376 config: crates,
377 });
378 }
379 }
380 Some(PublishKind::Npm) => {
381 let Some(npm) = config.npm.clone().filter(is_enabled_target) else {
382 return Err(
383 "No enabled npm publish target is configured in `.xbp/xbp.yaml`.".to_string(),
384 );
385 };
386 workflows.push(PublishWorkflow {
387 kind: PublishKind::Npm,
388 config: npm,
389 });
390 }
391 Some(PublishKind::Crates) => {
392 let Some(crates) = config.crates.clone().filter(is_enabled_target) else {
393 return Err(
394 "No enabled crates publish target is configured in `.xbp/xbp.yaml`."
395 .to_string(),
396 );
397 };
398 workflows.push(PublishWorkflow {
399 kind: PublishKind::Crates,
400 config: crates,
401 });
402 }
403 }
404
405 Ok(workflows)
406}
407
408fn normalize_target_filter(target: Option<&str>) -> Result<Option<PublishKind>, String> {
409 match target.map(|value| value.trim().to_ascii_lowercase()) {
410 None => Ok(None),
411 Some(value) if value.is_empty() => Ok(None),
412 Some(value) if value == "npm" => Ok(Some(PublishKind::Npm)),
413 Some(value) if value == "crates" || value == "crates.io" => Ok(Some(PublishKind::Crates)),
414 Some(value) => Err(format!(
415 "Unsupported publish target `{}`. Use `npm` or `crates`.",
416 value
417 )),
418 }
419}
420
421fn is_enabled_target(config: &PublishTargetConfig) -> bool {
422 config.enabled.unwrap_or(true)
423}
424
425fn prepare_publish_target(
426 project_root: &Path,
427 workflow: PublishWorkflow,
428 options: &PublishCommandOptions,
429 manifest_override: Option<&Path>,
430) -> Result<PreparedPublishTarget, String> {
431 let configured_working_directory = workflow
432 .config
433 .working_directory
434 .clone()
435 .map(PathBuf::from)
436 .unwrap_or_else(|| project_root.to_path_buf());
437
438 let manifest_path = manifest_override
439 .map(PathBuf::from)
440 .or_else(|| workflow.config.manifest_path.clone().map(PathBuf::from))
441 .unwrap_or_else(|| default_manifest_path(&configured_working_directory, workflow.kind));
442 let working_directory = manifest_override
443 .and_then(Path::parent)
444 .map(Path::to_path_buf)
445 .unwrap_or(configured_working_directory);
446
447 if !manifest_path.exists() {
448 return Err(format!(
449 "Configured manifest path does not exist for {}: {}",
450 workflow.kind.label(),
451 manifest_path.display()
452 ));
453 }
454
455 let package_name = if manifest_override.is_some() {
456 detect_package_name(workflow.kind, &manifest_path).unwrap_or_else(|| "package".to_string())
457 } else {
458 workflow
459 .config
460 .package_name
461 .clone()
462 .filter(|value| !value.trim().is_empty())
463 .unwrap_or_else(|| {
464 detect_package_name(workflow.kind, &manifest_path)
465 .unwrap_or_else(|| "package".to_string())
466 })
467 };
468 let version = detect_package_version(workflow.kind, &manifest_path)?;
469 if let Some(expected_version) = &options.expected_version {
470 if expected_version.trim() != version {
471 return Err(format!(
472 "Configured {} publish target resolved version {} but release target is {}.",
473 workflow.kind.label(),
474 version,
475 expected_version.trim()
476 ));
477 }
478 }
479
480 let token = resolve_publish_token(
481 project_root,
482 workflow.kind,
483 workflow.config.token.as_deref(),
484 );
485 let preflight_commands = workflow
486 .config
487 .preflight_commands
488 .iter()
489 .map(|value| value.trim().to_string())
490 .filter(|value| !value.is_empty())
491 .collect::<Vec<_>>();
492 let publish_command = workflow
493 .config
494 .publish_command
495 .clone()
496 .filter(|value| !value.trim().is_empty())
497 .unwrap_or_else(|| default_publish_command(workflow.kind, &workflow.config));
498
499 Ok(PreparedPublishTarget {
500 kind: workflow.kind,
501 package_name,
502 version,
503 working_directory,
504 manifest_path,
505 preflight_commands,
506 publish_command,
507 use_wsl: workflow.config.use_wsl.unwrap_or(false),
508 wsl_distribution: workflow.config.wsl_distribution.clone(),
509 token,
510 workspace_publish: None,
511 })
512}
513
514fn default_manifest_path(working_directory: &Path, kind: PublishKind) -> PathBuf {
515 match kind {
516 PublishKind::Npm => working_directory.join("package.json"),
517 PublishKind::Crates => working_directory.join("Cargo.toml"),
518 }
519}
520
521fn detect_package_name(kind: PublishKind, manifest_path: &Path) -> Option<String> {
522 match kind {
523 PublishKind::Npm => {
524 let content = fs::read_to_string(manifest_path).ok()?;
525 let json: JsonValue = serde_json::from_str(&content).ok()?;
526 json.get("name")
527 .and_then(|value| value.as_str())
528 .map(str::trim)
529 .filter(|value| !value.is_empty())
530 .map(str::to_string)
531 }
532 PublishKind::Crates => {
533 let content = fs::read_to_string(manifest_path).ok()?;
534 let toml: TomlValue = toml::from_str(&content).ok()?;
535 toml.get("package")
536 .and_then(|package| package.get("name"))
537 .and_then(|value| value.as_str())
538 .map(str::trim)
539 .filter(|value| !value.is_empty())
540 .map(str::to_string)
541 }
542 }
543}
544
545fn detect_package_version(kind: PublishKind, manifest_path: &Path) -> Result<String, String> {
546 match kind {
547 PublishKind::Npm => {
548 let content = fs::read_to_string(manifest_path).map_err(|e| {
549 format!(
550 "Failed to read npm manifest {}: {}",
551 manifest_path.display(),
552 e
553 )
554 })?;
555 let json: JsonValue = serde_json::from_str(&content).map_err(|e| {
556 format!(
557 "Failed to parse npm manifest {}: {}",
558 manifest_path.display(),
559 e
560 )
561 })?;
562 json.get("version")
563 .and_then(|value| value.as_str())
564 .map(str::trim)
565 .filter(|value| !value.is_empty())
566 .map(str::to_string)
567 .ok_or_else(|| {
568 format!(
569 "Could not resolve package.version from {}.",
570 manifest_path.display()
571 )
572 })
573 }
574 PublishKind::Crates => resolve_cargo_package_version_required(manifest_path),
575 }
576}
577
578fn resolve_publish_token(
579 project_root: &Path,
580 kind: PublishKind,
581 configured: Option<&str>,
582) -> Option<String> {
583 let token_from_config =
584 configured.and_then(|raw| resolve_publish_placeholder(project_root, raw));
585 if token_from_config.is_some() {
586 return token_from_config;
587 }
588
589 match kind {
590 PublishKind::Npm => resolve_npm_token(),
591 PublishKind::Crates => resolve_crates_token(),
592 }
593}
594
595fn resolve_publish_placeholder(project_root: &Path, raw: &str) -> Option<String> {
596 let trimmed = raw.trim();
597 if trimmed.is_empty() {
598 return None;
599 }
600
601 let mut env_map = HashMap::new();
602 env_map.insert("TOKEN".to_string(), trimmed.to_string());
603 let resolved = resolve_env_placeholders(project_root, &env_map)
604 .remove("TOKEN")
605 .unwrap_or_else(|| trimmed.to_string());
606
607 if looks_like_placeholder(&resolved) {
608 None
609 } else {
610 Some(resolved)
611 }
612}
613
614fn looks_like_placeholder(value: &str) -> bool {
615 let trimmed = value.trim();
616 trimmed.starts_with("${") && trimmed.ends_with('}') || trimmed.starts_with('$')
617}
618
619fn default_publish_command(kind: PublishKind, config: &PublishTargetConfig) -> String {
620 match kind {
621 PublishKind::Npm => {
622 if let Some(access) = config
623 .access
624 .as_deref()
625 .map(str::trim)
626 .filter(|value| !value.is_empty())
627 {
628 format!("npm publish --access {}", access)
629 } else {
630 "npm publish".to_string()
631 }
632 }
633 PublishKind::Crates => "cargo publish".to_string(),
634 }
635}
636
637fn ensure_clean_git_worktree(project_root: &Path) -> Result<(), String> {
638 if !command_exists("git") {
639 return Ok(());
640 }
641
642 let output = std::process::Command::new("git")
643 .current_dir(project_root)
644 .args(["status", "--porcelain=v1", "--untracked-files=all"])
645 .output()
646 .map_err(|e| format!("Failed to run `git status`: {}", e))?;
647
648 if !output.status.success() {
649 return Ok(());
650 }
651
652 let stdout = String::from_utf8_lossy(&output.stdout);
653 let entries = stdout
654 .lines()
655 .map(str::trim)
656 .filter(|line| !line.is_empty())
657 .take(8)
658 .collect::<Vec<_>>();
659
660 if entries.is_empty() {
661 return Ok(());
662 }
663
664 Err(format!(
665 "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
666 entries.join(", ")
667 ))
668}
669
670async fn registry_version_exists(target: &PreparedPublishTarget) -> Result<bool, String> {
671 let client = reqwest::Client::new();
672 match target.kind {
673 PublishKind::Npm => {
674 let encoded = target.package_name.replace('/', "%2f");
675 let url = format!("https://registry.npmjs.org/{}/{}", encoded, target.version);
676 let response = client
677 .get(url)
678 .send()
679 .await
680 .map_err(|e| format!("Failed to query npm registry: {}", e))?;
681 Ok(response.status().is_success())
682 }
683 PublishKind::Crates => {
684 let url = format!(
685 "https://crates.io/api/v1/crates/{}/{}",
686 target.package_name, target.version
687 );
688 let response = client
689 .get(url)
690 .send()
691 .await
692 .map_err(|e| format!("Failed to query crates.io: {}", e))?;
693 Ok(response.status().is_success())
694 }
695 }
696}
697
698async fn maybe_attach_workspace_publish_resolution(
699 target: &mut PreparedPublishTarget,
700 options: &PublishCommandOptions,
701 progress: &PublishProgress<'_>,
702) -> Result<(), String> {
703 if target.kind != PublishKind::Crates {
704 return Ok(());
705 }
706
707 let resolution =
708 resolve_manifest_workspace_publish(&target.manifest_path, options.include_prereqs).await?;
709 let missing_prereqs = collect_missing_workspace_prereqs(&resolution);
710 let needs_expansion = resolution.publish_order.len() > 1;
711 if missing_prereqs.is_empty() && !needs_expansion {
712 let blockers = collect_workspace_publish_blockers(&resolution);
713 if !blockers.is_empty() {
714 return Err(render_workspace_publish_blocker_error(
715 target,
716 &resolution,
717 &blockers,
718 options,
719 ));
720 }
721 return Ok(());
722 }
723
724 if !supports_workspace_auto_resolution(&target.publish_command) {
725 if delegates_workspace_publish(&target.publish_command) {
726 progress.log(&format!(
727 "Using configured workspace publish delegate for {} ({} package closure); running publish command as-is.",
728 target.identity(),
729 resolution.publish_order.len()
730 ));
731 return Ok(());
732 }
733 return Err(render_custom_workspace_publish_error(target, &resolution));
734 }
735
736 if !missing_prereqs.is_empty() && !options.include_prereqs {
737 return Err(render_workspace_prereq_required_error(
738 target,
739 &resolution,
740 &missing_prereqs,
741 options,
742 ));
743 }
744
745 let blockers = collect_workspace_publish_blockers(&resolution);
746 if !blockers.is_empty() {
747 return Err(render_workspace_publish_blocker_error(
748 target,
749 &resolution,
750 &blockers,
751 options,
752 ));
753 }
754
755 if !resolution.included_prereqs.is_empty() {
756 progress.log(&format!(
757 "Resolved workspace prerequisite closure for {}: {}",
758 target.identity(),
759 resolution.required_closure.join(" -> ")
760 ));
761 }
762 target.workspace_publish = Some(resolution);
763 Ok(())
764}
765
766fn collect_workspace_publish_blockers(
767 resolution: &ManifestWorkspacePublishResolution,
768) -> Vec<String> {
769 resolution
770 .packages
771 .iter()
772 .filter(|item| {
773 item.crates_io_visible != Some(true)
774 && (!item.blocked_by.is_empty() || !item.publishable)
775 })
776 .map(|item| {
777 if !item.blocked_by.is_empty() {
778 format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
779 } else {
780 format!("{} {}", item.package, item.reason)
781 }
782 })
783 .collect()
784}
785
786fn collect_missing_workspace_prereqs(
787 resolution: &ManifestWorkspacePublishResolution,
788) -> Vec<String> {
789 let mut missing = Vec::new();
790 for item in &resolution.packages {
791 for dependency in &item.blocked_by {
792 if !missing.contains(dependency) {
793 missing.push(dependency.clone());
794 }
795 }
796 }
797 missing
798}
799
800fn render_workspace_publish_blocker_error(
801 target: &PreparedPublishTarget,
802 resolution: &ManifestWorkspacePublishResolution,
803 blockers: &[String],
804 options: &PublishCommandOptions,
805) -> String {
806 let mut message = String::new();
807 message.push_str(&format!(
808 "Workspace publish is blocked for {}.\n",
809 target.identity()
810 ));
811 message.push_str(&format!(
812 "Requested package: {}\n",
813 resolution.requested_package
814 ));
815 if !resolution.included_prereqs.is_empty() {
816 message.push_str(&format!(
817 "Auto-included prerequisites: {}\n",
818 resolution.included_prereqs.join(", ")
819 ));
820 }
821 if !resolution.required_closure.is_empty() {
822 message.push_str(&format!(
823 "Required publish order: {}\n",
824 resolution.required_closure.join(" -> ")
825 ));
826 }
827 message.push_str("Blockers:\n");
828 for blocker in blockers {
829 message.push_str(&format!("- {}\n", blocker));
830 }
831 message.push_str("Inspect with:\n");
832 message.push_str(&format!(
833 " {}\n",
834 render_workspace_publish_plan_command(resolution, options)
835 ));
836 message.trim_end().to_string()
837}
838
839fn render_workspace_prereq_required_error(
840 target: &PreparedPublishTarget,
841 resolution: &ManifestWorkspacePublishResolution,
842 missing_prereqs: &[String],
843 options: &PublishCommandOptions,
844) -> String {
845 let mut message = String::new();
846 message.push_str(&format!(
847 "Workspace prerequisite closure detected for {}.\n",
848 target.identity()
849 ));
850 message.push_str(&format!(
851 "Requested package: {}\n",
852 resolution.requested_package
853 ));
854 if !resolution.required_closure.is_empty() {
855 message.push_str(&format!(
856 "Required publish order: {}\n",
857 resolution.required_closure.join(" -> ")
858 ));
859 }
860 if !missing_prereqs.is_empty() {
861 message.push_str(&format!(
862 "Missing internal prerequisites: {}\n",
863 missing_prereqs.join(", ")
864 ));
865 }
866 message.push_str("Rerun with:\n");
867 message.push_str(&format!(
868 " {}\n",
869 render_publish_include_prereqs_command(target, options)
870 ));
871 message.push_str("Or inspect with:\n");
872 message.push_str(&format!(
873 " {}\n",
874 render_workspace_publish_plan_command(resolution, options)
875 ));
876 message.trim_end().to_string()
877}
878
879fn render_custom_workspace_publish_error(
880 target: &PreparedPublishTarget,
881 resolution: &ManifestWorkspacePublishResolution,
882) -> String {
883 let mut message = String::new();
884 message.push_str(&format!(
885 "Workspace closure detected for {}, but the configured crates publish command cannot be safely expanded.\n",
886 target.identity()
887 ));
888 message.push_str(&format!(
889 "Configured publish command: {}\n",
890 target.publish_command
891 ));
892 message.push_str(&format!(
893 "Requested package: {}\n",
894 resolution.requested_package
895 ));
896 if !resolution.required_closure.is_empty() {
897 message.push_str(&format!(
898 "Required publish order: {}\n",
899 resolution.required_closure.join(" -> ")
900 ));
901 }
902 message.push_str("Use the workspace publish workflow instead:\n");
903 message.push_str(&format!(
904 " xbp version workspace publish plan --repo {} --only {} --include-prereqs\n",
905 quote_path_for_message(&resolution.workspace_root),
906 resolution.requested_package
907 ));
908 message.push_str(&format!(
909 " xbp version workspace publish run --repo {} --only {} --include-prereqs",
910 quote_path_for_message(&resolution.workspace_root),
911 resolution.requested_package
912 ));
913 message
914}
915
916fn delegates_workspace_publish(command: &str) -> bool {
917 let normalized = command.to_ascii_lowercase();
918 normalized.contains("publish_workspace.py")
919 || normalized.contains("version workspace publish run")
920}
921
922fn supports_workspace_auto_resolution(command: &str) -> bool {
923 let normalized = command.trim();
924 if normalized.is_empty() {
925 return false;
926 }
927 if normalized.contains("&&")
928 || normalized.contains("||")
929 || normalized.contains(';')
930 || normalized.contains('|')
931 || normalized.contains('\n')
932 || normalized.contains('\r')
933 || normalized.contains('`')
934 {
935 return false;
936 }
937 if normalized.contains("--manifest-path") {
938 return false;
939 }
940 normalized == "cargo publish"
941 || normalized.starts_with("cargo publish ")
942 || normalized == "cargo.exe publish"
943 || normalized.starts_with("cargo.exe publish ")
944}
945
946async fn run_workspace_publish_target(
947 target: &PreparedPublishTarget,
948 resolution: &ManifestWorkspacePublishResolution,
949 options: &PublishCommandOptions,
950 progress: Option<&PublishProgress<'_>>,
951) -> Result<(), String> {
952 if let Some(progress) = progress {
953 progress.log(&format!(
954 "Publishing workspace closure for {} in order: {}",
955 target.identity(),
956 resolution.required_closure.join(" -> ")
957 ));
958 }
959 for publish_target in &resolution.publish_order {
960 let mut step_target = target.clone();
961 step_target.package_name = publish_target.package.clone();
962 step_target.version = publish_target.version.clone();
963 step_target.manifest_path = publish_target.manifest_path.clone();
964 step_target.workspace_publish = None;
965 run_publish_target(&step_target, options, progress).await?;
966 wait_for_registry_version(
967 &step_target,
968 WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
969 WORKSPACE_PUBLISH_VISIBILITY_POLL,
970 )
971 .await?;
972 }
973 Ok(())
974}
975
976async fn wait_for_registry_version(
977 target: &PreparedPublishTarget,
978 timeout: Duration,
979 poll: Duration,
980) -> Result<(), String> {
981 let deadline = Instant::now() + timeout;
982 loop {
983 if registry_version_exists(target).await? {
984 return Ok(());
985 }
986 if Instant::now() >= deadline {
987 return Err(format!(
988 "{} {}@{} was published, but did not become visible on {} within {}s.",
989 "Timed out waiting for registry visibility:"
990 .bright_red()
991 .bold(),
992 target.package_name,
993 target.version,
994 target.kind.label(),
995 timeout.as_secs()
996 ));
997 }
998 sleep(poll).await;
999 }
1000}
1001
1002fn render_publish_include_prereqs_command(
1003 target: &PreparedPublishTarget,
1004 options: &PublishCommandOptions,
1005) -> String {
1006 let mut parts = vec!["xbp publish".to_string(), "--target crates".to_string()];
1007 if options.allow_dirty {
1008 parts.push("--allow-dirty".to_string());
1009 }
1010 if options.force {
1011 parts.push("--force".to_string());
1012 }
1013 parts.push("--include-prereqs".to_string());
1014 parts.push(format!(
1015 "--manifest-path {}",
1016 quote_path_for_message(&target.manifest_path)
1017 ));
1018 parts.join(" ")
1019}
1020
1021fn render_workspace_publish_plan_command(
1022 resolution: &ManifestWorkspacePublishResolution,
1023 options: &PublishCommandOptions,
1024) -> String {
1025 let mut parts = vec![
1026 "xbp version workspace publish plan".to_string(),
1027 format!(
1028 "--repo {}",
1029 quote_path_for_message(&resolution.workspace_root)
1030 ),
1031 format!("--only {}", resolution.requested_package),
1032 ];
1033 if options.include_prereqs || resolution.required_closure.len() > 1 {
1034 parts.push("--include-prereqs".to_string());
1035 }
1036 parts.join(" ")
1037}
1038
1039fn quote_path_for_message(path: &Path) -> String {
1040 let value = path.to_string_lossy();
1041 if value.contains(' ') {
1042 format!("\"{}\"", value)
1043 } else {
1044 value.to_string()
1045 }
1046}
1047
1048async fn run_publish_target(
1049 target: &PreparedPublishTarget,
1050 options: &PublishCommandOptions,
1051 progress: Option<&PublishProgress<'_>>,
1052) -> Result<(), String> {
1053 match target.kind {
1054 PublishKind::Npm => {
1055 let token = target.token.clone().ok_or_else(|| {
1056 "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()
1057 })?;
1058 let npmrc = TemporaryNpmrc::create()?;
1059 let mut extra_env = HashMap::new();
1060 extra_env.insert("NPM_TOKEN".to_string(), token);
1061 extra_env.insert(
1062 "NPM_CONFIG_USERCONFIG".to_string(),
1063 npmrc.path.to_string_lossy().to_string(),
1064 );
1065
1066 let result = run_configured_command(
1067 &target.publish_command,
1068 &target.publish_command,
1069 target,
1070 Some(&extra_env),
1071 progress,
1072 CommandStage::Publish,
1073 )
1074 .await?;
1075 if !result.success {
1076 return Err(format!(
1077 "npm publish failed for {}@{}.\n{}",
1078 target.package_name,
1079 target.version,
1080 result.output_detail()
1081 ));
1082 }
1083 Ok(())
1084 }
1085 PublishKind::Crates => {
1086 let token = target.token.clone().ok_or_else(|| {
1087 "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()
1088 })?;
1089
1090 let uses_workspace_delegate = delegates_workspace_publish(&target.publish_command);
1091 let publish_display_command =
1092 append_allow_dirty_arg(&render_publish_command(target), options.allow_dirty);
1093 let (publish_command, extra_env) = if uses_workspace_delegate {
1094 let mut env = HashMap::new();
1095 env.insert("CARGO_REGISTRY_TOKEN".to_string(), token.clone());
1096 env.insert("CARGO_REGISTRIES_CRATES_IO_TOKEN".to_string(), token);
1097 (publish_display_command.clone(), Some(env))
1098 } else if publish_display_command.contains("--token") {
1099 (publish_display_command.clone(), None)
1100 } else {
1101 (
1102 format!(
1103 "{} --token {}",
1104 publish_display_command,
1105 quote_for_shell(&token, target.use_wsl)
1106 ),
1107 None,
1108 )
1109 };
1110 let result = run_configured_command(
1111 &publish_command,
1112 &publish_display_command,
1113 target,
1114 extra_env.as_ref(),
1115 progress,
1116 CommandStage::Publish,
1117 )
1118 .await?;
1119 if !result.success {
1120 let action = if uses_workspace_delegate {
1121 "workspace publish command failed"
1122 } else {
1123 "cargo publish failed"
1124 };
1125 return Err(format!(
1126 "{} for {}@{}.\n{}",
1127 action,
1128 target.package_name,
1129 target.version,
1130 result.output_detail()
1131 ));
1132 }
1133 Ok(())
1134 }
1135 }
1136}
1137
1138async fn run_configured_command(
1139 command_line: &str,
1140 display_command: &str,
1141 target: &PreparedPublishTarget,
1142 extra_env: Option<&HashMap<String, String>>,
1143 progress: Option<&PublishProgress<'_>>,
1144 stage: CommandStage,
1145) -> Result<CommandResult, String> {
1146 if let Some(progress) = progress {
1147 progress.log(&format!(
1148 "Running {} for {} via {}: {}",
1149 stage.label(),
1150 target.identity(),
1151 target.runner_label(),
1152 display_command
1153 ));
1154 }
1155
1156 let effective_command_line = if target.use_wsl {
1157 extra_env
1158 .map(|env| prepend_shell_exports(command_line, env))
1159 .unwrap_or_else(|| command_line.to_string())
1160 } else {
1161 command_line.to_string()
1162 };
1163
1164 let mut command = build_shell_command(
1165 &effective_command_line,
1166 &target.working_directory,
1167 target.use_wsl,
1168 target.wsl_distribution.as_deref(),
1169 )?;
1170 if !target.use_wsl {
1171 if let Some(extra_env) = extra_env {
1172 command.envs(extra_env);
1173 }
1174 }
1175 command.stdout(Stdio::piped());
1176 command.stderr(Stdio::piped());
1177
1178 let mut child = command
1179 .spawn()
1180 .map_err(|e| format!("Failed to run `{}`: {}", command_line, e))?;
1181 let child_stdout = child
1182 .stdout
1183 .take()
1184 .ok_or_else(|| format!("Failed to capture stdout for `{}`.", command_line))?;
1185 let child_stderr = child
1186 .stderr
1187 .take()
1188 .ok_or_else(|| format!("Failed to capture stderr for `{}`.", command_line))?;
1189
1190 let stdout_capture =
1191 tokio::spawn(async move { mirror_command_stream(child_stdout, stdout()).await });
1192 let stderr_capture =
1193 tokio::spawn(async move { mirror_command_stream(child_stderr, stderr()).await });
1194 let started_at = Instant::now();
1195 let status = if let Some(progress) = progress {
1196 let mut wait = Box::pin(child.wait());
1197 let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
1198 heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
1199 heartbeat.tick().await;
1200 loop {
1201 tokio::select! {
1202 status = &mut wait => break status,
1203 _ = heartbeat.tick() => {
1204 progress.log(&format!(
1205 "Still running {} for {} via {} (elapsed {}): {}",
1206 stage.label(),
1207 target.identity(),
1208 target.runner_label(),
1209 format_elapsed(started_at.elapsed()),
1210 display_command
1211 ));
1212 }
1213 }
1214 }
1215 } else {
1216 child.wait().await
1217 };
1218 let status = status.map_err(|e| format!("Failed to wait for `{}`: {}", command_line, e))?;
1219 let stdout = stdout_capture.await.map_err(|e| {
1220 format!(
1221 "Failed to join stdout capture for `{}`: {}",
1222 command_line, e
1223 )
1224 })??;
1225 let stderr = stderr_capture.await.map_err(|e| {
1226 format!(
1227 "Failed to join stderr capture for `{}`: {}",
1228 command_line, e
1229 )
1230 })??;
1231
1232 if let Some(progress) = progress {
1233 progress.log(&format!(
1234 "Completed {} for {} in {}.",
1235 stage.label(),
1236 target.identity(),
1237 format_elapsed(started_at.elapsed())
1238 ));
1239 }
1240
1241 let result = CommandResult {
1242 success: status.success(),
1243 exit_code: status.code(),
1244 stdout,
1245 stderr,
1246 };
1247 persist_command_transcript(
1248 stage,
1249 target,
1250 display_command,
1251 started_at.elapsed(),
1252 &result,
1253 )
1254 .await;
1255
1256 Ok(result)
1257}
1258
1259async fn mirror_command_stream<R, W>(mut reader: R, mut writer: W) -> Result<String, String>
1260where
1261 R: AsyncRead + Unpin,
1262 W: AsyncWrite + Unpin,
1263{
1264 let mut buffer = [0_u8; 8192];
1265 let mut captured = Vec::new();
1266
1267 loop {
1268 let read_len = reader
1269 .read(&mut buffer)
1270 .await
1271 .map_err(|e| format!("Failed to read command output: {e}"))?;
1272 if read_len == 0 {
1273 break;
1274 }
1275 writer
1276 .write_all(&buffer[..read_len])
1277 .await
1278 .map_err(|e| format!("Failed to write command output: {e}"))?;
1279 writer
1280 .flush()
1281 .await
1282 .map_err(|e| format!("Failed to flush command output: {e}"))?;
1283 captured.extend_from_slice(&buffer[..read_len]);
1284 }
1285
1286 Ok(String::from_utf8_lossy(&captured).trim_end().to_string())
1287}
1288
1289fn build_shell_command(
1290 command_line: &str,
1291 working_directory: &Path,
1292 use_wsl: bool,
1293 wsl_distribution: Option<&str>,
1294) -> Result<Command, String> {
1295 #[cfg(target_os = "windows")]
1296 {
1297 if use_wsl {
1298 if !command_exists("wsl.exe") {
1299 return Err(
1300 "WSL was requested for this publish target, but `wsl.exe` is not available."
1301 .to_string(),
1302 );
1303 }
1304 let mut command = Command::new("wsl.exe");
1305 if let Some(distribution) = wsl_distribution
1306 .map(str::trim)
1307 .filter(|value| !value.is_empty())
1308 {
1309 command.args(["-d", distribution]);
1310 }
1311 let wsl_dir = windows_path_to_wsl(working_directory)?;
1312 let script = format!("cd {} && {}", quote_for_shell(&wsl_dir, true), command_line);
1313 command.args(["sh", "-lc", &script]);
1314 return Ok(command);
1315 }
1316
1317 let mut command = Command::new("cmd");
1318 command
1319 .current_dir(working_directory)
1320 .args(["/C", command_line]);
1321 Ok(command)
1322 }
1323
1324 #[cfg(not(target_os = "windows"))]
1325 {
1326 let _ = use_wsl;
1327 let _ = wsl_distribution;
1328 let mut command = Command::new("sh");
1329 command
1330 .current_dir(working_directory)
1331 .args(["-lc", command_line]);
1332 Ok(command)
1333 }
1334}
1335
1336#[cfg(target_os = "windows")]
1337fn windows_path_to_wsl(path: &Path) -> Result<String, String> {
1338 let rendered = path.to_string_lossy().replace('\\', "/");
1339 let mut chars = rendered.chars();
1340 let drive = chars
1341 .next()
1342 .ok_or_else(|| format!("Could not convert {} to a WSL path.", path.display()))?;
1343 if chars.next() != Some(':') {
1344 return Err(format!(
1345 "Could not convert {} to a WSL path.",
1346 path.display()
1347 ));
1348 }
1349 let remainder = chars.as_str().trim_start_matches('/');
1350 Ok(format!("/mnt/{}/{}", drive.to_ascii_lowercase(), remainder))
1351}
1352
1353fn render_publish_status(
1354 step_prefix: Option<&str>,
1355 step: usize,
1356 total: usize,
1357 detail: &str,
1358) -> String {
1359 match step_prefix {
1360 Some(prefix) => format!("{} {}", prefix, detail),
1361 None => format!("[{}/{}] {}", step, total, detail),
1362 }
1363}
1364
1365fn print_publish_completion(plan: &PublishPlanResult, dry_run: bool) {
1366 if dry_run {
1367 return;
1368 }
1369
1370 for target in &plan.prepared {
1371 if let Some(workspace_publish) = target.workspace_publish.as_ref() {
1372 for publish_target in &workspace_publish.publish_order {
1373 println!(
1374 "{} {}@{} via {}",
1375 "Published".bright_green().bold(),
1376 publish_target.package.bright_white(),
1377 publish_target.version.bright_white(),
1378 target.kind.label()
1379 );
1380 }
1381 continue;
1382 }
1383 println!(
1384 "{} {}@{} via {}",
1385 "Published".bright_green().bold(),
1386 target.package_name.bright_white(),
1387 target.version.bright_white(),
1388 target.kind.label()
1389 );
1390 }
1391}
1392
1393fn format_elapsed(duration: Duration) -> String {
1394 let seconds = duration.as_secs();
1395 if seconds == 0 {
1396 "<1s".to_string()
1397 } else if seconds < 60 {
1398 format!("{}s", seconds)
1399 } else {
1400 format!("{}m{}s", seconds / 60, seconds % 60)
1401 }
1402}
1403
1404fn print_publish_plan(prepared: &[PreparedPublishTarget], force: bool) {
1405 println!("{}", "Publish plan".bright_cyan().bold());
1406 for target in prepared {
1407 println!(
1408 " {} {}@{}",
1409 target.kind.label().bright_white().bold(),
1410 target.package_name,
1411 target.version
1412 );
1413 println!(" cwd: {}", target.working_directory.display());
1414 println!(" manifest: {}", target.manifest_path.display());
1415 if let Some(preflight_line) = render_preflight_plan(&target.preflight_commands, force) {
1416 println!(" preflight: {}", preflight_line);
1417 }
1418 println!(" publish: {}", render_publish_command(target));
1419 if let Some(workspace_publish) = target.workspace_publish.as_ref() {
1420 println!(" requested: {}", workspace_publish.requested_package);
1421 println!(
1422 " required closure: {}",
1423 workspace_publish.required_closure.join(" -> ")
1424 );
1425 if !workspace_publish.included_prereqs.is_empty() {
1426 println!(
1427 " auto-prereqs: {}",
1428 workspace_publish.included_prereqs.join(", ")
1429 );
1430 }
1431 if !workspace_publish.publish_order.is_empty() {
1432 println!(
1433 " expanded publish: {}",
1434 workspace_publish
1435 .publish_order
1436 .iter()
1437 .map(|item| format!("{}@{}", item.package, item.version))
1438 .collect::<Vec<_>>()
1439 .join(", ")
1440 );
1441 }
1442 }
1443 if target.use_wsl {
1444 println!(
1445 " runner: WSL{}",
1446 target
1447 .wsl_distribution
1448 .as_deref()
1449 .map(|value| format!(" ({})", value))
1450 .unwrap_or_default()
1451 );
1452 }
1453 }
1454}
1455
1456fn render_preflight_plan(preflight_commands: &[String], force: bool) -> Option<String> {
1457 if preflight_commands.is_empty() {
1458 return None;
1459 }
1460
1461 let joined = preflight_commands.join(" && ");
1462 if force {
1463 Some(format!("skipped via --force (configured: {})", joined))
1464 } else {
1465 Some(joined)
1466 }
1467}
1468
1469async fn persist_command_transcript(
1470 stage: CommandStage,
1471 target: &PreparedPublishTarget,
1472 display_command: &str,
1473 elapsed: Duration,
1474 result: &CommandResult,
1475) {
1476 let process_name = render_process_log_name(stage, target, display_command);
1477 let summary = render_process_log_summary(target, result);
1478 let level = if result.success {
1479 LogLevel::Info
1480 } else {
1481 LogLevel::Warning
1482 };
1483
1484 let _ = log_file_only(
1485 level,
1486 "publish",
1487 &format!("Completed {}", process_name),
1488 Some(&summary),
1489 Some(elapsed.as_millis() as u64),
1490 )
1491 .await;
1492 let _ = log_process_output_file_only("publish", &process_name, &result.stdout, &result.stderr)
1493 .await;
1494}
1495
1496fn render_process_log_name(
1497 stage: CommandStage,
1498 target: &PreparedPublishTarget,
1499 display_command: &str,
1500) -> String {
1501 format!(
1502 "{} {} command `{}`",
1503 stage.label(),
1504 target.identity(),
1505 display_command
1506 )
1507}
1508
1509fn render_process_log_summary(target: &PreparedPublishTarget, result: &CommandResult) -> String {
1510 format!(
1511 "runner={} cwd={} success={} exit_code={}",
1512 target.runner_label(),
1513 target.working_directory.display(),
1514 result.success,
1515 result
1516 .exit_code
1517 .map(|value| value.to_string())
1518 .unwrap_or_else(|| "signal".to_string())
1519 )
1520}
1521
1522struct CommandResult {
1523 success: bool,
1524 exit_code: Option<i32>,
1525 stdout: String,
1526 stderr: String,
1527}
1528
1529impl CommandResult {
1530 fn output_detail(&self) -> String {
1531 match (self.stdout.trim().is_empty(), self.stderr.trim().is_empty()) {
1532 (false, false) => format!("stdout:\n{}\n\nstderr:\n{}", self.stdout, self.stderr),
1533 (false, true) => format!("stdout:\n{}", self.stdout),
1534 (true, false) => format!("stderr:\n{}", self.stderr),
1535 (true, true) => "Command produced no output.".to_string(),
1536 }
1537 }
1538}
1539
1540struct TemporaryNpmrc {
1541 path: PathBuf,
1542}
1543
1544impl TemporaryNpmrc {
1545 fn create() -> Result<Self, String> {
1546 let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
1547 fs::write(&path, "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n").map_err(|e| {
1548 format!(
1549 "Failed to create temporary .npmrc {}: {}",
1550 path.display(),
1551 e
1552 )
1553 })?;
1554 Ok(Self { path })
1555 }
1556}
1557
1558impl Drop for TemporaryNpmrc {
1559 fn drop(&mut self) {
1560 let _ = fs::remove_file(&self.path);
1561 }
1562}
1563
1564impl PublishKind {
1565 fn label(self) -> &'static str {
1566 match self {
1567 Self::Npm => "npm",
1568 Self::Crates => "crates.io",
1569 }
1570 }
1571}
1572
1573impl PreparedPublishTarget {
1574 fn identity(&self) -> String {
1575 format!(
1576 "{} {}@{}",
1577 self.kind.label(),
1578 self.package_name,
1579 self.version
1580 )
1581 }
1582
1583 fn runner_label(&self) -> String {
1584 if self.use_wsl {
1585 self.wsl_distribution
1586 .as_deref()
1587 .map(str::trim)
1588 .filter(|value| !value.is_empty())
1589 .map(|value| format!("WSL ({})", value))
1590 .unwrap_or_else(|| "WSL".to_string())
1591 } else if cfg!(target_os = "windows") {
1592 "Windows shell".to_string()
1593 } else {
1594 "local shell".to_string()
1595 }
1596 }
1597}
1598
1599fn workflow_targets_manifest(
1600 project_root: &Path,
1601 workflow: &PublishWorkflow,
1602 requested_manifest: &Path,
1603) -> bool {
1604 let working_directory = workflow
1605 .config
1606 .working_directory
1607 .clone()
1608 .map(PathBuf::from)
1609 .unwrap_or_else(|| project_root.to_path_buf());
1610 let manifest_path = workflow
1611 .config
1612 .manifest_path
1613 .clone()
1614 .map(PathBuf::from)
1615 .unwrap_or_else(|| default_manifest_path(&working_directory, workflow.kind));
1616 paths_match(&manifest_path, requested_manifest)
1617}
1618
1619fn render_publish_command(target: &PreparedPublishTarget) -> String {
1620 match target.kind {
1621 PublishKind::Npm => target.publish_command.clone(),
1622 PublishKind::Crates => {
1623 if delegates_workspace_publish(&target.publish_command) {
1624 return target.publish_command.clone();
1625 }
1626 append_manifest_path_arg(
1627 &target.publish_command,
1628 &target.manifest_path,
1629 target.use_wsl,
1630 )
1631 }
1632 }
1633}
1634
1635fn append_manifest_path_arg(
1636 command_line: &str,
1637 manifest_path: &Path,
1638 use_wsl: bool,
1639) -> String {
1640 if command_line.contains("--manifest-path") {
1641 return command_line.to_string();
1642 }
1643
1644 let rendered_path = render_manifest_path_for_runner(manifest_path, use_wsl);
1645 format!(
1646 "{} --manifest-path {}",
1647 command_line,
1648 quote_for_shell(&rendered_path, use_wsl)
1649 )
1650}
1651
1652fn render_manifest_path_for_runner(manifest_path: &Path, use_wsl: bool) -> String {
1653 #[cfg(target_os = "windows")]
1654 if use_wsl {
1655 return windows_path_to_wsl(manifest_path).unwrap_or_else(|_| {
1656 manifest_path.to_string_lossy().replace('\\', "/")
1657 });
1658 }
1659
1660 manifest_path.to_string_lossy().into_owned()
1661}
1662
1663fn is_cargo_publish_command(command: &str) -> bool {
1664 let normalized = command.trim().to_ascii_lowercase();
1665 normalized == "cargo publish"
1666 || normalized.starts_with("cargo publish ")
1667 || normalized == "cargo.exe publish"
1668 || normalized.starts_with("cargo.exe publish ")
1669}
1670
1671fn append_allow_dirty_arg(command_line: &str, allow_dirty: bool) -> String {
1672 if !allow_dirty || !is_cargo_publish_command(command_line) {
1673 return command_line.to_string();
1674 }
1675 if command_line.contains("--allow-dirty") {
1676 return command_line.to_string();
1677 }
1678 format!("{} --allow-dirty", command_line.trim_end())
1679}
1680
1681fn resolve_requested_manifest_path(current_dir: &Path, requested_path: &Path) -> PathBuf {
1682 let candidate = if requested_path.is_absolute() {
1683 requested_path.to_path_buf()
1684 } else {
1685 current_dir.join(requested_path)
1686 };
1687 normalize_windows_verbatim_path(fs::canonicalize(&candidate).unwrap_or(candidate))
1688}
1689
1690fn paths_match(left: &Path, right: &Path) -> bool {
1691 fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
1692 == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
1693}
1694
1695fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
1696 PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
1697}
1698
1699fn strip_windows_verbatim_prefix(input: &str) -> &str {
1700 input.strip_prefix(r"\\?\").unwrap_or(input)
1701}
1702
1703fn prepend_shell_exports(command_line: &str, env: &HashMap<String, String>) -> String {
1704 let exports = env
1705 .iter()
1706 .map(|(key, value)| format!("export {}={}", key, quote_for_shell(value, true)))
1707 .collect::<Vec<_>>()
1708 .join(" && ");
1709 if exports.is_empty() {
1710 command_line.to_string()
1711 } else {
1712 format!("{exports} && {command_line}")
1713 }
1714}
1715
1716fn quote_for_shell(value: &str, use_posix_rules: bool) -> String {
1717 if use_posix_rules {
1718 format!("'{}'", value.replace('\'', "'\"'\"'"))
1719 } else {
1720 format!("\"{}\"", value.replace('"', "\\\""))
1721 }
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726 use super::{
1727 append_manifest_path_arg, looks_like_placeholder, normalize_target_filter,
1728 prepare_publish_targets, render_preflight_plan, render_process_log_name,
1729 render_process_log_summary, render_publish_command, render_publish_include_prereqs_command,
1730 delegates_workspace_publish, prepend_shell_exports, render_publish_status,
1731 run_configured_command,
1732 supports_workspace_auto_resolution,
1733 CommandResult, CommandStage, PreparedPublishTarget, PublishCommandOptions, PublishKind,
1734 };
1735 use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
1736 use std::collections::HashMap;
1737 use std::fs;
1738 use std::path::{Path, PathBuf};
1739
1740 #[test]
1741 fn target_filter_accepts_supported_aliases() {
1742 assert_eq!(
1743 normalize_target_filter(Some("npm")).expect("npm filter"),
1744 Some(PublishKind::Npm)
1745 );
1746 assert_eq!(
1747 normalize_target_filter(Some("crates.io")).expect("crates filter"),
1748 Some(PublishKind::Crates)
1749 );
1750 }
1751
1752 #[test]
1753 fn placeholder_detection_handles_env_style_tokens() {
1754 assert!(looks_like_placeholder("${NPM_TOKEN}"));
1755 assert!(looks_like_placeholder("$CARGO_REGISTRY_TOKEN"));
1756 assert!(!looks_like_placeholder("plain-token"));
1757 }
1758
1759 #[test]
1760 fn prefixed_progress_reuses_parent_release_step() {
1761 assert_eq!(
1762 render_publish_status(
1763 Some("[2/9]"),
1764 2,
1765 3,
1766 "Running preflight for crates.io xbp@1.2.3"
1767 ),
1768 "[2/9] Running preflight for crates.io xbp@1.2.3"
1769 );
1770 assert_eq!(
1771 render_publish_status(None, 2, 3, "Running preflight for crates.io xbp@1.2.3"),
1772 "[2/3] Running preflight for crates.io xbp@1.2.3"
1773 );
1774 }
1775
1776 #[tokio::test]
1777 async fn run_configured_command_captures_stdout_and_stderr() {
1778 let target = PreparedPublishTarget {
1779 kind: PublishKind::Crates,
1780 package_name: "fixture".to_string(),
1781 version: "0.1.0".to_string(),
1782 working_directory: std::env::temp_dir(),
1783 manifest_path: PathBuf::from("Cargo.toml"),
1784 preflight_commands: Vec::new(),
1785 publish_command: String::new(),
1786 use_wsl: false,
1787 wsl_distribution: None,
1788 token: None,
1789 workspace_publish: None,
1790 };
1791 let command_line = if cfg!(target_os = "windows") {
1792 "echo compiling crate-a && echo compiling crate-b && echo compile failed 1>&2 && exit /b 7"
1793 } else {
1794 "printf 'compiling crate-a\\ncompiling crate-b\\n'; printf 'compile failed\\n' 1>&2; exit 7"
1795 };
1796
1797 let result = run_configured_command(
1798 command_line,
1799 command_line,
1800 &target,
1801 None,
1802 None,
1803 CommandStage::Preflight,
1804 )
1805 .await
1806 .expect("command result");
1807
1808 assert!(!result.success);
1809 assert!(result.stdout.contains("compiling crate-a"));
1810 assert!(result.stdout.contains("compiling crate-b"));
1811 assert!(result.stderr.contains("compile failed"));
1812 }
1813
1814 #[test]
1815 fn render_preflight_plan_marks_forced_skips() {
1816 let preflight_commands = vec!["pnpm test".to_string()];
1817
1818 let forced =
1819 render_preflight_plan(&preflight_commands, true).expect("forced preflight line");
1820 let normal =
1821 render_preflight_plan(&preflight_commands, false).expect("normal preflight line");
1822
1823 assert_eq!(normal, "pnpm test");
1824 assert_eq!(forced, "skipped via --force (configured: pnpm test)");
1825 }
1826
1827 #[test]
1828 fn render_process_log_helpers_include_publish_context() {
1829 let target = PreparedPublishTarget {
1830 kind: PublishKind::Npm,
1831 package_name: "@xylex-group/athena-auth-ui".to_string(),
1832 version: "1.4.0".to_string(),
1833 working_directory: PathBuf::from("C:/repo"),
1834 manifest_path: PathBuf::from("C:/repo/package.json"),
1835 preflight_commands: vec!["pnpm test".to_string()],
1836 publish_command: "npm publish".to_string(),
1837 use_wsl: false,
1838 wsl_distribution: None,
1839 token: None,
1840 workspace_publish: None,
1841 };
1842 let result = CommandResult {
1843 success: false,
1844 exit_code: Some(1),
1845 stdout: "stdout".to_string(),
1846 stderr: "stderr".to_string(),
1847 };
1848
1849 let name = render_process_log_name(CommandStage::Preflight, &target, "pnpm test");
1850 let summary = render_process_log_summary(&target, &result);
1851
1852 assert_eq!(
1853 name,
1854 "preflight npm @xylex-group/athena-auth-ui@1.4.0 command `pnpm test`"
1855 );
1856 assert!(summary.contains("runner=Windows shell") || summary.contains("runner=local shell"));
1857 assert!(summary.contains("cwd=C:\\repo") || summary.contains("cwd=C:/repo"));
1858 assert!(summary.contains("success=false"));
1859 assert!(summary.contains("exit_code=1"));
1860 }
1861
1862 #[test]
1863 fn crates_publish_command_includes_manifest_path() {
1864 let command = append_manifest_path_arg(
1865 "cargo publish",
1866 Path::new("C:/repo/crates/cli/Cargo.toml"),
1867 false,
1868 );
1869
1870 assert_eq!(
1871 command,
1872 "cargo publish --manifest-path \"C:/repo/crates/cli/Cargo.toml\""
1873 );
1874 }
1875
1876 #[test]
1877 #[cfg(target_os = "windows")]
1878 fn crates_publish_command_converts_manifest_path_for_wsl() {
1879 let command = append_manifest_path_arg(
1880 "cargo publish",
1881 Path::new("C:/repo/crates/cli/Cargo.toml"),
1882 true,
1883 );
1884
1885 assert_eq!(
1886 command,
1887 "cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml"
1888 );
1889 }
1890
1891 #[test]
1892 fn append_allow_dirty_arg_adds_flag_to_cargo_publish_with_manifest_path() {
1893 let command = append_allow_dirty_arg(
1894 "cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml",
1895 true,
1896 );
1897
1898 assert_eq!(
1899 command,
1900 "cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml --allow-dirty"
1901 );
1902 }
1903
1904 #[test]
1905 fn rendered_publish_command_preserves_existing_manifest_path() {
1906 let target = PreparedPublishTarget {
1907 kind: PublishKind::Crates,
1908 package_name: "xbp".to_string(),
1909 version: "10.30.1".to_string(),
1910 working_directory: PathBuf::from("C:/repo/crates/cli"),
1911 manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
1912 preflight_commands: Vec::new(),
1913 publish_command: "cargo publish --manifest-path crates/cli/Cargo.toml".to_string(),
1914 use_wsl: false,
1915 wsl_distribution: None,
1916 token: None,
1917 workspace_publish: None,
1918 };
1919
1920 assert_eq!(
1921 render_publish_command(&target),
1922 "cargo publish --manifest-path crates/cli/Cargo.toml"
1923 );
1924 }
1925
1926 #[test]
1927 fn prepend_shell_exports_builds_posix_export_prefix() {
1928 let mut env = HashMap::new();
1929 env.insert("CARGO_REGISTRY_TOKEN".to_string(), "abc123".to_string());
1930 assert_eq!(
1931 prepend_shell_exports("python3 scripts/publish_workspace.py", &env),
1932 "export CARGO_REGISTRY_TOKEN='abc123' && python3 scripts/publish_workspace.py"
1933 );
1934 }
1935
1936 #[test]
1937 fn workspace_delegate_publish_command_skips_manifest_path_suffix() {
1938 let target = PreparedPublishTarget {
1939 kind: PublishKind::Crates,
1940 package_name: "athena_rs".to_string(),
1941 version: "4.5.0".to_string(),
1942 working_directory: PathBuf::from("C:/repo"),
1943 manifest_path: PathBuf::from("C:/repo/Cargo.toml"),
1944 preflight_commands: Vec::new(),
1945 publish_command:
1946 "python3 scripts/publish_workspace.py --allow-dirty".to_string(),
1947 use_wsl: true,
1948 wsl_distribution: None,
1949 token: None,
1950 workspace_publish: None,
1951 };
1952
1953 assert_eq!(
1954 render_publish_command(&target),
1955 "python3 scripts/publish_workspace.py --allow-dirty"
1956 );
1957 }
1958
1959 #[test]
1960 fn workspace_delegate_detection_accepts_publish_workspace_scripts() {
1961 assert!(delegates_workspace_publish(
1962 "export CARGO_TARGET_DIR=/tmp/athena-publish-target && python3 scripts/publish_workspace.py --allow-dirty"
1963 ));
1964 assert!(delegates_workspace_publish(
1965 "xbp version workspace publish run --repo . --only athena_rs --include-prereqs"
1966 ));
1967 assert!(!delegates_workspace_publish("cargo publish"));
1968 }
1969
1970 #[test]
1971 fn workspace_auto_resolution_accepts_plain_cargo_publish_commands() {
1972 assert!(supports_workspace_auto_resolution("cargo publish"));
1973 assert!(supports_workspace_auto_resolution(
1974 "cargo publish --no-verify"
1975 ));
1976 assert!(!supports_workspace_auto_resolution(
1977 "cargo publish --manifest-path crates/cli/Cargo.toml"
1978 ));
1979 assert!(!supports_workspace_auto_resolution(
1980 "cargo publish && cargo owner --list"
1981 ));
1982 }
1983
1984 #[test]
1985 fn include_prereqs_rerun_command_preserves_publish_flags() {
1986 let target = PreparedPublishTarget {
1987 kind: PublishKind::Crates,
1988 package_name: "xbp".to_string(),
1989 version: "10.30.1".to_string(),
1990 working_directory: PathBuf::from("C:/repo/crates/cli"),
1991 manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
1992 preflight_commands: Vec::new(),
1993 publish_command: "cargo publish".to_string(),
1994 use_wsl: false,
1995 wsl_distribution: None,
1996 token: None,
1997 workspace_publish: None,
1998 };
1999 let options = PublishCommandOptions {
2000 dry_run: false,
2001 allow_dirty: true,
2002 force: true,
2003 include_prereqs: false,
2004 target: Some("crates".to_string()),
2005 manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
2006 expected_version: None,
2007 };
2008
2009 let command = render_publish_include_prereqs_command(&target, &options);
2010 assert!(command.contains("xbp publish"));
2011 assert!(command.contains("--target crates"));
2012 assert!(command.contains("--allow-dirty"));
2013 assert!(command.contains("--force"));
2014 assert!(command.contains("--include-prereqs"));
2015 assert!(command.contains("--manifest-path"));
2016 }
2017
2018 #[tokio::test]
2019 async fn manifest_override_retargets_crates_publish_workflow() {
2020 let temp_dir = temp_dir("publish-manifest-override");
2021 let cli_dir = temp_dir.join("crates").join("cli");
2022 fs::create_dir_all(&cli_dir).expect("create cli dir");
2023 fs::write(
2024 cli_dir.join("Cargo.toml"),
2025 "[package]\nname = \"xbp\"\nversion = \"10.30.1\"\n",
2026 )
2027 .expect("write cargo");
2028
2029 let config = PublishProjectConfig {
2030 npm: None,
2031 crates: Some(PublishTargetConfig {
2032 enabled: Some(true),
2033 package_name: Some("workspace".to_string()),
2034 working_directory: Some(temp_dir.to_string_lossy().to_string()),
2035 manifest_path: None,
2036 token: None,
2037 preflight_commands: Vec::new(),
2038 publish_command: Some("cargo publish".to_string()),
2039 use_wsl: Some(false),
2040 wsl_distribution: None,
2041 generate_npmrc: None,
2042 access: None,
2043 }),
2044 };
2045 let options = PublishCommandOptions {
2046 dry_run: true,
2047 allow_dirty: true,
2048 force: true,
2049 include_prereqs: false,
2050 target: Some("crates".to_string()),
2051 manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
2052 expected_version: None,
2053 };
2054
2055 let prepared = prepare_publish_targets(&temp_dir, &temp_dir, &Some(config), &options)
2056 .await
2057 .expect("prepared targets");
2058
2059 assert_eq!(prepared.len(), 1);
2060 assert_eq!(prepared[0].package_name, "xbp");
2061 assert_eq!(prepared[0].working_directory, cli_dir);
2062 assert_eq!(prepared[0].manifest_path, cli_dir.join("Cargo.toml"));
2063 }
2064
2065 fn temp_dir(label: &str) -> PathBuf {
2066 let nanos = std::time::SystemTime::now()
2067 .duration_since(std::time::UNIX_EPOCH)
2068 .expect("system clock should be after epoch")
2069 .as_nanos();
2070 let dir = std::env::temp_dir().join(format!("xbp-{label}-{nanos}"));
2071 fs::create_dir_all(&dir).expect("create temp dir");
2072 dir
2073 }
2074}