Skip to main content

dnx_core/
publish.rs

1use crate::errors::{DnxError, Result};
2use crate::package_json::PackageJson;
3use crate::workspace::Workspace;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7/// Handles publishing packages to registries and creating tarballs.
8pub struct Publisher {
9    registry_url: String,
10    auth_token: Option<String>,
11}
12
13impl Publisher {
14    pub fn new(registry_url: String, auth_token: Option<String>) -> Self {
15        Self {
16            registry_url,
17            auth_token,
18        }
19    }
20
21    /// Publish a single package to the registry.
22    /// Shells out to `npm publish` with proper auth configuration.
23    pub fn publish(
24        &self,
25        package_dir: &Path,
26        tag: Option<&str>,
27        dry_run: bool,
28        access: Option<&str>,
29    ) -> Result<()> {
30        let pkg = PackageJson::read(&package_dir.join("package.json"))?;
31        let name = pkg.name.as_deref().unwrap_or("unknown");
32        let version = pkg.version.as_deref().unwrap_or("0.0.0");
33
34        eprintln!(
35            "\x1b[34m   Publishing {}@{} to {}\x1b[0m",
36            name, version, self.registry_url
37        );
38
39        // Prepare the package: resolve workspace: and catalog: references
40        let prepared_dir = self.prepare_for_publish(package_dir)?;
41
42        let mut args = vec!["publish".to_string()];
43
44        if let Some(tag) = tag {
45            args.push("--tag".to_string());
46            args.push(tag.to_string());
47        }
48
49        if dry_run {
50            args.push("--dry-run".to_string());
51        }
52
53        if let Some(access) = access {
54            args.push("--access".to_string());
55            args.push(access.to_string());
56        }
57
58        args.push("--registry".to_string());
59        args.push(self.registry_url.clone());
60
61        let mut cmd = std::process::Command::new("npm");
62        cmd.args(&args)
63            .current_dir(&prepared_dir)
64            .stdin(std::process::Stdio::inherit())
65            .stdout(std::process::Stdio::inherit())
66            .stderr(std::process::Stdio::inherit());
67
68        if let Some(ref token) = self.auth_token {
69            cmd.env("NPM_TOKEN", token);
70            // Also write a temporary .npmrc for auth
71            let npmrc_content = format!(
72                "//{}/:_authToken={}\n",
73                self.registry_url
74                    .trim_start_matches("https://")
75                    .trim_start_matches("http://")
76                    .trim_end_matches('/'),
77                token
78            );
79            let npmrc_path = prepared_dir.join(".npmrc");
80            let _ = std::fs::write(&npmrc_path, npmrc_content);
81        }
82
83        let status = cmd
84            .status()
85            .map_err(|e| DnxError::Io(format!("Failed to run npm publish: {}", e)))?;
86
87        // Clean up prepared dir if it's different from source
88        if prepared_dir != package_dir {
89            let _ = std::fs::remove_dir_all(&prepared_dir);
90        }
91
92        if !status.success() {
93            return Err(DnxError::Registry(format!(
94                "npm publish failed with exit code {}",
95                status.code().unwrap_or(1)
96            )));
97        }
98
99        Ok(())
100    }
101
102    /// Publish all workspace packages that have unpublished versions.
103    pub fn publish_workspace(
104        &self,
105        workspace: &Workspace,
106        tag: Option<&str>,
107        dry_run: bool,
108        access: Option<&str>,
109    ) -> Result<PublishStats> {
110        let mut stats = PublishStats::default();
111
112        // Publish in topological order so dependencies are published first
113        for name in &workspace.topo_order {
114            if let Some(member) = workspace.members.get(name) {
115                // Skip private packages
116                if member.package_json.private == Some(true) {
117                    eprintln!("\x1b[34m   Skipping private package: {}\x1b[0m", name);
118                    stats.skipped += 1;
119                    continue;
120                }
121
122                match self.publish(&member.path, tag, dry_run, access) {
123                    Ok(()) => stats.published += 1,
124                    Err(e) => {
125                        eprintln!("\x1b[33m⚠  Failed to publish {}: {}\x1b[0m", name, e);
126                        stats.failed += 1;
127                    }
128                }
129            }
130        }
131
132        Ok(stats)
133    }
134
135    /// Create a tarball (.tgz) of the package without publishing.
136    pub fn pack(&self, package_dir: &Path, output_dir: Option<&Path>) -> Result<PathBuf> {
137        let pkg = PackageJson::read(&package_dir.join("package.json"))?;
138        let name = pkg
139            .name
140            .as_deref()
141            .unwrap_or("package")
142            .replace('/', "-")
143            .replace('@', "");
144        let version = pkg.version.as_deref().unwrap_or("0.0.0");
145        let tarball_name = format!("{}-{}.tgz", name, version);
146
147        let output = output_dir.unwrap_or(package_dir);
148        let tarball_path = output.join(&tarball_name);
149
150        // Use npm pack to create the tarball
151        let mut cmd = std::process::Command::new("npm");
152        cmd.args(["pack"])
153            .current_dir(package_dir)
154            .stdout(std::process::Stdio::piped())
155            .stderr(std::process::Stdio::piped());
156
157        let output_result = cmd
158            .output()
159            .map_err(|e| DnxError::Io(format!("Failed to run npm pack: {}", e)))?;
160
161        if !output_result.status.success() {
162            return Err(DnxError::Registry(format!(
163                "npm pack failed: {}",
164                String::from_utf8_lossy(&output_result.stderr)
165            )));
166        }
167
168        // npm pack outputs the tarball filename to stdout
169        let created_name = String::from_utf8_lossy(&output_result.stdout)
170            .trim()
171            .to_string();
172
173        // Move tarball to desired output location if needed
174        let created_path = package_dir.join(&created_name);
175        if created_path != tarball_path && created_path.exists() {
176            std::fs::rename(&created_path, &tarball_path)
177                .map_err(|e| DnxError::Io(format!("Failed to move tarball: {}", e)))?;
178        }
179
180        Ok(tarball_path)
181    }
182
183    /// Prepare a package for publishing by resolving workspace: and catalog: references.
184    /// Returns the path to the prepared package (may be a temp copy).
185    fn prepare_for_publish(&self, package_dir: &Path) -> Result<PathBuf> {
186        let pkg_path = package_dir.join("package.json");
187        let pkg = PackageJson::read(&pkg_path)?;
188
189        let needs_transform = has_protocol_refs(&pkg);
190
191        if !needs_transform {
192            return Ok(package_dir.to_path_buf());
193        }
194
195        // Create a temp directory with transformed package.json
196        let temp_dir = tempfile::tempdir()
197            .map_err(|e| DnxError::Io(format!("Failed to create temp dir: {}", e)))?;
198        let temp_path = temp_dir.path().to_path_buf();
199        // Keep the temp dir from being deleted on drop
200        std::mem::forget(temp_dir);
201
202        // Copy all files
203        copy_dir_recursive(package_dir, &temp_path)?;
204
205        // Transform package.json — replace workspace: and catalog: refs
206        let mut transformed = PackageJson::read(&temp_path.join("package.json"))?;
207        resolve_protocol_refs(&mut transformed);
208        transformed.write(&temp_path.join("package.json"))?;
209
210        Ok(temp_path)
211    }
212}
213
214#[derive(Debug, Default)]
215pub struct PublishStats {
216    pub published: usize,
217    pub failed: usize,
218    pub skipped: usize,
219}
220
221/// Check if any dependency uses workspace: or catalog: protocols.
222fn has_protocol_refs(pkg: &PackageJson) -> bool {
223    let check_map = |deps: &Option<HashMap<String, String>>| -> bool {
224        deps.as_ref()
225            .map(|d| {
226                d.values()
227                    .any(|v| v.starts_with("workspace:") || v.starts_with("catalog:"))
228            })
229            .unwrap_or(false)
230    };
231
232    check_map(&pkg.dependencies)
233        || check_map(&pkg.dev_dependencies)
234        || check_map(&pkg.peer_dependencies)
235        || check_map(&pkg.optional_dependencies)
236}
237
238/// Replace workspace: and catalog: references with actual version ranges.
239fn resolve_protocol_refs(pkg: &mut PackageJson) {
240    let transform = |deps: &mut Option<HashMap<String, String>>| {
241        if let Some(deps) = deps {
242            for (_name, spec) in deps.iter_mut() {
243                if spec.starts_with("workspace:") {
244                    let range = &spec[10..];
245                    *spec = match range {
246                        "*" | "^" | "~" => "*".to_string(),
247                        _ => range.to_string(),
248                    };
249                } else if spec.starts_with("catalog:") {
250                    // catalog: refs should have been resolved already during install
251                    // but if not, replace with *
252                    *spec = "*".to_string();
253                }
254            }
255        }
256    };
257
258    transform(&mut pkg.dependencies);
259    transform(&mut pkg.dev_dependencies);
260    transform(&mut pkg.peer_dependencies);
261    transform(&mut pkg.optional_dependencies);
262}
263
264/// Copy a directory recursively.
265fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
266    std::fs::create_dir_all(dst).map_err(|e| {
267        DnxError::Io(format!(
268            "Failed to create directory {}: {}",
269            dst.display(),
270            e
271        ))
272    })?;
273
274    for entry in std::fs::read_dir(src)
275        .map_err(|e| DnxError::Io(format!("Failed to read directory {}: {}", src.display(), e)))?
276    {
277        let entry = entry.map_err(|e| DnxError::Io(format!("Dir entry error: {}", e)))?;
278        let src_path = entry.path();
279        let dst_path = dst.join(entry.file_name());
280        let file_name = entry.file_name().to_string_lossy().to_string();
281
282        // Skip node_modules and .git
283        if file_name == "node_modules" || file_name == ".git" {
284            continue;
285        }
286
287        if entry
288            .file_type()
289            .map_err(|e| DnxError::Io(format!("File type error: {}", e)))?
290            .is_dir()
291        {
292            copy_dir_recursive(&src_path, &dst_path)?;
293        } else {
294            std::fs::copy(&src_path, &dst_path).map_err(|e| {
295                DnxError::Io(format!(
296                    "Failed to copy {} to {}: {}",
297                    src_path.display(),
298                    dst_path.display(),
299                    e
300                ))
301            })?;
302        }
303    }
304
305    Ok(())
306}