1use crate::errors::{DnxError, Result};
2use crate::package_json::PackageJson;
3use crate::workspace::Workspace;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7pub 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 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 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 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 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 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 for name in &workspace.topo_order {
114 if let Some(member) = workspace.members.get(name) {
115 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 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 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 let created_name = String::from_utf8_lossy(&output_result.stdout)
170 .trim()
171 .to_string();
172
173 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 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 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 std::mem::forget(temp_dir);
201
202 copy_dir_recursive(package_dir, &temp_path)?;
204
205 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
221fn 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
238fn 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 *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
264fn 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 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}