ephemeris_release/adapters/npm/
publisher.rs1use std::ffi::OsStr;
4use std::io::Write as _;
5use std::path::Path;
6use std::process::Command;
7
8use ephemeris_token::Token;
9use reqwest::Url;
10use tempfile::NamedTempFile;
11
12use super::NpmError;
13use crate::PackageRegistry;
14use crate::error::PackageFailure;
15use crate::state::ReleaseTarget;
16use crate::unit::Unit;
17
18#[derive(Debug)]
20pub(crate) struct NpmPublisher<N> {
21 registry: Option<N>,
22 token: Option<Token>,
23 program: &'static str,
24}
25
26struct Npmrc(NamedTempFile);
28
29impl<N> NpmPublisher<N> {
30 #[must_use]
32 pub(crate) const fn new(registry: Option<N>) -> Self {
33 Self {
34 registry,
35 token: None,
36 program: "npm",
37 }
38 }
39
40 #[must_use]
42 pub(crate) fn authenticated(mut self, token: Token) -> Self {
43 self.token = Some(token);
44 self
45 }
46}
47
48impl<N: PackageRegistry> NpmPublisher<N> {
49 pub(crate) async fn is_published(&self, unit: &Unit) -> Result<bool, NpmError> {
50 if !publishes(unit) {
51 return Ok(true);
52 }
53 let registry = self.registry(unit)?;
54 let name = unit
55 .package_name
56 .as_deref()
57 .ok_or_else(|| NpmError::MissingPackage(unit.name.clone()))?;
58 registry
59 .package_exists(name, &unit.version.to_string())
60 .await
61 .map_err(|error| NpmError::Registry(PackageFailure::new(error)))
62 }
63
64 fn registry(&self, unit: &Unit) -> Result<&N, NpmError> {
65 self.registry
66 .as_ref()
67 .ok_or_else(|| NpmError::MissingRegistry {
68 unit: unit.name.clone(),
69 })
70 }
71
72 pub(crate) async fn publish(&self, unit: &Unit, file: &Path) -> Result<(), NpmError> {
73 if !publishes(unit) {
74 return Ok(());
75 }
76 let token = self.token.as_ref().ok_or(NpmError::MissingToken)?;
77 self.publish_with(token, unit, file).await
78 }
79
80 async fn publish_with(&self, token: &Token, unit: &Unit, file: &Path) -> Result<(), NpmError> {
81 if !publishes(unit) {
82 return Ok(());
83 }
84 let registry_url =
85 self.registry(unit)?
86 .base_url()
87 .ok_or_else(|| NpmError::MissingRegistry {
88 unit: unit.name.clone(),
89 })?;
90 let npmrc = Npmrc::new(registry_url, token)?;
91 let Err(error) = self.run(file, registry_url.as_str(), &npmrc) else {
92 return Ok(());
93 };
94 if self
97 .is_published(unit)
98 .await
99 .is_ok_and(|published| published)
100 {
101 Ok(())
102 } else {
103 Err(error)
104 }
105 }
106
107 fn run(&self, file: &Path, registry_url: &str, npmrc: &Npmrc) -> Result<(), NpmError> {
108 let file = file.to_string_lossy();
109 let arguments = ["publish", file.as_ref(), "--registry", registry_url];
110 let owned = || arguments.iter().map(ToString::to_string).collect();
111 let output = Command::new(self.program)
112 .args(arguments)
113 .env("NPM_CONFIG_USERCONFIG", npmrc.path())
114 .output()
115 .map_err(|source| NpmError::Spawn {
116 arguments: owned(),
117 source,
118 })?;
119 if output.status.success() {
120 return Ok(());
121 }
122 Err(NpmError::Failed {
123 arguments: owned(),
124 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
125 })
126 }
127}
128
129impl Npmrc {
130 fn new(registry_url: &Url, token: &Token) -> Result<Self, NpmError> {
131 let line = Self::line(registry_url, token)?;
132 let mut file = NamedTempFile::new().map_err(NpmError::Npmrc)?;
133 writeln!(file, "{line}").map_err(NpmError::Npmrc)?;
134 file.flush().map_err(NpmError::Npmrc)?;
135 Ok(Self(file))
136 }
137
138 fn line(url: &Url, token: &Token) -> Result<String, NpmError> {
141 let host = url
142 .host_str()
143 .ok_or_else(|| NpmError::RegistryWithoutHost {
144 url: url.to_string(),
145 })?;
146 let port = url
147 .port()
148 .map(|port| format!(":{port}"))
149 .unwrap_or_default();
150 let slash = if url.path().ends_with('/') { "" } else { "/" };
151 Ok(format!(
152 "//{host}{port}{}{slash}:_authToken={}",
153 url.path(),
154 token.expose()
155 ))
156 }
157
158 fn path(&self) -> &OsStr {
159 self.0.path().as_os_str()
160 }
161}
162
163const fn publishes(unit: &Unit) -> bool {
165 matches!(
166 &unit.target,
167 ReleaseTarget::Npm {
168 manifest: _,
169 publish: true,
170 }
171 )
172}
173
174#[cfg(test)]
175mod tests {
176 use std::cell::Cell;
177 use std::collections::BTreeMap;
178 use std::convert::Infallible;
179 use std::fs;
180 use std::path::Path;
181
182 use ephemeris_token::Token;
183 use reqwest::Url;
184
185 use super::{NpmError, NpmPublisher, Npmrc};
186 use crate::PackageRegistry;
187 use crate::state::ReleaseTarget;
188 use crate::unit::Unit;
189
190 struct VisibleFrom {
191 lookups: Cell<usize>,
192 first_visible: usize,
193 base: Option<Url>,
194 }
195
196 impl PackageRegistry for VisibleFrom {
197 type Error = Infallible;
198
199 async fn package_exists(&self, _: &str, _: &str) -> Result<bool, Self::Error> {
200 let lookup = self.lookups.get();
201 self.lookups.set(lookup.saturating_add(1));
202 Ok(lookup >= self.first_visible)
203 }
204
205 fn base_url(&self) -> Option<&Url> {
206 self.base.as_ref()
207 }
208 }
209
210 fn unit(publish: bool) -> Unit {
211 Unit {
212 name: "web".into(),
213 package_name: Some("@acme/web".into()),
214 version: "1.0.0".parse().unwrap(),
215 target: ReleaseTarget::Npm {
216 manifest: "web/package.json".parse().unwrap(),
217 publish,
218 },
219 source: vec!["web".parse().unwrap()].try_into().unwrap(),
220 changelog: Some("web/CHANGELOG.md".parse().unwrap()),
221 tag_prefix: "web-v".into(),
222 dependencies: Vec::new(),
223 pins: BTreeMap::new(),
224 revision: None,
225 }
226 }
227
228 fn publisher(first_visible: usize) -> NpmPublisher<VisibleFrom> {
229 let mut publisher = NpmPublisher::new(Some(VisibleFrom {
230 lookups: Cell::new(0),
231 first_visible,
232 base: Some(
233 "https://forge.example/api/packages/acme/npm/"
234 .parse()
235 .unwrap(),
236 ),
237 }));
238 publisher.program = "false";
239 publisher
240 }
241
242 #[test]
243 fn the_npmrc_line_names_the_registry_without_its_scheme() {
244 let token = Token::new("secret");
245 let line = |url: &str| Npmrc::line(&url.parse().unwrap(), &token);
246 assert_eq!(
247 line("https://forge.example/api/packages/acme/npm").unwrap(),
248 "//forge.example/api/packages/acme/npm/:_authToken=secret"
249 );
250 assert_eq!(
251 line("http://127.0.0.1:3000/").unwrap(),
252 "//127.0.0.1:3000/:_authToken=secret"
253 );
254 assert!(matches!(
255 line("mailto:ops@example.org"),
256 Err(NpmError::RegistryWithoutHost { url: _ })
257 ));
258 }
259
260 #[test]
261 fn the_npmrc_file_holds_the_line() {
262 let npmrc = Npmrc::new(
263 &"https://forge.example/npm/".parse().unwrap(),
264 &Token::new("secret"),
265 )
266 .unwrap();
267 assert_eq!(
268 fs::read_to_string(npmrc.path()).unwrap(),
269 "//forge.example/npm/:_authToken=secret\n"
270 );
271 }
272
273 #[tokio::test]
274 async fn a_unit_that_does_not_publish_counts_as_published() {
275 let publisher = publisher(usize::MAX);
276 assert!(publisher.is_published(&unit(false)).await.unwrap());
277 assert_eq!(publisher.registry.as_ref().unwrap().lookups.get(), 0);
278 assert!(!publisher.is_published(&unit(true)).await.unwrap());
279 }
280
281 #[tokio::test]
282 async fn failed_publish_of_a_version_now_visible_counts_as_published() {
283 let publisher = publisher(0);
284 publisher
285 .publish_with(&Token::new("secret"), &unit(true), Path::new("web.tgz"))
286 .await
287 .unwrap();
288 assert_eq!(publisher.registry.as_ref().unwrap().lookups.get(), 1);
289 }
290
291 #[tokio::test]
292 async fn failed_publish_of_an_absent_version_is_an_error() {
293 assert!(matches!(
294 publisher(usize::MAX)
295 .publish_with(&Token::new("secret"), &unit(true), Path::new("web.tgz"))
296 .await,
297 Err(NpmError::Failed {
298 arguments: _,
299 stderr: _
300 })
301 ));
302 }
303
304 #[tokio::test]
305 async fn a_publish_without_a_configured_registry_is_an_error() {
306 let publisher = NpmPublisher::new(Some(VisibleFrom {
307 lookups: Cell::new(0),
308 first_visible: 0,
309 base: None,
310 }));
311 assert!(matches!(
312 publisher
313 .publish_with(&Token::new("secret"), &unit(true), Path::new("web.tgz"))
314 .await,
315 Err(NpmError::MissingRegistry { unit: _ })
316 ));
317 }
318
319 #[tokio::test]
320 async fn an_unconfigured_registry_fails_before_any_lookup() {
321 let publisher = NpmPublisher::new(None::<VisibleFrom>);
322 assert!(matches!(
323 publisher.is_published(&unit(true)).await,
324 Err(NpmError::MissingRegistry { unit: _ })
325 ));
326 }
327}