Skip to main content

osdk_core/backend/
pnpm.rs

1//! pnpm backend: installs pnpm's complete JavaScript distribution from the npm
2//! registry with first-party SRI verification. osdk supplies the exact managed
3//! Node runtime and creates portable launchers for `pnpm` and `pnpx`.
4
5use 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}