1use std::path::PathBuf;
6
7use async_trait::async_trait;
8
9use crate::backend::{Backend, Ctx, InstallCtx};
10use crate::error::{Error, Result};
11use crate::pipeline::{self, ArchiveKind, InstallPlan, PipelineCtx};
12use crate::platform::Os;
13use crate::source::Source;
14use crate::version::{ToolVersion, VersionInfo};
15
16pub struct PnpmBackend;
17
18impl PnpmBackend {
19 fn version_info(version: String) -> VersionInfo {
20 VersionInfo {
21 stable: semver::Version::parse(&version)
22 .map(|version| version.pre.is_empty())
23 .unwrap_or(false),
24 version,
25 lts: None,
26 }
27 }
28}
29
30#[async_trait]
31impl Backend for PnpmBackend {
32 fn id(&self) -> &str {
33 "pnpm"
34 }
35
36 fn default_sources(&self) -> Vec<Source> {
37 vec![
38 Source::mirror("npmmirror", "https://registry.npmmirror.com/", 5)
39 .with_index("https://registry.npmmirror.com/pnpm"),
40 Source::official("npm", "https://registry.npmjs.org/")
41 .with_index("https://registry.npmjs.org/pnpm"),
42 ]
43 }
44
45 fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
46 source.index_url.clone()
47 }
48
49 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
50 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
51 let versions = crate::npm::list_versions(ctx, &sources, "pnpm").await?;
52 Ok(versions.into_iter().map(Self::version_info).collect())
53 }
54
55 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
56 let ctx = ictx.ctx;
57 let plan = if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
58 plan
59 } else {
60 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
61 let dist = crate::npm::resolve_dist(ctx, &sources, "pnpm", &tv.version).await?;
62 InstallPlan {
63 tool: self.id().to_string(),
64 version: tv.version.clone(),
65 urls: dist.urls,
66 file_name: format!("pnpm-{}.tgz", tv.version),
67 kind: ArchiveKind::TarGz,
68 checksum: dist.checksum,
69 strip_root: true,
70 subdir: None,
71 }
72 };
73 let pctx = PipelineCtx {
74 client: &ctx.client,
75 dirs: &ctx.dirs,
76 cas: &ctx.cas,
77 link_mode: ctx.config.settings.link_mode,
78 show_progress: ctx.show_progress,
79 offline: ctx.config.settings.offline,
80 require_checksums: ctx.config.settings.require_checksums,
81 };
82 let install_dir = pipeline::run(&plan, &pctx).await?;
83 write_launchers(&install_dir.join("bin"), ctx.platform.os)?;
84 Ok(())
85 }
86
87 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
88 Ok(vec![ctx
89 .dirs
90 .install_path(self.id(), &tv.version)
91 .join("bin")])
92 }
93
94 fn exec_env(
95 &self,
96 ctx: &Ctx,
97 tv: &ToolVersion,
98 ) -> Result<std::collections::BTreeMap<String, String>> {
99 let mapping = cache_mapping(&tv.version);
100 Ok(crate::cache::manager_exec_env(
101 &ctx.dirs.cache,
102 &[("PNPM_HOME", "pnpm"), mapping],
103 ))
104 }
105
106 fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
107 let _ = (ctx, tv);
108 Ok(vec!["pnpm".into(), "pnpx".into()])
109 }
110}
111
112fn major_version(version: &str) -> u64 {
113 version
114 .trim_start_matches('v')
115 .split('.')
116 .next()
117 .and_then(|part| part.parse().ok())
118 .unwrap_or(0)
119}
120
121fn cache_mapping(version: &str) -> (&'static str, &'static str) {
122 if major_version(version) >= 11 {
123 ("pnpm_config_store_dir", "pnpm-store")
124 } else {
125 ("npm_config_store_dir", "pnpm-store")
126 }
127}
128
129#[cfg(unix)]
130fn write_launchers(bin_dir: &std::path::Path, _os: Os) -> Result<()> {
131 use std::os::unix::fs::PermissionsExt;
132 for name in ["pnpm", "pnpx"] {
133 let path = bin_dir.join(name);
134 let module = bin_dir.join(format!("{name}.mjs"));
135 if !module.is_file() {
136 return Err(Error::other(format!(
137 "pnpm distribution is missing {}",
138 module.display()
139 )));
140 }
141 let script = format!("#!/bin/sh\nexec node \"{}\" \"$@\"\n", module.display());
142 std::fs::write(&path, script).map_err(|error| Error::io(&path, error))?;
143 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
144 .map_err(|error| Error::io(&path, error))?;
145 }
146 Ok(())
147}
148
149#[cfg(windows)]
150fn write_launchers(bin_dir: &std::path::Path, _os: Os) -> Result<()> {
151 for name in ["pnpm", "pnpx"] {
152 let module = bin_dir.join(format!("{name}.mjs"));
153 if !module.is_file() {
154 return Err(Error::other(format!(
155 "pnpm distribution is missing {}",
156 module.display()
157 )));
158 }
159 let path = bin_dir.join(format!("{name}.cmd"));
160 let script = format!("@echo off\r\nnode \"%~dp0{name}.mjs\" %*\r\n");
161 std::fs::write(&path, script).map_err(|error| Error::io(&path, error))?;
162 }
163 Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::version::{select_version, VersionSpec};
170
171 #[test]
172 fn latest_ignores_newer_prerelease_versions() {
173 let versions = ["11.22.0", "12.0.0-alpha.21"]
174 .into_iter()
175 .map(|version| PnpmBackend::version_info(version.into()))
176 .collect::<Vec<_>>();
177
178 assert_eq!(
179 select_version(&VersionSpec::Latest, &versions)
180 .unwrap()
181 .version,
182 "11.22.0"
183 );
184 }
185
186 #[test]
187 fn selects_version_specific_store_environment_key() {
188 assert_eq!(
189 cache_mapping("10.15.0"),
190 ("npm_config_store_dir", "pnpm-store")
191 );
192 assert_eq!(
193 cache_mapping("11.0.0-rc.1"),
194 ("pnpm_config_store_dir", "pnpm-store")
195 );
196 assert_eq!(
197 cache_mapping("v12.1.0"),
198 ("pnpm_config_store_dir", "pnpm-store")
199 );
200 }
201
202 #[test]
203 fn exposes_pnpx_as_a_routing_alias() {
204 let context = ctx();
205 assert_eq!(
206 PnpmBackend
207 .bin_names(&context, &ToolVersion::new("pnpm", "11.24.0"))
208 .unwrap(),
209 vec!["pnpm".to_string(), "pnpx".to_string()]
210 );
211 }
212
213 #[test]
214 fn launchers_target_complete_distribution_modules() {
215 let temporary = tempfile::tempdir().unwrap();
216 let bin = temporary.path().join("bin");
217 std::fs::create_dir(&bin).unwrap();
218 std::fs::write(bin.join("pnpm.mjs"), b"export {};").unwrap();
219 std::fs::write(bin.join("pnpx.mjs"), b"export {};").unwrap();
220 write_launchers(&bin, Os::Linux).unwrap();
221 #[cfg(unix)]
222 {
223 let pnpm = std::fs::read_to_string(bin.join("pnpm")).unwrap();
224 assert!(pnpm.contains("bin/pnpm.mjs"), "{pnpm}");
225 assert!(std::fs::metadata(bin.join("pnpm")).unwrap().is_file());
226 }
227 #[cfg(windows)]
228 {
229 let pnpm = std::fs::read_to_string(bin.join("pnpm.cmd")).unwrap();
230 assert!(pnpm.contains("%~dp0pnpm.mjs"), "{pnpm}");
231 assert!(std::fs::metadata(bin.join("pnpm.cmd")).unwrap().is_file());
232 }
233 }
234
235 fn ctx() -> Ctx {
236 let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
237 "OSDK_DATA_DIR" => Some("/tmp/osdk-pnpm-test/data".into()),
238 "OSDK_CACHE_DIR" => Some("/tmp/osdk-pnpm-test/cache".into()),
239 "OSDK_CONFIG_DIR" => Some("/tmp/osdk-pnpm-test/config".into()),
240 _ => None,
241 })
242 .unwrap();
243 Ctx {
244 dirs: dirs.clone(),
245 platform: crate::platform::Platform::current(),
246 config: crate::config::Config {
247 settings: Default::default(),
248 sources: Default::default(),
249 tools: Default::default(),
250 tool_configs: Default::default(),
251 global_tools: Default::default(),
252 global_tool_configs: Default::default(),
253 tool_origins: Default::default(),
254 aliases: Default::default(),
255 project_config_path: None,
256 },
257 client: reqwest::Client::new(),
258 cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store)),
259 show_progress: false,
260 }
261 }
262}